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
138 lines
5.1 KiB
TypeScript
138 lines
5.1 KiB
TypeScript
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
|
import type { ScheduledMessage } from '../state/scheduledMessages';
|
|
|
|
/**
|
|
* Schedule a message via MSC4140 (delayed messages).
|
|
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
|
* /v3/rooms send endpoint. The server stores the event and returns delay_id
|
|
* instead of event_id.
|
|
*/
|
|
export async function scheduleMessage(
|
|
mx: MatrixClient,
|
|
roomId: string,
|
|
content: IContent,
|
|
sendAtMs: number,
|
|
): Promise<string> {
|
|
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
|
|
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
|
|
// (and later in the timeline) in the clear. Refuse rather than leak; the
|
|
// composer also hides the Schedule button in encrypted rooms.
|
|
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
|
|
throw new Error('Scheduled messages are not supported in encrypted rooms.');
|
|
}
|
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
|
// Use standard v3 prefix with the MSC4140 delay query param.
|
|
const res = (await mx.http.authedRequest(
|
|
Method.Put,
|
|
path,
|
|
{
|
|
'org.matrix.msc4140.delay': delayMs,
|
|
},
|
|
content,
|
|
)) as { delay_id: string };
|
|
return res.delay_id;
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled message via MSC4140.
|
|
*/
|
|
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'cancel' },
|
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
|
);
|
|
}
|
|
|
|
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'restart' },
|
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send a scheduled message immediately via MSC4140 (`action: 'send'`) — the
|
|
* server dispatches the stored delayed event now, as a normal timeline event,
|
|
* instead of waiting for its timeout.
|
|
*/
|
|
export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise<void> {
|
|
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
|
|
await mx.http.authedRequest(
|
|
Method.Post,
|
|
path,
|
|
undefined,
|
|
{ action: 'send' },
|
|
{ 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;
|
|
}
|