composer: persist the draft on input, keep unconfirmed sends in a durable outbox #122
5 changed files with 579 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
57
src/app/features/room/RoomOutbox.test.ts
Normal file
57
src/app/features/room/RoomOutbox.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
src/app/features/room/RoomOutbox.tsx
Normal file
108
src/app/features/room/RoomOutbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/app/state/room/composerStore.test.ts
Normal file
131
src/app/state/room/composerStore.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
184
src/app/state/room/composerStore.ts
Normal file
184
src/app/state/room/composerStore.ts
Normal 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
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue