Offline outbox: unsent messages survive reload and retry (#112) #250

Merged
jared merged 1 commits from offline-outbox into lotus 2026-09-28 22:08:29 -04:00
Owner

Implements #112 as recommended there: small UX work plus reload persistence, without a real outbox service.

Before

  • No retry: a send that failed went straight to a red ✕. Offline, homeserver down or a blip made no difference, and nothing ever retried it.
  • Lost on reload: reloading dropped the unsent message without a trace, because local echoes live only in memory.

After

  • Survives reload: own sends (text, stickers, reactions, polls) are mirrored to localStorage until the server confirms them or you cancel. After a reload they come back:
    • under 1 h old: sent again automatically, with the same txnId;
    • older: shown as Failed to send for you to retry or cancel;
    • already on the server: dropped. "Already there" means the transaction id came back in /sync, so there are no duplicates.
  • Auto-retry: network failures are re-sent when the connection returns, whether sync recovers or the browser goes back online. A one-off failure while online is retried after about 5 s, with backoff and at most 10 tries. Messages go oldest first and stay in order per room. 4xx, consent and encryption errors are left to the user.
  • UI:
    • While offline, a network failure shows 🕓 "Queued. Will send when you're back online", including in threads.
    • The red ✕ is now a button (click to retry). The Retry/Cancel menu is unchanged.
  • Privacy: logout wipes the outbox along with drafts and the other plaintext caches. The stored content is decrypted text, like drafts.
  • Out of scope: call signalling and redactions are never stored.

Why not pendingEventOrdering: Detached

It would move every local echo out of the timeline and touch every timeline and thread path; useThread even relies on getPendingEvents() throwing in the current mode. The outbox restores through the SDK's own room.addPendingEvent, so the rest of the app sees ordinary local echoes.

Tested

End to end against a local Synapse (Chromium), 19/19:

scenario result
browser offline → send → Queued → back online sent once
homeserver unreachable (banner "Connection Lost") → Queued → back sent once
send fails → reload restored, sent once, shown once
server got it, response lost → reload no duplicate
2 h old entry → reload Failed to send, not sent; click ✕ → sent
Cancel Message → reload gone
encrypted room: fail → reload goes out as m.room.encrypted, no plaintext on the wire, decrypts
one-off failure while online retried by itself (~5 s)
page errors none
  • Unit tests: 9 new tests for the pure parts, and the logout-wipe test now covers the outbox. The full unit suite has no failures.
  • Other checks: tsc and eslint clean; Playwright 20 passed.

Not covered by e2e: thread replies. They take the same path; the thread view shows the queued caption too.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA

Implements **#112** as recommended there: small UX work plus reload persistence, without a real outbox service. ## Before - **No retry:** a send that failed went straight to a red ✕. Offline, homeserver down or a blip made no difference, and nothing ever retried it. - **Lost on reload:** reloading dropped the unsent message without a trace, because local echoes live only in memory. ## After - **Survives reload:** own sends (text, stickers, reactions, polls) are mirrored to `localStorage` until the server confirms them or you cancel. After a reload they come back: - **under 1 h old:** sent again automatically, with the **same txnId**; - **older:** shown as *Failed to send* for you to retry or cancel; - **already on the server:** dropped. "Already there" means the transaction id came back in /sync, so there are no duplicates. - **Auto-retry:** network failures are re-sent when the connection returns, whether sync recovers or the browser goes back online. A one-off failure while online is retried after about 5 s, with backoff and at most 10 tries. Messages go oldest first and stay in order per room. 4xx, consent and encryption errors are left to the user. - **UI:** - While offline, a network failure shows 🕓 *"Queued. Will send when you're back online"*, including in threads. - The red ✕ is now a **button** (click to retry). The Retry/Cancel menu is unchanged. - **Privacy:** logout wipes the outbox along with drafts and the other plaintext caches. The stored content is decrypted text, like drafts. - **Out of scope:** call signalling and redactions are never stored. ## Why not `pendingEventOrdering: Detached` It would move every local echo out of the timeline and touch every timeline and thread path; `useThread` even relies on `getPendingEvents()` throwing in the current mode. The outbox restores through the SDK's own `room.addPendingEvent`, so the rest of the app sees ordinary local echoes. ## Tested End to end against a local Synapse (Chromium), **19/19**: | scenario | result | |---|---| | browser offline → send → *Queued* → back online | sent **once** | | homeserver unreachable (banner "Connection Lost") → *Queued* → back | sent **once** | | send fails → reload | restored, sent once, shown once | | server got it, response lost → reload | **no duplicate** | | 2 h old entry → reload | *Failed to send*, not sent; click ✕ → sent | | Cancel Message → reload | gone | | encrypted room: fail → reload | goes out as `m.room.encrypted`, no plaintext on the wire, decrypts | | one-off failure while online | retried by itself (~5 s) | | page errors | none | - **Unit tests:** 9 new tests for the pure parts, and the logout-wipe test now covers the outbox. The full unit suite has no failures. - **Other checks:** tsc and eslint clean; Playwright 20 passed. **Not covered by e2e:** thread replies. They take the same path; the thread view shows the queued caption too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
jared added 1 commit 2026-09-28 21:04:12 -04:00
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
02d86caeb0
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
jared merged commit 899e160aed into lotus 2026-09-28 22:08:29 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#250