diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b4483d8b..e92ae575 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -28,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 { @@ -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..cf164472 --- /dev/null +++ b/src/app/features/room/RoomOutbox.tsx @@ -0,0 +1,108 @@ +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; +}; + +/** 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 }; + +/** + * 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'; + const interrupted = !failed && entry.ts < pageStart; + const canResend = failed || interrupted; + return ( + + + + {(() => { + 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 && ( + <> + { + 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..92bdcdfd --- /dev/null +++ b/src/app/state/room/composerStore.test.ts @@ -0,0 +1,131 @@ +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: () => undefined, + }; + 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); + }); + + 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: () => undefined, + }; + 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 new file mode 100644 index 00000000..1cbe54aa --- /dev/null +++ b/src/app/state/room/composerStore.ts @@ -0,0 +1,184 @@ +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 => { + 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): boolean => { + try { + 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 + } +}; + +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)) { + removeKey(store, draftKey(userId, roomId)); + return; + } + if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); +}; + +export const clearDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): void => { + 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 : []; +}; + +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) => { + 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()); +}; + +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 + ); +};