From 8bbb436ca7d2375c2ad718a091f268e800fd3762 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:40:03 -0400 Subject: [PATCH] fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322) The tray only knew what this browser had scheduled (localStorage), but Synapse lists every pending one via MSC4140 GET /delayed_events. Load it when the tray mounts and on window focus (localStorage stays as a first- paint cache), keeping only non-state m.room.message events. Logout now says "N scheduled messages still send after you log out" and offers to cancel them. Fixes #322 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/LogoutDialog.tsx | 68 +++++++++++++++---- .../features/room/ScheduledMessagesTray.tsx | 27 +++++++- src/app/state/scheduledMessages.ts | 4 +- src/app/utils/scheduledMessages.test.ts | 60 ++++++++++++++++ src/app/utils/scheduledMessages.ts | 59 ++++++++++++++++ 5 files changed, 201 insertions(+), 17 deletions(-) diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index 921e595ec..0dc15ae98 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback } from 'react'; +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { useAtom } from 'jotai'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient'; import { useModalStyle } from '../hooks/useModalStyle'; import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { InfoCard } from './info-card'; +import { ScheduledMessage } from '../state/scheduledMessages'; +import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages'; import { useDeviceVerificationStatus, VerificationStatus, @@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef( const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom); - const [logoutState, logout] = useAsyncCallback( - useCallback(async () => { - // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC - // membership (expires: 4 h) stays behind as a ghost participant and - // everyone else sees you "in call" until it times out. - if (callEmbed && callEmbed.joined && !callEmbed.disposed) { - await hangupCallAndWait(mx, callEmbed); - setCallEmbed(undefined); - } - await logoutClient(mx); - }, [mx, callEmbed, setCallEmbed]), + // [Gitea #322] Scheduled messages live on the server and still send after + // logout, where no client would show them. Say so, and offer to cancel. + const [scheduled, setScheduled] = useState([]); + useEffect(() => { + let alive = true; + fetchScheduledMessages(mx) + .then((list) => alive && setScheduled(list)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [mx]); + + const [logoutState, logout] = useAsyncCallback( + useCallback( + async (cancelScheduled?: boolean) => { + if (cancelScheduled) { + await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId))); + } + // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC + // membership (expires: 4 h) stays behind as a ghost participant and + // everyone else sees you "in call" until it times out. + if (callEmbed && callEmbed.joined && !callEmbed.disposed) { + await hangupCallAndWait(mx, callEmbed); + setCallEmbed(undefined); + } + await logoutClient(mx); + }, + [mx, callEmbed, setCallEmbed, scheduled], + ), ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef( description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." /> ))} + {scheduled.length > 0 && ( + + )} You’re about to log out. Are you sure? {logoutState.status === AsyncStatus.Error && ( @@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef( + {scheduled.length > 0 && ( + + )} diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 46866bd22..c66563a1b 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; -import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; +import { + cancelScheduledMessage, + fetchScheduledMessages, + groupScheduledByRoom, + sendScheduledMessageNow, +} from '../../utils/scheduledMessages'; import { ScheduleMessageModal } from './ScheduleMessageModal'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { formatFriendlyDateTime } from '../../utils/datetimeInput'; @@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); + // [Gitea #322] The server is the source of truth: messages scheduled on + // another device (or before a logout) show up here and can be cancelled. + // localStorage is only a cache for the first paint; on error we keep it. + useEffect(() => { + let alive = true; + const load = () => { + fetchScheduledMessages(mx) + .then((list) => { + if (alive) setScheduledMessages(groupScheduledByRoom(list)); + }) + .catch(() => undefined); + }; + load(); + window.addEventListener('focus', load); + return () => { + alive = false; + window.removeEventListener('focus', load); + }; + }, [mx, roomId, setScheduledMessages]); + // Remove scheduled messages whose time has passed useEffect(() => { if (messages.length === 0) return undefined; diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts index ddceb704b..6eaf0eb5b 100644 --- a/src/app/state/scheduledMessages.ts +++ b/src/app/state/scheduledMessages.ts @@ -21,8 +21,8 @@ const internalAtom = atomWithStorage>( /** * Global atom: Map * Stores all locally-tracked scheduled messages across rooms. - * MSC4140 has no list endpoint, so we track them ourselves. - * Backed by localStorage so scheduled messages survive page refreshes. + * Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts + * (Gitea #322); localStorage is a cache for the first paint. */ export const scheduledMessagesAtom = atom( (get): Map => new Map(Object.entries(get(internalAtom))), diff --git a/src/app/utils/scheduledMessages.test.ts b/src/app/utils/scheduledMessages.test.ts index 69869ba31..c3328fd5f 100644 --- a/src/app/utils/scheduledMessages.test.ts +++ b/src/app/utils/scheduledMessages.test.ts @@ -6,6 +6,8 @@ import { scheduleMessage, cancelScheduledMessage, restartScheduledMessage, + parseDelayedEvents, + groupScheduledByRoom, } from './scheduledMessages'; // Minimal MatrixClient stub: records every authedRequest call and returns a @@ -119,3 +121,61 @@ test('cancel/restart url-encode the delay id', async () => { await cancelScheduledMessage(mx, 'a/b c'); assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c'); }); + +test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => { + const res = { + delayed_events: [ + { + delay_id: 'syd_a', + room_id: '!r:x', + type: 'm.room.message', + content: { msgtype: 'm.text', body: 'hi' }, + delay: 60000, + running_since: 1000, + }, + { + delay_id: 'syd_call', + room_id: '!r:x', + type: 'org.matrix.msc3401.call.member', + state_key: '_@a:x_D', + content: {}, + delay: 8000, + running_since: 1000, + }, + { delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} }, + null, + ], + }; + assert.deepEqual(parseDelayedEvents(res), [ + { delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 }, + ]); +}); + +test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => { + const item = { + delay_id: 'd', + room_id: '!r:x', + type: 'm.room.message', + content: { body: 'b' }, + delay_ms: 5, + delayed_since_ts: 10, + }; + assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15); + assert.deepEqual(parseDelayedEvents(undefined), []); + assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []); +}); + +test('groupScheduledByRoom: groups and sorts by sendAt', () => { + const m = (delayId: string, roomId: string, sendAt: number) => ({ + delayId, + roomId, + content: {}, + sendAt, + }); + const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]); + assert.deepEqual( + g.get('!1')?.map((x) => x.delayId), + ['a', 'b'], + ); + assert.equal(g.get('!2')?.length, 1); +}); diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index 3f157ab1a..a8acde312 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -1,4 +1,5 @@ import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; +import type { ScheduledMessage } from '../state/scheduledMessages'; /** * Schedule a message via MSC4140 (delayed messages). @@ -76,3 +77,61 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string) { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, ); } + +type DelayedEventItem = { + delay_id?: unknown; + room_id?: unknown; + type?: unknown; + state_key?: unknown; + content?: unknown; + delay?: unknown; + delay_ms?: unknown; + running_since?: unknown; + delayed_since_ts?: unknown; +}; + +/** + * [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140 + * `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`; + * the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed + * events share the endpoint, so only non-state `m.room.message`s are kept. + */ +export function parseDelayedEvents(res: unknown): ScheduledMessage[] { + if (!res || typeof res !== 'object') return []; + const r = res as { delayed_events?: unknown; scheduled?: unknown }; + const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled; + if (!Array.isArray(list)) return []; + const out: ScheduledMessage[] = []; + list.forEach((raw: DelayedEventItem) => { + if (!raw || typeof raw !== 'object') return; + if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return; + if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return; + if (!raw.content || typeof raw.content !== 'object') return; + const delay = Number(raw.delay ?? raw.delay_ms); + const since = Number(raw.running_since ?? raw.delayed_since_ts); + if (!Number.isFinite(delay) || !Number.isFinite(since)) return; + out.push({ + delayId: raw.delay_id, + roomId: raw.room_id, + content: raw.content as IContent, + sendAt: since + delay, + }); + }); + return out; +} + +/** The user's pending scheduled messages, as the server has them. */ +export async function fetchScheduledMessages(mx: MatrixClient): Promise { + const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, { + prefix: '/_matrix/client/unstable/org.matrix.msc4140', + }); + return parseDelayedEvents(res); +} + +/** Group by room, the shape of `scheduledMessagesAtom`. */ +export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map { + const map = new Map(); + msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m])); + map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt)); + return map; +}