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