feat(web): pendingMessageStore with FIFO dedup matcher and TTL discard
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import 'fake-indexeddb/auto';
|
||||||
|
|
||||||
|
// Break the import chain before transferStore pulls voiceStore → AudioManager →
|
||||||
|
// @sapphi-red/web-noise-suppressor (jsdom can't provide AudioWorkletNode).
|
||||||
|
vi.mock('./authStore', () => ({
|
||||||
|
useAuthStore: {
|
||||||
|
getState: () => ({ token: 'test-token', user: { id: 'u-9', username: 'tester' } }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePendingMessageStore, type PendingBubble } from './pendingMessageStore';
|
||||||
|
import { useTransferStore, type Transfer } from './transferStore';
|
||||||
|
|
||||||
|
function bubble(over: Partial<PendingBubble> = {}): PendingBubble {
|
||||||
|
return {
|
||||||
|
clientId: over.clientId ?? 'c-1',
|
||||||
|
channelId: 'ch-1',
|
||||||
|
content: '',
|
||||||
|
replyToId: null,
|
||||||
|
transferIds: ['t-1'],
|
||||||
|
createdAtLocal: 1000,
|
||||||
|
state: 'sending',
|
||||||
|
tusExpiresAt: Date.now() + 60_000,
|
||||||
|
retryCount: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTransfer(over: Partial<Transfer> & Pick<Transfer, 'id'>): Transfer {
|
||||||
|
return {
|
||||||
|
type: 'upload',
|
||||||
|
state: 'queued',
|
||||||
|
file: { name: 'a', size: 1, mimetype: 'image/png' },
|
||||||
|
progress: { loaded: 0, total: 1 },
|
||||||
|
tray: true,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pendingMessageStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
usePendingMessageStore.setState({ bubbles: new Map() });
|
||||||
|
useTransferStore.setState({ transfers: new Map() });
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a bubble in sending state', () => {
|
||||||
|
usePendingMessageStore.getState().append(bubble());
|
||||||
|
expect(usePendingMessageStore.getState().listForChannel('ch-1').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchAndRemove dedups by content + sortedAttachmentIds, oldest first', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map<string, Transfer>([
|
||||||
|
['t-A', makeTransfer({ id: 't-A', state: 'completed', attachmentId: 'att-1', progress: { loaded: 1, total: 1 } })],
|
||||||
|
['t-B', makeTransfer({ id: 't-B', state: 'completed', attachmentId: 'att-2', progress: { loaded: 1, total: 1 } })],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'b1', createdAtLocal: 1000, content: 'x', transferIds: ['t-A', 't-B'] }));
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'b2', createdAtLocal: 2000, content: 'x', transferIds: ['t-A', 't-B'] }));
|
||||||
|
const removed = usePendingMessageStore.getState().matchAndRemove('ch-1', 'x', ['att-2', 'att-1']);
|
||||||
|
expect(removed?.clientId).toBe('b1');
|
||||||
|
expect(usePendingMessageStore.getState().listForChannel('ch-1').map((b) => b.clientId)).toEqual(['b2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discardExpired drops bubbles past tusExpiresAt', () => {
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'old', tusExpiresAt: 1 }));
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'new', tusExpiresAt: Date.now() + 60_000 }));
|
||||||
|
const dropped = usePendingMessageStore.getState().discardExpired(Date.now());
|
||||||
|
expect(dropped.map((b) => b.clientId)).toEqual(['old']);
|
||||||
|
expect(usePendingMessageStore.getState().listForChannel('ch-1').map((b) => b.clientId)).toEqual(['new']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listReadyForDeferredSend returns bubbles whose all transfers have attachmentIds', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map<string, Transfer>([
|
||||||
|
['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-9', progress: { loaded: 1, total: 1 } })],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'r1', transferIds: ['t-1'] }));
|
||||||
|
expect(usePendingMessageStore.getState().listReadyForDeferredSend().map((b) => b.clientId)).toEqual(['r1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matchAndRemove returns null when only some referenced transfers have completed', () => {
|
||||||
|
useTransferStore.setState({
|
||||||
|
transfers: new Map([
|
||||||
|
['t-1', makeTransfer({ id: 't-1', state: 'completed', attachmentId: 'att-1' })],
|
||||||
|
['t-2', makeTransfer({ id: 't-2', state: 'active' })], // no attachmentId yet
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'b1', transferIds: ['t-1', 't-2'] }));
|
||||||
|
const result = usePendingMessageStore.getState().matchAndRemove('ch-1', '', ['att-1', 'att-2']);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(usePendingMessageStore.getState().listForChannel('ch-1').length).toBe(1); // bubble still there
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markFailed on a missing clientId is a no-op (does not throw)', () => {
|
||||||
|
expect(() => usePendingMessageStore.getState().markFailed('does-not-exist')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discardExpired with no expired bubbles does not mutate state', () => {
|
||||||
|
usePendingMessageStore.getState().append(bubble({ clientId: 'live', tusExpiresAt: Date.now() + 60_000 }));
|
||||||
|
const before = usePendingMessageStore.getState().bubbles;
|
||||||
|
const dropped = usePendingMessageStore.getState().discardExpired(Date.now());
|
||||||
|
const after = usePendingMessageStore.getState().bubbles;
|
||||||
|
expect(dropped).toEqual([]);
|
||||||
|
expect(after).toBe(before); // same Map reference
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
|
||||||
|
import { useTransferStore } from './transferStore';
|
||||||
|
|
||||||
|
export type PendingBubbleState = 'sending' | 'failed';
|
||||||
|
|
||||||
|
export interface PendingBubble {
|
||||||
|
clientId: string;
|
||||||
|
channelId: string;
|
||||||
|
content: string;
|
||||||
|
replyToId: string | null;
|
||||||
|
transferIds: string[];
|
||||||
|
createdAtLocal: number;
|
||||||
|
state: PendingBubbleState;
|
||||||
|
tusExpiresAt: number;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingMessageStoreState {
|
||||||
|
bubbles: Map<string, PendingBubble[]>; // channelId → bubbles
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingMessageStoreActions {
|
||||||
|
append: (b: PendingBubble) => void;
|
||||||
|
removeByClientId: (channelId: string, clientId: string) => void;
|
||||||
|
markFailed: (clientId: string) => void;
|
||||||
|
markSending: (clientId: string) => void;
|
||||||
|
bumpRetry: (clientId: string) => void;
|
||||||
|
listForChannel: (channelId: string) => PendingBubble[];
|
||||||
|
matchAndRemove: (
|
||||||
|
channelId: string,
|
||||||
|
content: string,
|
||||||
|
sortedAttachmentIds: string[],
|
||||||
|
) => PendingBubble | null;
|
||||||
|
discardExpired: (now: number) => PendingBubble[];
|
||||||
|
listReadyForDeferredSend: () => PendingBubble[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingMessageStore = PendingMessageStoreState & PendingMessageStoreActions;
|
||||||
|
|
||||||
|
function findBubble(
|
||||||
|
map: Map<string, PendingBubble[]>,
|
||||||
|
clientId: string,
|
||||||
|
): { ch: string; bubble: PendingBubble } | null {
|
||||||
|
for (const [ch, list] of map) {
|
||||||
|
const b = list.find((x) => x.clientId === clientId);
|
||||||
|
if (b) return { ch, bubble: b };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBubble(
|
||||||
|
map: Map<string, PendingBubble[]>,
|
||||||
|
clientId: string,
|
||||||
|
mut: (b: PendingBubble) => PendingBubble,
|
||||||
|
): Map<string, PendingBubble[]> {
|
||||||
|
const found = findBubble(map, clientId);
|
||||||
|
if (!found) return map;
|
||||||
|
const next = new Map(map);
|
||||||
|
const list = next.get(found.ch) ?? [];
|
||||||
|
next.set(
|
||||||
|
found.ch,
|
||||||
|
list.map((b) => (b.clientId === clientId ? mut(b) : b)),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom storage that serializes Map<string, PendingBubble[]> as an array of entries.
|
||||||
|
// Mirrors composerStore's mapAwareStorage; only the `bubbles` slice is persisted.
|
||||||
|
const mapAwareStorage: PersistStorage<Pick<PendingMessageStoreState, 'bubbles'>> = {
|
||||||
|
getItem: (name) => {
|
||||||
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(name) : null;
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
state: { bubbles: [string, PendingBubble[]][] };
|
||||||
|
version?: number;
|
||||||
|
};
|
||||||
|
const stateOut: Pick<PendingMessageStoreState, 'bubbles'> = {
|
||||||
|
bubbles: new Map<string, PendingBubble[]>(parsed.state.bubbles ?? []),
|
||||||
|
};
|
||||||
|
return { state: stateOut, version: parsed.version } as StorageValue<
|
||||||
|
Pick<PendingMessageStoreState, 'bubbles'>
|
||||||
|
>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem: (name, value) => {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const entries = Array.from(value.state.bubbles.entries());
|
||||||
|
const payload = JSON.stringify({ state: { bubbles: entries }, version: value.version });
|
||||||
|
try {
|
||||||
|
localStorage.setItem(name, payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[pendingMessageStore] persist failed:`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.removeItem(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePendingMessageStore = create<PendingMessageStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
bubbles: new Map<string, PendingBubble[]>(),
|
||||||
|
|
||||||
|
append: (b) =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Map(s.bubbles);
|
||||||
|
next.set(b.channelId, [...(next.get(b.channelId) ?? []), b]);
|
||||||
|
return { bubbles: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeByClientId: (channelId, clientId) =>
|
||||||
|
set((s) => {
|
||||||
|
const list = s.bubbles.get(channelId);
|
||||||
|
if (!list) return s;
|
||||||
|
const next = new Map(s.bubbles);
|
||||||
|
next.set(
|
||||||
|
channelId,
|
||||||
|
list.filter((b) => b.clientId !== clientId),
|
||||||
|
);
|
||||||
|
return { bubbles: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
markFailed: (clientId) =>
|
||||||
|
set((s) => ({
|
||||||
|
bubbles: setBubble(s.bubbles, clientId, (b) => ({ ...b, state: 'failed' })),
|
||||||
|
})),
|
||||||
|
|
||||||
|
markSending: (clientId) =>
|
||||||
|
set((s) => ({
|
||||||
|
bubbles: setBubble(s.bubbles, clientId, (b) => ({ ...b, state: 'sending' })),
|
||||||
|
})),
|
||||||
|
|
||||||
|
bumpRetry: (clientId) =>
|
||||||
|
set((s) => ({
|
||||||
|
bubbles: setBubble(s.bubbles, clientId, (b) => ({ ...b, retryCount: b.retryCount + 1 })),
|
||||||
|
})),
|
||||||
|
|
||||||
|
listForChannel: (channelId) => get().bubbles.get(channelId) ?? [],
|
||||||
|
|
||||||
|
matchAndRemove: (channelId, content, sortedAttachmentIds) => {
|
||||||
|
const list = get().bubbles.get(channelId);
|
||||||
|
if (!list) return null;
|
||||||
|
const transfers = useTransferStore.getState().transfers;
|
||||||
|
const target = [...sortedAttachmentIds].sort();
|
||||||
|
const matches = list
|
||||||
|
.filter((b) => b.content === content)
|
||||||
|
.filter((b) => {
|
||||||
|
const ids = b.transferIds
|
||||||
|
.map((tid) => transfers.get(tid)?.attachmentId)
|
||||||
|
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||||
|
const sorted = [...ids].sort();
|
||||||
|
return sorted.length === target.length && sorted.every((v, i) => v === target[i]);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.createdAtLocal - b.createdAtLocal);
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
const winner = matches[0]!;
|
||||||
|
get().removeByClientId(channelId, winner.clientId);
|
||||||
|
return winner;
|
||||||
|
},
|
||||||
|
|
||||||
|
discardExpired: (now) => {
|
||||||
|
const map = get().bubbles;
|
||||||
|
const dropped: PendingBubble[] = [];
|
||||||
|
let mutated = false;
|
||||||
|
const next = new Map<string, PendingBubble[]>();
|
||||||
|
for (const [ch, list] of map) {
|
||||||
|
const surviving: PendingBubble[] = [];
|
||||||
|
for (const b of list) {
|
||||||
|
if (b.tusExpiresAt < now) {
|
||||||
|
dropped.push(b);
|
||||||
|
mutated = true;
|
||||||
|
} else {
|
||||||
|
surviving.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (surviving.length > 0) next.set(ch, surviving);
|
||||||
|
else if (list.length > 0) mutated = true; // dropping a non-empty channel entry counts as mutation
|
||||||
|
}
|
||||||
|
if (mutated) set({ bubbles: next });
|
||||||
|
return dropped;
|
||||||
|
},
|
||||||
|
|
||||||
|
listReadyForDeferredSend: () => {
|
||||||
|
const transfers = useTransferStore.getState().transfers;
|
||||||
|
const ready: PendingBubble[] = [];
|
||||||
|
for (const list of get().bubbles.values()) {
|
||||||
|
for (const b of list) {
|
||||||
|
if (b.state !== 'sending') continue;
|
||||||
|
const allDone = b.transferIds.every((tid) => {
|
||||||
|
const t = transfers.get(tid);
|
||||||
|
return t?.state === 'completed' && !!t.attachmentId;
|
||||||
|
});
|
||||||
|
if (allDone) ready.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'pendingMessageStore@v1',
|
||||||
|
version: 1,
|
||||||
|
storage: mapAwareStorage,
|
||||||
|
partialize: (s) => ({ bubbles: s.bubbles }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user