feat: offline outbox — unsent messages survive reload and retry (#112)
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
This commit is contained in:
Lotus CI
2026-09-28 21:02:45 -04:00
co-authored by Claude Opus 5.5
parent c0c93213c1
commit 02d86caeb0
9 changed files with 676 additions and 30 deletions
@@ -33,6 +33,8 @@ import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, conf
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { isKeyHotkey } from 'is-hotkey';
import { isRetryableSendError } from '../../../utils/outbox';
import { sendOfflineAtom } from '../../../state/sendOffline';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
@@ -253,6 +255,7 @@ export type ThreadTimelineProps = {
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const mx = useMatrixClient();
const sendOffline = useAtomValue(sendOfflineAtom);
const alive = useAlive();
const useAuthentication = useMediaAuthentication();
@@ -992,8 +995,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const showEmptyReplies = ready && thread.length === 0;
const renderPendingEvent = (mEvent: MatrixEvent) => {
// [Gitea #112] Network failures while offline are queued, not failed.
const queued =
mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error);
const failed =
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
!queued &&
(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED);
return (
<div
key={mEvent.getId() ?? mEvent.getTxnId()}
@@ -1007,6 +1014,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
</Text>
</Box>
)}
{queued && (
<Box style={{ padding: `0 ${config.space.S400}` }}>
<Text size="T200" priority="300">
Queued. Will send when you&apos;re back online
</Text>
</Box>
)}
</div>
);
};