Merge remote-tracking branch 'origin/void/login-training-notice' into void/preview-all

This commit is contained in:
Void 2026-11-07 18:02:59 +00:00
commit 469cd83c9f
7 changed files with 176 additions and 1 deletions

View file

@ -0,0 +1,25 @@
import { Text, color, config } from 'folds';
import { Room } from 'matrix-js-sdk';
import React from 'react';
import { useStateEvent } from '../../hooks/useStateEvent';
import {
ROOM_NOTICE_EVENT_TYPE,
parseRoomNotice,
roomTrainingLine,
} from '../../utils/trainingNotice';
// The room's notice state event decides the line; no event, or a malformed one, no line. The
// collector reads the version from the same event, so nothing is stored in the browser.
export function RoomTrainingLine({ room }: { room: Room }) {
const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE);
const line = roomTrainingLine(parseRoomNotice(event?.getContent()));
if (!line) return null;
return (
<Text
size="T200"
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
>
{line}
</Text>
);
}

View file

@ -13,6 +13,7 @@ import { RoomTimeline } from './RoomTimeline';
import { RoomViewTyping } from './RoomViewTyping';
import { RoomTombstone } from './RoomTombstone';
import { RoomInput } from './RoomInput';
import { RoomTrainingLine } from './RoomTrainingLine';
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
import { Page } from '../../components/page';
import { useKeyDown } from '../../hooks/useKeyDown';
@ -104,6 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
</Box>
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomTrainingLine room={room} />
{tombstoneEvent ? (
<RoomTombstone
roomId={roomId}

View file

@ -0,0 +1,41 @@
import { Box, Checkbox, Text, color, config } from 'folds';
import React from 'react';
import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice';
type TrainingNoticeProps = {
checked: boolean;
onToggle: () => void;
};
// Registration only: ticking it is the newcomer's acceptance of the current notice version.
export function TrainingNotice({ checked, onToggle }: TrainingNoticeProps) {
return (
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S300,
borderRadius: config.radii.R300,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
}}
>
<Text
as="label"
size="T300"
style={{ display: 'flex', alignItems: 'center', gap: config.space.S200 }}
>
<Checkbox
name="trainingAck"
size="300"
variant="Primary"
required
checked={checked}
onClick={onToggle}
/>
{TRAINING_NOTICE_TEXT}
</Text>
</Box>
);
}

View file

@ -32,6 +32,7 @@ import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsy
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import { FieldError } from '../FiledError';
import { TrainingNotice } from '../TrainingNotice';
import {
AutoDummyStageDialog,
AutoTermsStageDialog,
@ -192,6 +193,7 @@ export function PasswordRegisterForm({
const [formData, setFormData] = useState<FormData>();
const [ongoingFlow, setOngoingFlow] = useState<UIAFlow>();
const [trainingAck, setTrainingAck] = useState(false);
const [registerEmailState, registerEmail] = useRegisterEmail(mx);
@ -389,7 +391,8 @@ export function PasswordRegisterForm({
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
)}
<span data-spacing-node />
<Button variant="Primary" size="500" type="submit">
<TrainingNotice checked={trainingAck} onToggle={() => setTrainingAck((ack) => !ack)} />
<Button variant="Primary" size="500" type="submit" disabled={!trainingAck}>
<Text as="span" size="B500">
Register
</Text>

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
MAX_NOTICE_VERSION,
ROOM_NOTICE_EVENT_TYPE,
TRAINING_NOTICE_TEXT,
parseRoomNotice,
roomTrainingLine,
} from './trainingNotice';
const lineFor = (content: unknown) => roomTrainingLine(parseRoomNotice(content));
describe('training notice', () => {
it('says exactly the ruled sentence and never mentions Discord', () => {
expect(TRAINING_NOTICE_TEXT).toBe(
"Everything said here may be used to train GameCult's AI stewards."
);
expect(TRAINING_NOTICE_TEXT.toLowerCase()).not.toContain('discord');
});
it('reads the notice from the org.gamecult.notice state event', () => {
expect(ROOM_NOTICE_EVENT_TYPE).toBe('org.gamecult.notice');
});
it('shows the train line only where the room declares train', () => {
expect(parseRoomNotice({ version: 2, scope: 'train' })).toEqual({ version: 2, scope: 'train' });
expect(lineFor({ version: 2, scope: 'train' })).toBe(TRAINING_NOTICE_TEXT);
expect(lineFor({ version: 1, scope: 'study' })).toBeUndefined();
expect(lineFor({ version: 1, scope: 'show' })).toBeUndefined();
});
it('shows nothing where the notice is absent', () => {
expect(lineFor(undefined)).toBeUndefined();
expect(lineFor(null)).toBeUndefined();
expect(lineFor({})).toBeUndefined();
});
it('shows nothing where the notice is malformed in any way', () => {
expect(lineFor('train')).toBeUndefined();
expect(lineFor({ scope: 'train' })).toBeUndefined();
expect(lineFor({ version: 1 })).toBeUndefined();
expect(lineFor({ version: 0, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: -1, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: 1.5, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: '1', scope: 'train' })).toBeUndefined();
expect(lineFor({ version: null, scope: 'train' })).toBeUndefined();
});
it('rejects a version written with leading zeros', () => {
expect(lineFor({ version: '01', scope: 'train' })).toBeUndefined();
expect(lineFor({ version: '007', scope: 'train' })).toBeUndefined();
});
it('bounds the version at 4294967295', () => {
expect(MAX_NOTICE_VERSION).toBe(4294967295);
expect(lineFor({ version: 4294967295, scope: 'train' })).toBe(TRAINING_NOTICE_TEXT);
expect(parseRoomNotice({ version: 4294967295, scope: 'train' })?.version).toBe(4294967295);
expect(lineFor({ version: 4294967296, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: Number.MAX_SAFE_INTEGER, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: 4294967295.5, scope: 'train' })).toBeUndefined();
expect(lineFor({ version: '4294967295', scope: 'train' })).toBeUndefined();
});
it('rejects an unknown or wrongly cased scope', () => {
expect(lineFor({ version: 1, scope: 'everything' })).toBeUndefined();
expect(lineFor({ version: 1, scope: 'Train' })).toBeUndefined();
expect(lineFor({ version: 1, scope: ['train'] })).toBeUndefined();
});
});

View file

@ -0,0 +1,35 @@
import { StateEvent } from '../../types/matrix/room';
export const TRAINING_NOTICE_TEXT =
"Everything said here may be used to train GameCult's AI stewards.";
/**
* Heimdall's ruling: a room's notice is a room state event, not a pinned message. Type below,
* state_key '', content `{version, scope}`; scope exactly one of show, study or train. Heimdall's
* one version rule: a positive integer, no leading zeros, no greater than 4294967295; anything
* else is malformed. Absent or malformed in any way means no train line.
*/
export const MAX_NOTICE_VERSION = 4294967295;
export const ROOM_NOTICE_EVENT_TYPE = StateEvent.GameCultNotice;
export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' };
/** The notice a state event's content declares, or undefined: no scope is ever inferred. */
export const parseRoomNotice = (content: unknown): RoomNotice | undefined => {
if (typeof content !== 'object' || content === null) return undefined;
const { version, scope } = content as { version?: unknown; scope?: unknown };
if (
typeof version === 'number' &&
Number.isSafeInteger(version) &&
version > 0 &&
version <= MAX_NOTICE_VERSION &&
(scope === 'show' || scope === 'study' || scope === 'train')
) {
return { version, scope };
}
return undefined;
};
/** The line a room shows: only a notice that says `train` in terms gets the train sentence. */
export const roomTrainingLine = (notice: RoomNotice | undefined): string | undefined =>
notice?.scope === 'train' ? TRAINING_NOTICE_TEXT : undefined;

View file

@ -23,6 +23,7 @@ export enum StateEvent {
RoomMember = 'm.room.member',
RoomThirdPartyInvite = 'm.room.third_party_invite',
RoomPowerLevels = 'm.room.power_levels',
GameCultNotice = 'org.gamecult.notice',
RoomName = 'm.room.name',
RoomTopic = 'm.room.topic',
RoomAvatar = 'm.room.avatar',