composer: persist the draft on input, keep unconfirmed sends in a durable outbox #122

Merged
void merged 3 commits from void/draft-persist into dev 2026-11-12 21:17:30 +00:00
5 changed files with 579 additions and 36 deletions

View file

@ -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<HTMLDivElement, RoomInputProps>(
useCallback((width) => setHideStickerBtn(width < 500), [])
);
const userId = mx.getUserId() ?? '';
const draftSaveTimer = useRef<ReturnType<typeof setTimeout>>();
// 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onChange={handleEditorChange}
top={
replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
<>
<RoomOutbox mx={mx} roomId={roomId} />
{replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</Box>
</div>
)
</div>
)}
</>
}
before={
<IconButton

View file

@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadOutbox } from '../../state/room/composerStore';
import { sendTracked } from './RoomOutbox';
const stubLocalStorage = () => {
const m = new Map<string, string>();
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<unknown>) =>
({ 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');
});
});

View file

@ -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<void> =>
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<string, unknown>
): Promise<void> => {
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 (
<Box
direction="Column"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
{entries.map((entry) => {
const failed = entry.status === 'failed';
const interrupted = !failed && entry.ts < pageStart;
const canResend = failed || interrupted;
return (
<Box key={entry.txnId} alignItems="Center" gap="200" data-outbox-status={entry.status}>
<Box grow="Yes" direction="Column">
<Text
size="T200"
style={{ color: failed ? color.Critical.Main : color.Surface.OnContainer }}
>
{(() => {
if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`;
return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…';
})()}
</Text>
<Text size="T300" truncate>
{String(entry.content.body ?? '')}
</Text>
</Box>
{canResend && (
<>
<Chip
variant="Surface"
radii="Pill"
onClick={() => {
retryOutbox(userId, entry.txnId);
attempt(mx, userId, entry);
}}
>
<Text size="B300">Resend</Text>
</Chip>
<Chip variant="Surface" radii="Pill" onClick={() => removeOutbox(userId, entry.txnId)}>
<Text size="B300">Discard</Text>
</Chip>
</>
)}
</Box>
);
})}
</Box>
);
}

View file

@ -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<string, string>();
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([]);
});
});

View file

@ -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<string, unknown>;
status: OutboxStatus;
error?: string;
ts: number;
};
type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
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 = <T>(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<Descendant[]>(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<string, OutboxEntry[]>();
export const loadOutbox = (
userId: string,
store: Store | undefined = defaultStore()
): OutboxEntry[] => {
const held = unpersisted.get(userId);
if (held) return held;
const entries = readJson<OutboxEntry[]>(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<OutboxEntry, 'status' | 'ts'>,
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
);
};