[research] Offline outbox — what happens today when a send fails or the client is offline #112

Open
opened 2026-09-17 13:27:07 -04:00 by jared · 2 comments
Owner

Before deciding anything: establish exactly what the client does now.

Questions to answer (with file:line evidence)

  1. Sending while offline: does matrix-js-sdk queue the event (EventStatus.QUEUED/SENDING/NOT_SENT) and retry automatically on reconnect, or does it fail fast? What does Lotus render for each status (Message.tsx, ThreadTimeline.tsx reference NOT_SENT)?
  2. Is there a manual "resend / cancel" affordance? Is it discoverable?
  3. Does the SDK's pending-event store survive a reload (pendingEventOrdering, IndexedDB store)? Are drafts and pending events separate?
  4. Media uploads mid-flight when the network drops: retried or orphaned (and is the mxc content deleted on cancel — tryDeleteMxcContent)?
  5. What does SyncStatus show, and does it block sending?
  6. Encrypted rooms: are pending events re-encrypted correctly on retry after a reconnect?

Deliverable

A short state-of-play write-up in this issue with a recommendation: nothing / small UX polish (visible "queued — will send when online", retry-all) / real outbox work. Decision is Jared's.

Before deciding anything: establish exactly what the client does now. ### Questions to answer (with file:line evidence) 1. Sending while offline: does matrix-js-sdk queue the event (`EventStatus.QUEUED/SENDING/NOT_SENT`) and retry automatically on reconnect, or does it fail fast? What does Lotus render for each status (`Message.tsx`, `ThreadTimeline.tsx` reference `NOT_SENT`)? 2. Is there a manual "resend / cancel" affordance? Is it discoverable? 3. Does the SDK's pending-event store survive a reload (`pendingEventOrdering`, IndexedDB store)? Are drafts and pending events separate? 4. Media uploads mid-flight when the network drops: retried or orphaned (and is the mxc content deleted on cancel — `tryDeleteMxcContent`)? 5. What does `SyncStatus` show, and does it block sending? 6. Encrypted rooms: are pending events re-encrypted correctly on retry after a reconnect? ### Deliverable A short state-of-play write-up in this issue with a recommendation: nothing / small UX polish (visible "queued — will send when online", retry-all) / real outbox work. Decision is Jared's.
jared added this to the Features 2026-Q4 milestone 2026-09-17 13:27:07 -04:00
jared added the priority: mediumarea: messagingresearch labels 2026-09-17 13:27:07 -04:00
jared self-assigned this 2026-09-17 13:27:07 -04:00
Author
Owner

Research: send-failure / offline behavior in Lotus (matrix-js-sdk 41.7.0)

I read node_modules/matrix-js-sdk (src) and Lotus's src/app directly. Summary per question, with file:line evidence.

1. Sending while offline — queue+retry or fail fast?

Fail fast, no automatic retry. MatrixScheduler (node_modules/matrix-js-sdk/src/scheduler.ts:44-302, with RETRY_BACKOFF_RATELIMIT at line 51 and QUEUE_MESSAGES at line 61) implements exactly the queue/backoff/retry machinery the issue describes — but it is only wired up if a scheduler is passed into createClient() (client.ts:1374-1389, client.ts:2944-2955). Lotus's initMatrix.ts:59-74 calls createClient({...}) without a scheduler option, so this.scheduler is undefined and the scheduler code (including all retry/backoff) is dead code in this app.

Without a scheduler, encryptAndSendEvent (client.ts:2894-2984) goes straight to sendEventHttpRequest (line 2957-2965). Any failure (including a network error while offline) is caught at client.ts:2968-2982, which sets event.error and calls updatePendingEventStatus(room, event, EventStatus.NOT_SENT) — immediately, on the first failure, with no retry and no reconnect-triggered resend anywhere in the SDK or in Lotus's own code (confirmed by grep: resendEvent is only ever called from the "Retry Send" context-menu handler, src/app/features/room/message/Message.tsx:1397).

Lotus does render all the statuses:

  • src/app/features/room/message/Message.tsx:99-150 (DeliveryStatus) maps SENDING/ENCRYPTING → "Sending..." spinner, QUEUED → "Queued" (never reachable here since there's no scheduler), NOT_SENT/CANCELLED → red cross "Failed to send", anything else → "Sent" check.
  • src/app/features/room/thread/ThreadTimeline.tsx:930-948 shows a "Failed to send" caption under thread replies with NOT_SENT/CANCELLED status.
  • RoomTimeline.tsx has no bespoke pending/failed handling — pending/failed own-messages flow through the same Message.tsx render path and get the same DeliveryStatus icon (main timeline uses PendingEventOrdering.Chronological, so pending events live directly in the live timeline, not a side list — see Q3).

2. Manual resend / cancel — is it discoverable?

Yes, but it's easy to miss. Message.tsx:1386-1431: when mEvent.status is NOT_SENT or CANCELLED, the message's hover "..." context menu grows an extra group with "Retry Send" (calls mx.resendEvent(mEvent, room), line 1397) and "Cancel Message" (calls mx.cancelPendingEvent(mEvent), line 1416). There is no standalone "failed to send, tap to retry" affordance and no global "you have N failed messages" indicator — the only visible cue is the small red cross icon next to the message (DeliveryStatus, Message.tsx:111-114); a user has to notice the icon, hover the message, open the menu, and find the item at the bottom.

3. Does the pending-event store survive reload? Are drafts separate from pending events?

No, pending/failed events do not survive a reload, and yes, drafts are a completely separate mechanism.

  • Room only populates/persists pendingEventList (and thus calls the store's setPendingEvents) when constructed with pendingEventOrdering: Detached (node_modules/matrix-js-sdk/src/models/room.ts:493, 512-524, savePendingEvents() guarded by if (this.pendingEventList) at room.ts ~2849-2865). Restoring persisted pending events from client.store.getPendingEvents() also only happens in that same Detached branch (room.ts:512-522).
  • Lotus never sets pendingEventOrdering when creating the client (src/client/initMatrix.ts:59-74), and sync.ts:1965-1969 in the SDK propagates opts.pendingEventOrdering (undefined here) straight into new Room(...), so Lotus rooms default to PendingEventOrdering.Chronological (room.ts:493). Net effect: pendingEventList is never allocated, IndexedDBStore.setPendingEvents/getPendingEvents (node_modules/matrix-js-sdk/src/store/indexeddb.ts:353-368) are never invoked, and a NOT_SENT/still-SENDING local echo is pure in-memory state — a page reload silently drops it. The user's typed text is gone from the timeline as if never typed (no persisted "failed message" to retry after reload).
  • Drafts are unrelated: src/app/utils/draft.ts:1-19 defines DRAFT_MSG_KEY_PREFIX = 'draft-msg-'; RoomInput.tsx:437,477 reads/writes localStorage['draft-msg-<roomId>'] (JSON {userId, nodes}) as the user types, independent of send state; src/app/hooks/useHydrateMsgDrafts.ts rehydrates these into atoms on boot for the room-nav "has draft" dot. Drafts are composer text that was never submitted; pending events are messages that were submitted and are in flight or failed. The two never interact.

4. Media uploads mid-flight when network drops — retried or orphaned? mxc cleanup?

Uploads have their own retry loop, independent of the (unused) SDK scheduler: src/app/utils/matrix.ts:193-274 (uploadContent) retries up to UPLOAD_MAX_RETRY_COUNT = 3 (line 191) with capped exponential backoff or the server's Retry-After (lines 257-263), but only for isRetryableUploadError cases — no HTTP status (network/transport failure), 408, 429, or 5xx (matrix.ts:174-189); a user-driven AbortError from mx.cancelUpload() is explicitly excluded from retry (line 179). After exhausting retries it surfaces UploadStatus.Error, and UploadCardRenderer.tsx:332-341 renders a manual "Retry Upload" button that calls startUpload again.

mxc cleanup: tryDeleteMxcContent (src/app/utils/matrix.ts:532-544) is a best-effort DELETE /_matrix/client/v1/media/... call (Synapse 1.97+ media-owner delete), invoked from UploadCardRenderer.tsx:311-318 (removeUpload, only if the upload had already reached Success before the user removes it) and RoomInput.tsx:591 (after image compression replaces an already-uploaded original). If an upload fails outright (never reached Success), there is no mxc to delete — nothing was orphaned server-side. So: uploads auto-retry a few times, then require a manual retry click; content is only explicitly deleted when an already-successful upload is discarded, not when a failed one is abandoned (there's nothing to clean up in that case).

5. What does SyncStatus show, and does it block sending?

src/app/pages/client/SyncStatus.tsx:33-92 renders a thin banner keyed off SyncState from useSyncState: "Connecting..." for Prepared/Syncing/Catchup (line 34-53), "Connection Lost! Reconnecting..." for Reconnecting (line 56-71), "Connection Lost!" for Error (line 74-89). It is purely informational and never touches the composerRoomInput.tsx has no reference to SyncState/useSyncState and never disables its send button or input based on connectivity (confirmed by grep). A user can type and hit send while fully offline; the message goes to SENDING then immediately NOT_SENT per Q1.

6. Encrypted rooms — is a retried pending event re-encrypted correctly?

Yes, and correctly so: it is not re-encrypted — it is resent as-is. resendEvent() (node_modules/matrix-js-sdk/src/client.ts:2501-2507) sets status back to SENDING and calls encryptAndSendEvent again, which calls encryptEventIfNeededshouldEncryptEventForRoom (client.ts:3006-3013): if (event.isEncrypted()) return false; with the comment "this happens if the encryption step succeeded, but the send step failed on the first attempt." So a message whose encryption already completed keeps its original ciphertext (from whatever Megolm session was active at encrypt time) and only the HTTP send is retried — there's no double-encryption, no session-mismatch risk, and no plaintext re-exposure. This is core SDK behavior (not anything Lotus added/broke).


Recommendation

Small UX polish, not a real outbox rewrite. The dangerous gap here isn't missing retry logic — matrix-js-sdk's scheduler intentionally isn't the right tool (rooms would serialize all messages FIFO per-room, which nobody wants) — it's that failed/in-flight sends are silently lost on reload (Q3) with only a small red icon (Q1/Q2) as the only sign anything went wrong, and the composer gives zero feedback that you're offline (Q5) beyond a thin top banner that's easy to miss while scrolled into a room. I'd suggest: (a) surface a lightweight "N messages failed to send — Retry all / Dismiss" affordance instead of requiring per-message menu digging, and (b) when the client is offline (SyncState.Error/Reconnecting), show a small inline hint in RoomInput itself (not just the top-of-timeline banner) so users don't type into a void. Persisting pending events across reload (switching to pendingEventOrdering: Detached and wiring IndexedDBStore's existing setPendingEvents/getPendingEvents) would fix the reload-loses-your-message case for real, but it's a bigger, riskier change (touches every timeline/pending-event code path) that I'd scope separately. Estimated effort: ~0.5-1 day for the UX polish (failed-message banner + composer offline hint, reusing existing EventStatus/SyncState plumbing); ~3-5 days for the Detached pending-event persistence work plus regression testing across normal/thread/encrypted timelines.

## Research: send-failure / offline behavior in Lotus (matrix-js-sdk 41.7.0) I read `node_modules/matrix-js-sdk` (src) and Lotus's `src/app` directly. Summary per question, with file:line evidence. ### 1. Sending while offline — queue+retry or fail fast? **Fail fast, no automatic retry.** `MatrixScheduler` (`node_modules/matrix-js-sdk/src/scheduler.ts:44-302`, with `RETRY_BACKOFF_RATELIMIT` at line 51 and `QUEUE_MESSAGES` at line 61) implements exactly the queue/backoff/retry machinery the issue describes — but it is only wired up if a `scheduler` is passed into `createClient()` (`client.ts:1374-1389`, `client.ts:2944-2955`). Lotus's `initMatrix.ts:59-74` calls `createClient({...})` without a `scheduler` option, so `this.scheduler` is `undefined` and the scheduler code (including all retry/backoff) is dead code in this app. Without a scheduler, `encryptAndSendEvent` (`client.ts:2894-2984`) goes straight to `sendEventHttpRequest` (line 2957-2965). Any failure (including a network error while offline) is caught at `client.ts:2968-2982`, which sets `event.error` and calls `updatePendingEventStatus(room, event, EventStatus.NOT_SENT)` — immediately, on the first failure, with no retry and no reconnect-triggered resend anywhere in the SDK or in Lotus's own code (confirmed by grep: `resendEvent` is only ever called from the "Retry Send" context-menu handler, `src/app/features/room/message/Message.tsx:1397`). Lotus does render all the statuses: - `src/app/features/room/message/Message.tsx:99-150` (`DeliveryStatus`) maps `SENDING`/`ENCRYPTING` → "Sending..." spinner, `QUEUED` → "Queued" (never reachable here since there's no scheduler), `NOT_SENT`/`CANCELLED` → red cross "Failed to send", anything else → "Sent" check. - `src/app/features/room/thread/ThreadTimeline.tsx:930-948` shows a "Failed to send" caption under thread replies with `NOT_SENT`/`CANCELLED` status. - `RoomTimeline.tsx` has no bespoke pending/failed handling — pending/failed own-messages flow through the same `Message.tsx` render path and get the same `DeliveryStatus` icon (main timeline uses `PendingEventOrdering.Chronological`, so pending events live directly in the live timeline, not a side list — see Q3). ### 2. Manual resend / cancel — is it discoverable? Yes, but it's easy to miss. `Message.tsx:1386-1431`: when `mEvent.status` is `NOT_SENT` or `CANCELLED`, the message's hover "..." context menu grows an extra group with **"Retry Send"** (calls `mx.resendEvent(mEvent, room)`, line 1397) and **"Cancel Message"** (calls `mx.cancelPendingEvent(mEvent)`, line 1416). There is no standalone "failed to send, tap to retry" affordance and no global "you have N failed messages" indicator — the only visible cue is the small red cross icon next to the message (`DeliveryStatus`, `Message.tsx:111-114`); a user has to notice the icon, hover the message, open the menu, and find the item at the bottom. ### 3. Does the pending-event store survive reload? Are drafts separate from pending events? **No, pending/failed events do not survive a reload**, and **yes, drafts are a completely separate mechanism.** - `Room` only populates/persists `pendingEventList` (and thus calls the store's `setPendingEvents`) when constructed with `pendingEventOrdering: Detached` (`node_modules/matrix-js-sdk/src/models/room.ts:493`, `512-524`, `savePendingEvents()` guarded by `if (this.pendingEventList)` at `room.ts` ~2849-2865). Restoring persisted pending events from `client.store.getPendingEvents()` also only happens in that same `Detached` branch (`room.ts:512-522`). - Lotus never sets `pendingEventOrdering` when creating the client (`src/client/initMatrix.ts:59-74`), and `sync.ts:1965-1969` in the SDK propagates `opts.pendingEventOrdering` (undefined here) straight into `new Room(...)`, so Lotus rooms default to `PendingEventOrdering.Chronological` (`room.ts:493`). Net effect: `pendingEventList` is never allocated, `IndexedDBStore.setPendingEvents`/`getPendingEvents` (`node_modules/matrix-js-sdk/src/store/indexeddb.ts:353-368`) are never invoked, and a `NOT_SENT`/still-`SENDING` local echo is pure in-memory state — a page reload silently drops it. The user's typed text is gone from the timeline as if never typed (no persisted "failed message" to retry after reload). - Drafts are unrelated: `src/app/utils/draft.ts:1-19` defines `DRAFT_MSG_KEY_PREFIX = 'draft-msg-'`; `RoomInput.tsx:437,477` reads/writes `localStorage['draft-msg-<roomId>']` (JSON `{userId, nodes}`) as the user types, independent of send state; `src/app/hooks/useHydrateMsgDrafts.ts` rehydrates these into atoms on boot for the room-nav "has draft" dot. Drafts are composer text that was never submitted; pending events are messages that were submitted and are in flight or failed. The two never interact. ### 4. Media uploads mid-flight when network drops — retried or orphaned? mxc cleanup? Uploads have their **own** retry loop, independent of the (unused) SDK scheduler: `src/app/utils/matrix.ts:193-274` (`uploadContent`) retries up to `UPLOAD_MAX_RETRY_COUNT = 3` (line 191) with capped exponential backoff or the server's `Retry-After` (lines 257-263), but only for `isRetryableUploadError` cases — no HTTP status (network/transport failure), 408, 429, or 5xx (`matrix.ts:174-189`); a user-driven `AbortError` from `mx.cancelUpload()` is explicitly excluded from retry (line 179). After exhausting retries it surfaces `UploadStatus.Error`, and `UploadCardRenderer.tsx:332-341` renders a manual **"Retry Upload"** button that calls `startUpload` again. mxc cleanup: `tryDeleteMxcContent` (`src/app/utils/matrix.ts:532-544`) is a best-effort `DELETE /_matrix/client/v1/media/...` call (Synapse 1.97+ media-owner delete), invoked from `UploadCardRenderer.tsx:311-318` (`removeUpload`, only if the upload had already reached `Success` before the user removes it) and `RoomInput.tsx:591` (after image compression replaces an already-uploaded original). If an upload fails outright (never reached `Success`), there is no mxc to delete — nothing was orphaned server-side. So: uploads auto-retry a few times, then require a manual retry click; content is only explicitly deleted when an already-*successful* upload is discarded, not when a failed one is abandoned (there's nothing to clean up in that case). ### 5. What does SyncStatus show, and does it block sending? `src/app/pages/client/SyncStatus.tsx:33-92` renders a thin banner keyed off `SyncState` from `useSyncState`: "Connecting..." for `Prepared`/`Syncing`/`Catchup` (line 34-53), "Connection Lost! Reconnecting..." for `Reconnecting` (line 56-71), "Connection Lost!" for `Error` (line 74-89). **It is purely informational and never touches the composer** — `RoomInput.tsx` has no reference to `SyncState`/`useSyncState` and never disables its send button or input based on connectivity (confirmed by grep). A user can type and hit send while fully offline; the message goes to `SENDING` then immediately `NOT_SENT` per Q1. ### 6. Encrypted rooms — is a retried pending event re-encrypted correctly? Yes, and correctly so: it is **not** re-encrypted — it is resent as-is. `resendEvent()` (`node_modules/matrix-js-sdk/src/client.ts:2501-2507`) sets status back to `SENDING` and calls `encryptAndSendEvent` again, which calls `encryptEventIfNeeded` → `shouldEncryptEventForRoom` (`client.ts:3006-3013`): `if (event.isEncrypted()) return false;` with the comment "this happens if the encryption step succeeded, but the send step failed on the first attempt." So a message whose encryption already completed keeps its original ciphertext (from whatever Megolm session was active at encrypt time) and only the HTTP send is retried — there's no double-encryption, no session-mismatch risk, and no plaintext re-exposure. This is core SDK behavior (not anything Lotus added/broke). --- ### Recommendation **Small UX polish, not a real outbox rewrite.** The dangerous gap here isn't missing retry logic — matrix-js-sdk's scheduler intentionally isn't the right tool (rooms would serialize all messages FIFO per-room, which nobody wants) — it's that failed/in-flight sends are **silently lost on reload** (Q3) with only a small red icon (Q1/Q2) as the only sign anything went wrong, and the composer gives zero feedback that you're offline (Q5) beyond a thin top banner that's easy to miss while scrolled into a room. I'd suggest: (a) surface a lightweight "N messages failed to send — Retry all / Dismiss" affordance instead of requiring per-message menu digging, and (b) when the client is offline (`SyncState.Error`/`Reconnecting`), show a small inline hint in `RoomInput` itself (not just the top-of-timeline banner) so users don't type into a void. Persisting pending events across reload (switching to `pendingEventOrdering: Detached` and wiring `IndexedDBStore`'s existing `setPendingEvents`/`getPendingEvents`) would fix the reload-loses-your-message case for real, but it's a bigger, riskier change (touches every timeline/pending-event code path) that I'd scope separately. Estimated effort: ~0.5-1 day for the UX polish (failed-message banner + composer offline hint, reusing existing `EventStatus`/`SyncState` plumbing); ~3-5 days for the `Detached` pending-event persistence work plus regression testing across normal/thread/encrypted timelines.
Author
Owner

State of play (2026-09-20, code audit with file:line)

1. Sending while offline. mx.sendMessage(...) (RoomInput.tsx:317/356/365/613) goes through the SDK's MatrixScheduler with the default RETRY_BACKOFF_RATELIMITcalculateRetryBackoff(err, attempts, /*retryConnectionError*/ false) (matrix-js-sdk/lib/scheduler.js:42, http-api/utils.js:155-161). A ConnectionError (offline / DNS / TCP) returns −1 = give up immediately; only 429 and 5xx get up to 4 exponential retries. So an offline send fails fast: local echo → SENDINGNOT_SENT within one request timeout. Nothing retries on reconnect. Lotus renders each status in Message.tsx:108-130: QUEUED "Queued", SENDING/ENCRYPTING "Sending...", NOT_SENT/CANCELLED red ✕ "Failed to send" (and ThreadTimeline.tsx:961 mirrors it).

2. Manual affordance. Only in the ⋮ message menu, and only for NOT_SENT/CANCELLED events: "Retry Send" → mx.resendEvent and "Cancel Message" → mx.cancelPendingEvent (Message.tsx:1277-1310). Not discoverable: the ✕ icon itself is not a button, there is no "retry all", and on touch the menu is the long-press sheet.

3. Persistence across reload. pendingEventOrdering is not set (initMatrix.ts:94-101) → SDK default Chronological (models/room.js:334). Pending events are only persisted to the store (store.setPendingEvents, room.js:2450-2461) in Detached mode; in Chronological mode a reload drops every unsent message — it vanishes from the timeline with no trace. Drafts are separate (roomIdToMsgDraftAtomFamily + draft-msg-* localStorage, #37/#41) and do survive; a failed send is not put back into the draft.

4. Media uploads. uploadContent (utils/matrix.ts:193-270) retries transient failures up to 3× with Retry-After/backoff, and the upload card's cancel calls tryDeleteMxcContent (UploadCardRenderer.tsx:315, utils/matrix.ts:553). Once the upload succeeded and the event send fails, the mxc stays on the server (orphaned until media retention) — same as Element.

5. SyncStatus. SyncStatus.tsx:49/67/85 shows "Connecting…", "Connection Lost! Reconnecting…", "Connection Lost!". It does not block sending — the composer stays live, so an offline user sends into a fail-fast queue.

6. Encrypted rooms on retry. resendEvent re-runs the send pipeline from the pending event's status; for ENCRYPTING/NOT_SENT the SDK re-encrypts with the current outbound session (client.js encryptAndSendEvent path — NOT_SENT is set at client.js:1983 after encryption fails or the request fails). No known Lotus-side bug here; the risk is only the KE-1 key-cluster issues (#201).

Recommendation

Middle option — small UX work, no real outbox:

  1. Set pendingEventOrdering: Detached so unsent messages survive a reload (they then render at the bottom, "Failed to send", with the existing Retry/Cancel). One line + a check that the timeline handles the detached pending list (room.getPendingEvents()), which upstream Cinny supports.
  2. Auto-retry on reconnect: on ClientEvent.SyncPREPARED/SYNCING after RECONNECTING/ERROR, walk each room's NOT_SENT pending events and resendEvent them once, oldest first (a retryFailedSends() util, unit-tested). Show "Queued — will send when online" instead of the red ✕ while the client is offline (navigator.onLine === false or sync state RECONNECTING), red ✕ only once online and still failing.
  3. Make the ✕ a button (click = Retry) and add "Retry all" to the SyncStatus bar when there are failed sends.
    That's ~200 lines and covers the "I typed on the train" case without building a durable outbox. A real outbox (send while the app is closed, ordering guarantees across devices) needs the SW + push plumbing from #202 and isn't worth it yet. Decision is Jared's.
## State of play (2026-09-20, code audit with file:line) **1. Sending while offline.** `mx.sendMessage(...)` (`RoomInput.tsx:317/356/365/613`) goes through the SDK's `MatrixScheduler` with the default `RETRY_BACKOFF_RATELIMIT` → `calculateRetryBackoff(err, attempts, /*retryConnectionError*/ false)` (`matrix-js-sdk/lib/scheduler.js:42`, `http-api/utils.js:155-161`). A `ConnectionError` (offline / DNS / TCP) returns **−1 = give up immediately**; only 429 and 5xx get up to 4 exponential retries. So an offline send fails fast: local echo → `SENDING` → `NOT_SENT` within one request timeout. **Nothing retries on reconnect.** Lotus renders each status in `Message.tsx:108-130`: `QUEUED` "Queued", `SENDING`/`ENCRYPTING` "Sending...", `NOT_SENT`/`CANCELLED` red ✕ "Failed to send" (and `ThreadTimeline.tsx:961` mirrors it). **2. Manual affordance.** Only in the ⋮ message menu, and only for `NOT_SENT`/`CANCELLED` events: "Retry Send" → `mx.resendEvent` and "Cancel Message" → `mx.cancelPendingEvent` (`Message.tsx:1277-1310`). Not discoverable: the ✕ icon itself is not a button, there is no "retry all", and on touch the menu is the long-press sheet. **3. Persistence across reload.** `pendingEventOrdering` is not set (`initMatrix.ts:94-101`) → SDK default `Chronological` (`models/room.js:334`). Pending events are only persisted to the store (`store.setPendingEvents`, `room.js:2450-2461`) in `Detached` mode; in Chronological mode **a reload drops every unsent message** — it vanishes from the timeline with no trace. Drafts are separate (`roomIdToMsgDraftAtomFamily` + `draft-msg-*` localStorage, #37/#41) and do survive; a failed send is *not* put back into the draft. **4. Media uploads.** `uploadContent` (`utils/matrix.ts:193-270`) retries transient failures up to 3× with Retry-After/backoff, and the upload card's cancel calls `tryDeleteMxcContent` (`UploadCardRenderer.tsx:315`, `utils/matrix.ts:553`). Once the upload succeeded and the *event* send fails, the mxc stays on the server (orphaned until media retention) — same as Element. **5. SyncStatus.** `SyncStatus.tsx:49/67/85` shows "Connecting…", "Connection Lost! Reconnecting…", "Connection Lost!". It does **not** block sending — the composer stays live, so an offline user sends into a fail-fast queue. **6. Encrypted rooms on retry.** `resendEvent` re-runs the send pipeline from the pending event's status; for `ENCRYPTING`/`NOT_SENT` the SDK re-encrypts with the current outbound session (`client.js` `encryptAndSendEvent` path — `NOT_SENT` is set at `client.js:1983` after encryption fails or the request fails). No known Lotus-side bug here; the risk is only the KE-1 key-cluster issues (#201). ## Recommendation Middle option — **small UX work, no real outbox**: 1. Set `pendingEventOrdering: Detached` so unsent messages survive a reload (they then render at the bottom, "Failed to send", with the existing Retry/Cancel). One line + a check that the timeline handles the detached pending list (`room.getPendingEvents()`), which upstream Cinny supports. 2. Auto-retry on reconnect: on `ClientEvent.Sync` → `PREPARED/SYNCING` after `RECONNECTING`/`ERROR`, walk each room's `NOT_SENT` pending events and `resendEvent` them once, oldest first (a `retryFailedSends()` util, unit-tested). Show "Queued — will send when online" instead of the red ✕ while the client is offline (`navigator.onLine === false` or sync state `RECONNECTING`), red ✕ only once online and still failing. 3. Make the ✕ a button (click = Retry) and add "Retry all" to the SyncStatus bar when there are failed sends. That's ~200 lines and covers the "I typed on the train" case without building a durable outbox. A real outbox (send while the app is closed, ordering guarantees across devices) needs the SW + push plumbing from #202 and isn't worth it yet. Decision is Jared's.
jared added the needs-human-review label 2026-09-20 15:07:10 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#112