CI / Build & Quality Checks (pull_request) Successful in 6m4s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Successful in 10m28s
Until now a send that failed (offline, homeserver down, a blip) went straight to "Failed to send": nothing retried it, and a reload dropped it without trace (chronological pending ordering keeps local echoes in memory only). - Outbox (utils/outbox.ts + features/outbox/OutboxFeature): own message sends (text, stickers, reactions, polls; not call signalling or redactions) are mirrored to localStorage from their first local echo until the server confirms them or the user cancels. - After a reload they come back as local echoes via room.addPendingEvent, same shape as the SDK's own. Recent ones (< 1 h) are sent again with the same txnId; older ones come back as "Failed to send" for the user to retry or cancel. Ones the server already has (transaction id seen in /sync) are dropped, so no duplicates. - Retries: network failures (ConnectionError, 408/429/5xx) are re-sent when the connection returns (sync recovers or the browser goes back online), and after a blip while online (5 s, backing off, max 10 per message). Oldest first, in order per room. 4xx / consent / encryption failures are left to the user. - UI: a network failure while offline shows a clock, "Queued. Will send when you're back online" (thread view too), not the red ✕. The ✕ is now a button: click to retry. - Logout wipes the outbox with the other plaintext caches (the content is decrypted, like drafts). Tested end to end against a local Synapse (Chromium): offline → queued → sent once on reconnect; homeserver unreachable → queued → sent once; failed send → reload → sent once and shown once; server accepted but response lost → reload → no duplicate; 2 h old entry → failed, not sent, click ✕ → sent; cancel → gone after reload; encrypted room → restored message goes out as m.room.encrypted with no plaintext and decrypts; one-off failure retried by itself in ~5 s; no page errors. Unit tests for the pure parts; Playwright 20 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
import { IContent } from 'matrix-js-sdk';
|
|
|
|
/**
|
|
* [Gitea #112] Offline outbox: unsent messages survive a reload and are
|
|
* retried when the connection comes back.
|
|
*
|
|
* The SDK keeps local echoes in memory only (chronological pending ordering),
|
|
* so a reload used to drop every message that hadn't reached the server. Each
|
|
* own message send is mirrored here (type + clear content + txnId) from its
|
|
* first local echo until the server confirms it or the user cancels it. On
|
|
* the next start it is put back as a failed local echo (Retry / Cancel as
|
|
* usual) and, if it's recent, sent again with the SAME txnId — the server
|
|
* deduplicates a transaction it already accepted.
|
|
*
|
|
* The content is the decrypted message, like a composer draft: it lives in
|
|
* localStorage until sent and is wiped on logout (clearPlaintextCaches).
|
|
*/
|
|
|
|
export type OutboxEntry = {
|
|
txnId: string;
|
|
roomId: string;
|
|
threadId: string | null;
|
|
type: string;
|
|
content: IContent;
|
|
/** When the message was first sent (local echo timestamp, ms). */
|
|
ts: number;
|
|
};
|
|
|
|
type Stored = { userId: string; entries: OutboxEntry[] };
|
|
|
|
const STORAGE_KEY = 'lotus_outbox_v1';
|
|
/** Hard cap so a long offline stretch can't grow localStorage without bound. */
|
|
export const OUTBOX_MAX_ENTRIES = 100;
|
|
/**
|
|
* Restored messages younger than this are sent again automatically after a
|
|
* reload; older ones come back as "Failed to send" and wait for the user
|
|
* (sending a message typed hours ago without asking would surprise people).
|
|
*/
|
|
export const OUTBOX_AUTOSEND_MS = 60 * 60 * 1000;
|
|
/** Auto-retries per message per session (one per reconnect). */
|
|
export const OUTBOX_MAX_AUTO_RETRIES = 10;
|
|
|
|
/** Delay before automatic retry number `attempt` (0-based) after a blip. */
|
|
export const outboxRetryDelayMs = (attempt: number): number =>
|
|
Math.min(5000 * 2 ** Math.max(0, attempt), 60_000);
|
|
|
|
/** Message-like events worth keeping. Not call signalling, not redactions. */
|
|
export const OUTBOX_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
'm.room.message',
|
|
'm.sticker',
|
|
'm.reaction',
|
|
'm.poll.start',
|
|
'm.poll.response',
|
|
'm.poll.end',
|
|
'org.matrix.msc3381.poll.start',
|
|
'org.matrix.msc3381.poll.response',
|
|
'org.matrix.msc3381.poll.end',
|
|
]);
|
|
|
|
const relatesToPendingEvent = (content: IContent): boolean => {
|
|
const rel = content['m.relates_to'] as
|
|
| { event_id?: unknown; 'm.in_reply_to'?: { event_id?: unknown } }
|
|
| undefined;
|
|
const ids = [rel?.event_id, rel?.['m.in_reply_to']?.event_id];
|
|
// A local echo's id ("~!room:txn") means nothing after a reload.
|
|
return ids.some((id) => typeof id === 'string' && id.startsWith('~'));
|
|
};
|
|
|
|
/** Whether a send should be mirrored into the outbox. */
|
|
export const shouldKeepInOutbox = (type: string, content: IContent): boolean =>
|
|
OUTBOX_EVENT_TYPES.has(type) && !relatesToPendingEvent(content);
|
|
|
|
/**
|
|
* A failure the network is to blame for: no connection (the SDK's
|
|
* ConnectionError), a timeout, rate limiting or a server error. Anything else
|
|
* (403, consent, bad request, encryption failure) needs the user.
|
|
*/
|
|
export const isRetryableSendError = (err: unknown): boolean => {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const { name, httpStatus } = err as { name?: unknown; httpStatus?: unknown };
|
|
if (name === 'ConnectionError') return true;
|
|
if (typeof httpStatus !== 'number') return false;
|
|
return httpStatus === 408 || httpStatus === 429 || httpStatus >= 500;
|
|
};
|
|
|
|
const isEntry = (e: unknown): e is OutboxEntry => {
|
|
if (!e || typeof e !== 'object') return false;
|
|
const o = e as Record<string, unknown>;
|
|
return (
|
|
typeof o.txnId === 'string' &&
|
|
typeof o.roomId === 'string' &&
|
|
(o.threadId === null || typeof o.threadId === 'string') &&
|
|
typeof o.type === 'string' &&
|
|
!!o.content &&
|
|
typeof o.content === 'object' &&
|
|
typeof o.ts === 'number'
|
|
);
|
|
};
|
|
|
|
/** Parse the stored outbox, keeping only this user's well-formed entries. */
|
|
export const parseOutbox = (raw: string | null, userId: string): OutboxEntry[] => {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<Stored>;
|
|
if (parsed.userId !== userId || !Array.isArray(parsed.entries)) return [];
|
|
return parsed.entries.filter(isEntry);
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
/** Add (or keep) an entry: first write wins, oldest dropped past the cap. */
|
|
export const withEntry = (entries: OutboxEntry[], entry: OutboxEntry): OutboxEntry[] => {
|
|
if (entries.some((e) => e.txnId === entry.txnId)) return entries;
|
|
const next = [...entries, entry].sort((a, b) => a.ts - b.ts);
|
|
return next.length > OUTBOX_MAX_ENTRIES ? next.slice(next.length - OUTBOX_MAX_ENTRIES) : next;
|
|
};
|
|
|
|
export const withoutEntry = (entries: OutboxEntry[], txnId: string): OutboxEntry[] =>
|
|
entries.some((e) => e.txnId === txnId) ? entries.filter((e) => e.txnId !== txnId) : entries;
|
|
|
|
export type RestorePlan = {
|
|
/** Put back as failed local echoes, oldest first. */
|
|
restore: OutboxEntry[];
|
|
/** Subset of `restore` to send again right away. */
|
|
autoSend: Set<string>;
|
|
/** Already delivered or no longer sendable: forget them. */
|
|
drop: OutboxEntry[];
|
|
};
|
|
|
|
/**
|
|
* Decide what to do with the stored outbox on start.
|
|
* `canSend(roomId)`: the user is still joined; `delivered(entry)`: the
|
|
* server already has it (the transaction id came back down /sync).
|
|
*/
|
|
export const planRestore = (
|
|
entries: OutboxEntry[],
|
|
now: number,
|
|
canSend: (roomId: string) => boolean,
|
|
delivered: (entry: OutboxEntry) => boolean,
|
|
): RestorePlan => {
|
|
const restore: OutboxEntry[] = [];
|
|
const autoSend = new Set<string>();
|
|
const drop: OutboxEntry[] = [];
|
|
[...entries]
|
|
.sort((a, b) => a.ts - b.ts)
|
|
.forEach((entry) => {
|
|
if (!canSend(entry.roomId) || delivered(entry)) {
|
|
drop.push(entry);
|
|
return;
|
|
}
|
|
restore.push(entry);
|
|
if (now - entry.ts < OUTBOX_AUTOSEND_MS) autoSend.add(entry.txnId);
|
|
});
|
|
return { restore, autoSend, drop };
|
|
};
|
|
|
|
export const loadOutbox = (userId: string): OutboxEntry[] => {
|
|
try {
|
|
return parseOutbox(localStorage.getItem(STORAGE_KEY), userId);
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
export const saveOutbox = (userId: string, entries: OutboxEntry[]): void => {
|
|
try {
|
|
if (entries.length === 0) localStorage.removeItem(STORAGE_KEY);
|
|
else localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, entries } satisfies Stored));
|
|
} catch {
|
|
// Storage full or blocked: the outbox is best-effort.
|
|
}
|
|
};
|
|
|
|
/** Wipe the outbox (logout): it holds decrypted message content. */
|
|
export const clearOutbox = (): void => {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
} catch {
|
|
/* localStorage unavailable — nothing to clear */
|
|
}
|
|
};
|