Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 8bbb436ca7 fix(schedule): load scheduled messages from the server; offer to cancel them at logout (#322)
CI / Build & Quality Checks (pull_request) Successful in 2m56s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m43s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:40:03 -04:00
9 changed files with 209 additions and 128 deletions
+54 -14
View File
@@ -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<HTMLDivElement, LogoutDialogProps>(
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
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<ScheduledMessage[]>([]);
useEffect(() => {
let alive = true;
fetchScheduledMessages(mx)
.then((list) => alive && setScheduled(list))
.catch(() => undefined);
return () => {
alive = false;
};
}, [mx]);
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
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<HTMLDivElement, LogoutDialogProps>(
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
/>
))}
{scheduled.length > 0 && (
<InfoCard
variant="Warning"
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
/>
)}
<Text priority="400">You’re about to log out. Are you sure?</Text>
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
<Box direction="Column" gap="200">
<Button
variant="Critical"
onClick={logout}
onClick={() => logout()}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
>
<Text size="B400">Logout</Text>
<Text size="B400">
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
</Text>
</Button>
{scheduled.length > 0 && (
<Button
variant="Critical"
fill="Soft"
onClick={() => logout(true)}
disabled={ongoingLogout}
>
<Text size="B400">Cancel scheduled messages and logout</Text>
</Button>
)}
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">Cancel</Text>
</Button>
@@ -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;
@@ -9,7 +9,6 @@ import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
@@ -164,12 +163,7 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
});
}, []),
);
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
// watchdog flags it sooner, and Kuma is asked at once.
const stalled = useHomeserverStall(mx);
const setStalled = useSetAtom(homeserverStalledAtom);
useEffect(() => setStalled(stalled), [setStalled, stalled]);
const syncLost = lostAt !== null || stalled;
const syncLost = lostAt !== null;
const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
-16
View File
@@ -1,16 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SyncState } from 'matrix-js-sdk';
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
const now = 1_000_000;
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
assert.equal(
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
false,
'SyncStatus already shows that',
);
assert.equal(syncOverdue(now - 120_000, now, null), false);
});
-57
View File
@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { atom } from 'jotai';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
/**
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
* /sync that's overdue and confirms with a short probe of /versions.
*/
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
const CHECK_EVERY_MS = 10_000;
const PROBE_TIMEOUT_MS = 5_000;
export const homeserverStalledAtom = atom(false);
/** Pure: should we probe, given the last sync response time? */
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
export function useHomeserverStall(mx: MatrixClient): boolean {
const [stalled, setStalled] = useState(false);
useEffect(() => {
let lastSyncAt = Date.now();
let probing = false;
const onSync = () => {
lastSyncAt = Date.now();
setStalled(false);
};
const check = async () => {
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
probing = true;
let answered = false;
try {
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
answered = res.ok;
} catch {
answered = false;
}
probing = false;
// Still overdue and the server didn't answer a trivial request: stalled.
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
if (answered) setStalled(false);
};
mx.on(ClientEvent.Sync, onSync);
const id = setInterval(check, CHECK_EVERY_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
clearInterval(id);
};
}, [mx]);
return stalled;
}
+7 -31
View File
@@ -6,7 +6,6 @@ import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
type StateData = {
current: SyncState | null;
@@ -18,7 +17,6 @@ type SyncStatusProps = {
};
export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
@@ -36,35 +34,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, []),
);
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
role="status"
aria-live="polite"
>
<Text size="L400">
Lotus Chat&apos;s server isn&apos;t responding. Messages will send when it&apos;s back.
Still trying…
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
@@ -88,6 +57,13 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) {
return (
<Box direction="Column" shrink="No">
+2 -2
View File
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
/**
* Global atom: Map<roomId, ScheduledMessage[]>
* 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<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
+60
View File
@@ -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);
});
+59
View File
@@ -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<ScheduledMessage[]> {
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<string, ScheduledMessage[]> {
const map = new Map<string, ScheduledMessage[]>();
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;
}