From 3fcf1cf9dd441260f2609e8df4e2d1826cf48d24 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 22:27:47 +0000 Subject: [PATCH] auth: train line comes from the room's pinned notice; drop browser-side acceptance The collector reads the notice version from the room's pinned notice and takes the member's first reply as the acceptance (Heimdall answer-collector-consent.md), so a localStorage record at registration was a second, unread copy. Deleted it and its unused re-ask helpers. RoomTrainingLine now reads the room's own pinned notice and shows the train sentence only where that notice declares train; no notice, no line (DMs included). The pinned event's encoding is not fixed by the doc; the key is provisional and isolated in trainingNotice.ts. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomTrainingLine.tsx | 45 +++++++++++++-- src/app/features/room/RoomView.tsx | 2 +- .../auth/register/PasswordRegisterForm.tsx | 2 - src/app/utils/trainingNotice.test.ts | 49 ++++++---------- src/app/utils/trainingNotice.ts | 57 ++++++++----------- 5 files changed, 80 insertions(+), 75 deletions(-) diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index 21877c3a..8855f0ff 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -1,15 +1,50 @@ import { Text, color, config } from 'folds'; -import React from 'react'; -import { TRAINING_NOTICE_TEXT } from '../../utils/trainingNotice'; +import { Room } from 'matrix-js-sdk'; +import React, { useEffect, useState } from 'react'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents'; +import { RoomNotice, parseRoomNotice, roomTrainingLine } from '../../utils/trainingNotice'; -// One line per room, pinned above the composer. -export function RoomTrainingLine() { +// The room's own pinned notice decides the line, newest pin first; no notice, no line. The version +// is read by the collector from the same pinned event, so nothing is stored in the browser. +const useRoomNotice = (room: Room): RoomNotice | undefined => { + const mx = useMatrixClient(); + const pinned = useRoomPinnedEvents(room); + const [notice, setNotice] = useState(); + + useEffect(() => { + let live = true; + setNotice(undefined); + (async () => { + for (let i = pinned.length - 1; i >= 0; i -= 1) { + const id = pinned[i]; + const local = room.findEventById(id)?.getContent(); + // eslint-disable-next-line no-await-in-loop + const content = local ?? (await mx.fetchRoomEvent(room.roomId, id).catch(() => undefined))?.content; + const found = parseRoomNotice(content); + if (found) { + if (live) setNotice(found); + return; + } + } + })(); + return () => { + live = false; + }; + }, [mx, room, pinned]); + + return notice; +}; + +export function RoomTrainingLine({ room }: { room: Room }) { + const line = roomTrainingLine(useRoomNotice(room)); + if (!line) return null; return ( - {TRAINING_NOTICE_TEXT} + {line} ); } diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 6e2a315a..3fbd81fa 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -105,7 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
- + {tombstoneEvent ? ( { - const data = new Map(); - return { - getItem: (k: string) => data.get(k) ?? null, - setItem: (k: string, v: string) => void data.set(k, v), - }; -}; +const pinned = (notice: unknown) => ({ body: 'sample', [ROOM_NOTICE_CONTENT_KEY]: notice }); describe('training notice', () => { it('says exactly the ruled sentence and never mentions Discord', () => { @@ -23,29 +16,19 @@ describe('training notice', () => { expect(TRAINING_NOTICE_TEXT.toLowerCase()).not.toContain('discord'); }); - it('records the current version and time on acceptance', () => { - const storage = memoryStorage(); - recordNoticeAcceptance(storage, new Date('2026-11-06T20:00:00Z')); - expect(getNoticeAcceptance(storage)).toEqual({ - noticeVersion: TRAINING_NOTICE_VERSION, - acceptedAt: '2026-11-06T20:00:00.000Z', - }); - expect(needsFreshAcceptance(storage)).toBe(false); + it('shows the train line only where the room declares train', () => { + const train = parseRoomNotice(pinned({ version: 2, scope: 'train' })); + expect(train).toEqual({ version: 2, scope: 'train' }); + expect(roomTrainingLine(train)).toBe(TRAINING_NOTICE_TEXT); + expect(roomTrainingLine(parseRoomNotice(pinned({ version: 1, scope: 'study' })))).toBeUndefined(); + expect(roomTrainingLine(parseRoomNotice(pinned({ version: 1, scope: 'show' })))).toBeUndefined(); }); - it('asks again when the notice version changes', () => { - const storage = memoryStorage(); - recordNoticeAcceptance(storage, new Date(), TRAINING_NOTICE_VERSION); - expect(needsFreshAcceptance(storage, TRAINING_NOTICE_VERSION + 1)).toBe(true); - }); - - it('does not re-ask members who never registered here (no record)', () => { - expect(needsFreshAcceptance(memoryStorage())).toBe(false); - }); - - it('treats a corrupt record as none', () => { - const storage = memoryStorage(); - storage.setItem('trainingNoticeAcceptance', '{nope'); - expect(getNoticeAcceptance(storage)).toBeUndefined(); + it('shows nothing where no notice is declared, and never infers train', () => { + expect(roomTrainingLine(undefined)).toBeUndefined(); + expect(parseRoomNotice({ body: 'plain message' })).toBeUndefined(); + expect(parseRoomNotice(pinned({ scope: 'train' }))).toBeUndefined(); + expect(parseRoomNotice(pinned({ version: 0, scope: 'train' }))).toBeUndefined(); + expect(parseRoomNotice(pinned({ version: 1, scope: 'everything' }))).toBeUndefined(); }); }); diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 709476b9..c5cd7aa1 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -1,43 +1,32 @@ export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; -// Any change to the wording or scope is a new version, and a new version needs a fresh acceptance. -export const TRAINING_NOTICE_VERSION = 1; +/** + * Where a room declares its notice. Heimdall's answer-collector-consent.md says the notice is pinned + * with the sample and carries `notice_version` and a scope, but does not fix the pinned event's + * encoding; this key is this client's provisional reading of it, and the one place to change. + */ +export const ROOM_NOTICE_CONTENT_KEY = 'org.gamecult.notice'; -const ACCEPTANCE_KEY = 'trainingNoticeAcceptance'; +export type RoomNotice = { version: number; scope: 'show' | 'study' | 'train' }; -export type NoticeAcceptance = { noticeVersion: number; acceptedAt: string }; - -type NoticeStorage = Pick; - -/** Written at registration only, so only newcomers have one; earlier members are never re-asked. */ -export const recordNoticeAcceptance = ( - storage: NoticeStorage, - now: Date = new Date(), - noticeVersion: number = TRAINING_NOTICE_VERSION -): NoticeAcceptance => { - const acceptance = { noticeVersion, acceptedAt: now.toISOString() }; - storage.setItem(ACCEPTANCE_KEY, JSON.stringify(acceptance)); - return acceptance; -}; - -export const getNoticeAcceptance = (storage: NoticeStorage): NoticeAcceptance | undefined => { - try { - const parsed = JSON.parse(storage.getItem(ACCEPTANCE_KEY) ?? 'null'); - if (typeof parsed?.noticeVersion === 'number' && typeof parsed?.acceptedAt === 'string') { - return parsed; - } - } catch { - // unreadable record counts as none +/** The notice a pinned event declares, or undefined: no scope is ever inferred. */ +export const parseRoomNotice = (content: unknown): RoomNotice | undefined => { + const raw = (content as Record | undefined)?.[ROOM_NOTICE_CONTENT_KEY] as + | { version?: unknown; scope?: unknown } + | undefined; + if ( + raw && + typeof raw.version === 'number' && + Number.isInteger(raw.version) && + raw.version > 0 && + (raw.scope === 'show' || raw.scope === 'study' || raw.scope === 'train') + ) { + return { version: raw.version, scope: raw.scope }; } return undefined; }; -/** True when a newcomer's recorded acceptance is for an older notice version and must be renewed. */ -export const needsFreshAcceptance = ( - storage: NoticeStorage, - currentVersion: number = TRAINING_NOTICE_VERSION -): boolean => { - const accepted = getNoticeAcceptance(storage); - return accepted !== undefined && accepted.noticeVersion !== currentVersion; -}; +/** 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;