From 7316c92ff32f195f38587ff5cc9184ea55144d4d Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:14:16 +0000 Subject: [PATCH 1/3] composer: persist the draft on input, keep unconfirmed sends in a durable outbox The draft lived in a memory-only jotai atom written only when RoomInput unmounted, so a reload lost it. A sent message was cleared from the editor at once and held nowhere durable. The draft is now written to localStorage (debounced, flushed on pagehide/hidden) and restored on load; a send stays in a per-account outbox under its txn id until the server confirms, shown above the composer with Resend and Discard. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 137 ++++++++++++++----- src/app/features/room/RoomOutbox.test.ts | 55 ++++++++ src/app/features/room/RoomOutbox.tsx | 96 +++++++++++++ src/app/state/room/composerStore.test.ts | 116 ++++++++++++++++ src/app/state/room/composerStore.ts | 164 +++++++++++++++++++++++ 5 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 src/app/features/room/RoomOutbox.test.ts create mode 100644 src/app/features/room/RoomOutbox.tsx create mode 100644 src/app/state/room/composerStore.test.ts create mode 100644 src/app/state/room/composerStore.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b4483d8b..b3d59d18 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,6 +12,8 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -221,22 +223,66 @@ export const RoomInput = forwardRef( useCallback((width) => setHideStickerBtn(width < 500), []) ); + const userId = mx.getUserId() ?? ''; + const draftSaveTimer = useRef>(); + + // The atom is only a cache: after a reload it is empty and the stored draft is the truth. + useEffect(() => { + if (msgDraft.length === 0) { + const stored = loadDraft(userId, roomId); + if (stored.length > 0) setMsgDraft(stored); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); + const persistDraft = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + saveDraft(userId, roomId, editor.children); + }, [userId, roomId, editor]); + + // Write on input (debounced), and flush when the page is hidden or unloading, which is + // all a mobile reload gives us. + const handleEditorChange = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = setTimeout(persistDraft, 400); + }, [persistDraft]); + + useEffect(() => { + const flush = () => { + if (draftSaveTimer.current !== undefined) persistDraft(); + }; + const onVisibility = () => { + if (document.visibilityState === 'hidden') flush(); + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', onVisibility); + return () => { + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [persistDraft]); + useEffect( () => () => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); + saveDraft(userId, roomId, parsedDraft); } else { setMsgDraft([]); + clearDraft(userId, roomId); } resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [roomId, editor, setMsgDraft, userId] ); const handleFileMetadata = useCallback( @@ -366,12 +412,25 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, content as any); + sendTracked(mx, roomId, content); + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + clearDraft(userId, roomId); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); + }, [ + mx, + roomId, + userId, + editor, + replyDraft, + sendTypingStatus, + setReplyDraft, + isMarkdown, + commands, + ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -537,44 +596,48 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} + onChange={handleEditorChange} top={ - replyDraft && ( -
- - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" + <> + + {replyDraft && ( +
+ - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" > - - {trimReplyFromBody(replyDraft.body)} - - + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > + + {trimReplyFromBody(replyDraft.body)} + + + - -
- ) +
+ )} + } before={ { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); +}; + +const fakeClient = (send: (...a: unknown[]) => Promise) => + ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never); + +describe('sendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => vi.unstubAllGlobals()); + + const content = { msgtype: 'm.text', body: 'typed before reload' }; + + it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { + let finish!: () => void; + const mx = fakeClient( + () => + new Promise((r) => { + finish = () => r({ event_id: '$e' }); + }) + ); + const done = sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); + finish(); + await done; + expect(loadOutbox('@a:x')).toEqual([]); + }); + + it('a failed send stays, marked failed, and is not cleared', async () => { + const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); + await sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, + ]); + }); + + it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + await sendTracked(fakeClient(send), '!r:x', content); + expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1'); + }); +}); diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx new file mode 100644 index 00000000..4d3d6aca --- /dev/null +++ b/src/app/features/room/RoomOutbox.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { Box, Chip, Text, color, config } from 'folds'; +import { + OutboxEntry, + enqueueOutbox, + failOutbox, + loadOutbox, + removeOutbox, + retryOutbox, + subscribeOutbox, +} from '../../state/room/composerStore'; + +const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => + mx + .sendMessage(entry.roomId, entry.content as never, entry.txnId) + .then(() => removeOutbox(userId, entry.txnId)) + .catch((e: unknown) => + failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)) + ); + +/** Send a message, keeping it in the durable outbox until the server confirms it. */ +export const sendTracked = ( + mx: MatrixClient, + roomId: string, + content: Record +): Promise => { + const userId = mx.getUserId() ?? ''; + const entry = { roomId, content, txnId: mx.makeTxnId() }; + enqueueOutbox(userId, entry); + return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +}; + +export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { + const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId); + const [entries, setEntries] = useState(read); + useEffect(() => { + setEntries(read()); + return subscribeOutbox(() => setEntries(read())); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + return entries; +}; + +type RoomOutboxProps = { mx: MatrixClient; roomId: string }; + +/** + * Messages this client sent that the server has not confirmed: still sending, or failed. + * They also appear here after a reload, since the outbox is durable. Resending reuses the + * original txn id, so a send that did land the first time is not duplicated. + */ +export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { + const userId = mx.getUserId() ?? ''; + const entries = useRoomOutbox(userId, roomId); + if (entries.length === 0) return null; + + return ( + + {entries.map((entry) => { + const failed = entry.status === 'failed'; + return ( + + + + {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + + + {String(entry.content.body ?? '')} + + + { + retryOutbox(userId, entry.txnId); + attempt(mx, userId, entry); + }} + > + Resend + + removeOutbox(userId, entry.txnId)}> + Discard + + + ); + })} + + ); +} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts new file mode 100644 index 00000000..d3957d68 --- /dev/null +++ b/src/app/state/room/composerStore.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Descendant } from 'slate'; +import { + clearDraft, + enqueueOutbox, + failOutbox, + loadDraft, + loadOutbox, + removeOutbox, + retryOutbox, + saveDraft, + subscribeOutbox, +} from './composerStore'; + +const memoryStore = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + size: () => m.size, + }; +}; + +const para = (text: string): Descendant[] => [{ type: 'paragraph', children: [{ text }] } as never]; + +describe('draft', () => { + it('survives a reload: a new read of the same store returns what input wrote', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('half a thought'), store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual(para('half a thought')); + }); + + it('is keyed by account and room', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('one'), store); + expect(loadDraft('@b:x', '!r:x', store)).toEqual([]); + expect(loadDraft('@a:x', '!other:x', store)).toEqual([]); + }); + + it('emptying the editor removes the stored draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + saveDraft('@a:x', '!r:x', para(''), store); + expect(store.size()).toBe(0); + }); + + it('clearDraft removes it; corrupt storage reads as no draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + clearDraft('@a:x', '!r:x', store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + store.setItem('ratatoskr.draft.@a:x.!r:x', '{not json'); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + }); + + it('a throwing store (quota) does not throw into the composer', () => { + const store = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); + }); +}); + +describe('outbox', () => { + const entry = { txnId: 't1', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' } }; + + it('keeps a sent message until it is confirmed, across a reload', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + const after = loadOutbox('@a:x', store); + expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]); + removeOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)).toEqual([]); + expect(store.size()).toBe(0); + }); + + it('a failed send stays, marked failed with its error; retry returns it to pending', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + failOutbox('@a:x', 't1', 'offline', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); + retryOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); + expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); + }); + + it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + enqueueOutbox('@a:x', entry, 6, store); + expect(loadOutbox('@a:x', store)).toHaveLength(1); + expect(loadOutbox('@b:x', store)).toEqual([]); + }); + + it('notifies subscribers on every change', () => { + const store = memoryStore(); + let n = 0; + const off = subscribeOutbox(() => { + n += 1; + }); + enqueueOutbox('@a:x', entry, 5, store); + removeOutbox('@a:x', 't1', store); + off(); + enqueueOutbox('@a:x', entry, 5, store); + expect(n).toBe(2); + }); +}); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts new file mode 100644 index 00000000..8401e0d7 --- /dev/null +++ b/src/app/state/room/composerStore.ts @@ -0,0 +1,164 @@ +import { Descendant } from 'slate'; + +/** + * Durable composer state. The in-memory jotai atoms in roomInputDrafts.ts are a cache of this; + * a page reload keeps only what was written here. + * + * Draft: the unsent editor contents of one room, rewritten on input and removed when emptied. + * Outbox: a sent message stays here, with its txn id, until the server confirms it. A reload + * mid-send therefore leaves it behind to be shown and resent; resending under the same txn id + * makes the server deduplicate if the first attempt did land. + */ + +export type OutboxStatus = 'pending' | 'failed'; + +export type OutboxEntry = { + txnId: string; + roomId: string; + content: Record; + status: OutboxStatus; + error?: string; + ts: number; +}; + +type Store = Pick; + +const defaultStore = (): Store | undefined => { + try { + return localStorage; + } catch { + return undefined; + } +}; + +const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}.${roomId}`; +const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; + +const readJson = (store: Store | undefined, key: string): T | undefined => { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } +}; + +const writeJson = (store: Store | undefined, key: string, value: unknown): void => { + try { + store?.setItem(key, JSON.stringify(value)); + } catch { + // quota or private mode: the in-memory atom still holds the draft for this session + } +}; + +const isEmptyDraft = (draft: Descendant[]): boolean => + draft.length === 0 || + (draft.length === 1 && + 'children' in draft[0] && + draft[0].children.length === 1 && + 'text' in draft[0].children[0] && + draft[0].children[0].text === ''); + +export const loadDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): Descendant[] => { + const draft = readJson(store, draftKey(userId, roomId)); + return Array.isArray(draft) ? draft : []; +}; + +export const saveDraft = ( + userId: string, + roomId: string, + draft: Descendant[], + store: Store | undefined = defaultStore() +): void => { + if (isEmptyDraft(draft)) { + store?.removeItem(draftKey(userId, roomId)); + return; + } + writeJson(store, draftKey(userId, roomId), draft); +}; + +export const clearDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): void => { + store?.removeItem(draftKey(userId, roomId)); +}; + +export const loadOutbox = ( + userId: string, + store: Store | undefined = defaultStore() +): OutboxEntry[] => { + const entries = readJson(store, outboxKey(userId)); + return Array.isArray(entries) ? entries : []; +}; + +const outboxListeners = new Set<() => void>(); +export const subscribeOutbox = (listener: () => void): (() => void) => { + outboxListeners.add(listener); + return () => outboxListeners.delete(listener); +}; + +const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { + if (entries.length === 0) store?.removeItem(outboxKey(userId)); + else writeJson(store, outboxKey(userId), entries); + outboxListeners.forEach((l) => l()); +}; + +export const enqueueOutbox = ( + userId: string, + entry: Omit, + now: number = Date.now(), + store: Store | undefined = defaultStore() +): void => { + const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); + saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store); +}; + +export const failOutbox = ( + userId: string, + txnId: string, + error: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'failed', error } : e + ), + store + ); +}; + +/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */ +export const retryOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e + ), + store + ); +}; + +/** Server confirmed the send, or the user discarded it. */ +export const removeOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).filter((e) => e.txnId !== txnId), + store + ); +}; -- 2.47.3 From 350ec987e0e06d312809a2627f8097db9f8d71d7 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:06 +0000 Subject: [PATCH 2/3] outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends Soul pass findings: a failed localStorage write no longer loses the entry (memory copy wins until a write succeeds), a stale draft is dropped when its rewrite fails, storage reads/removes cannot throw, and pending entries older than the page load read as interrupted with Resend available. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 4 ++- src/app/features/room/RoomOutbox.tsx | 14 ++++++++- src/app/state/room/composerStore.test.ts | 15 ++++++++++ src/app/state/room/composerStore.ts | 38 ++++++++++++++++++------ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index f0efe1ff..df22f9d4 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -20,7 +20,9 @@ const fakeClient = (send: (...a: unknown[]) => Promise) => describe('sendTracked', () => { beforeEach(stubLocalStorage); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + }); const content = { msgtype: 'm.text', body: 'typed before reload' }; diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 4d3d6aca..cf164472 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -42,6 +42,9 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +const pageStart = Date.now(); + type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** @@ -62,6 +65,8 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { > {entries.map((entry) => { const failed = entry.status === 'failed'; + const interrupted = !failed && entry.ts < pageStart; + const canResend = failed || interrupted; return ( @@ -69,12 +74,17 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { size="T200" style={{ color: failed ? color.Critical.Main : color.Surface.OnContainer }} > - {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + {(() => { + if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; + return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; + })()} {String(entry.content.body ?? '')} + {canResend && ( + <> removeOutbox(userId, entry.txnId)}> Discard + + )} ); })} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index d3957d68..8eb36e4f 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -113,4 +113,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); expect(n).toBe(2); }); + + it('when storage refuses the write, memory keeps the entry so a failed send still shows', () => { + const broken = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + enqueueOutbox('@q:x', entry, 5, broken); + failOutbox('@q:x', 't1', 'offline', broken); + expect(loadOutbox('@q:x', broken)).toMatchObject([{ txnId: 't1', status: 'failed' }]); + removeOutbox('@q:x', 't1', broken); + expect(loadOutbox('@q:x', broken)).toEqual([]); + }); }); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 8401e0d7..1cbe54aa 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -35,20 +35,31 @@ const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}. const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; const readJson = (store: Store | undefined, key: string): T | undefined => { - const raw = store?.getItem(key); - if (raw === null || raw === undefined) return undefined; try { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; return JSON.parse(raw) as T; } catch { return undefined; } }; -const writeJson = (store: Store | undefined, key: string, value: unknown): void => { +const writeJson = (store: Store | undefined, key: string, value: unknown): boolean => { try { - store?.setItem(key, JSON.stringify(value)); + if (!store) return false; + store.setItem(key, JSON.stringify(value)); + return true; } catch { // quota or private mode: the in-memory atom still holds the draft for this session + return false; + } +}; + +const removeKey = (store: Store | undefined, key: string): void => { + try { + store?.removeItem(key); + } catch { + // nothing durable to remove } }; @@ -76,10 +87,10 @@ export const saveDraft = ( store: Store | undefined = defaultStore() ): void => { if (isEmptyDraft(draft)) { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); return; } - writeJson(store, draftKey(userId, roomId), draft); + if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -87,13 +98,18 @@ export const clearDraft = ( roomId: string, store: Store | undefined = defaultStore() ): void => { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); }; +/** Outbox contents whose last write to storage failed: memory is then the only copy, so it wins. */ +const unpersisted = new Map(); + export const loadOutbox = ( userId: string, store: Store | undefined = defaultStore() ): OutboxEntry[] => { + const held = unpersisted.get(userId); + if (held) return held; const entries = readJson(store, outboxKey(userId)); return Array.isArray(entries) ? entries : []; }; @@ -105,8 +121,12 @@ export const subscribeOutbox = (listener: () => void): (() => void) => { }; const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { - if (entries.length === 0) store?.removeItem(outboxKey(userId)); - else writeJson(store, outboxKey(userId), entries); + const ok = + entries.length === 0 + ? (removeKey(store, outboxKey(userId)), true) + : writeJson(store, outboxKey(userId), entries); + if (ok) unpersisted.delete(userId); + else unpersisted.set(userId, entries); outboxListeners.forEach((l) => l()); }; -- 2.47.3 From a5319766758b7e62f1e6a24d1b8ca5665666ddfc Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:26 +0000 Subject: [PATCH 3/3] lint: import order, explicit no-op test stubs Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 4 ++-- src/app/state/room/composerStore.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b3d59d18..e92ae575 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,8 +12,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; -import { RoomOutbox, sendTracked } from './RoomOutbox'; -import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -30,6 +28,8 @@ import { config, toRem, } from 'folds'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 8eb36e4f..92bdcdfd 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -64,7 +64,7 @@ describe('draft', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); }); @@ -120,7 +120,7 @@ describe('outbox', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; enqueueOutbox('@q:x', entry, 5, broken); failOutbox('@q:x', 't1', 'offline', broken); -- 2.47.3