Files
cinny/src/app/utils/outbox.ts
T
Lotus CIandClaude Opus 5.5 02d86caeb0
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
feat: offline outbox — unsent messages survive reload and retry (#112)
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
2026-09-28 21:02:45 -04:00

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 */
}
};