Merge remote-tracking branch 'origin/void/login-training-notice' into void/preview-all
This commit is contained in:
commit
469cd83c9f
7 changed files with 176 additions and 1 deletions
25
src/app/features/room/RoomTrainingLine.tsx
Normal file
25
src/app/features/room/RoomTrainingLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
41
src/app/pages/auth/TrainingNotice.tsx
Normal file
41
src/app/pages/auth/TrainingNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
68
src/app/utils/trainingNotice.test.ts
Normal file
68
src/app/utils/trainingNotice.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
src/app/utils/trainingNotice.ts
Normal file
35
src/app/utils/trainingNotice.ts
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue