diff --git a/packages/web/src/stores/pendingMessageStore.test.ts b/packages/web/src/stores/pendingMessageStore.test.ts new file mode 100644 index 00000000..9f146400 --- /dev/null +++ b/packages/web/src/stores/pendingMessageStore.test.ts @@ -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 { + 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 & Pick): 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([ + ['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([ + ['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 + }); +}); diff --git a/packages/web/src/stores/pendingMessageStore.ts b/packages/web/src/stores/pendingMessageStore.ts new file mode 100644 index 00000000..01858758 --- /dev/null +++ b/packages/web/src/stores/pendingMessageStore.ts @@ -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; // 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, + 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, + clientId: string, + mut: (b: PendingBubble) => PendingBubble, +): Map { + 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 as an array of entries. +// Mirrors composerStore's mapAwareStorage; only the `bubbles` slice is persisted. +const mapAwareStorage: PersistStorage> = { + 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 = { + bubbles: new Map(parsed.state.bubbles ?? []), + }; + return { state: stateOut, version: parsed.version } as StorageValue< + Pick + >; + } 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()( + persist( + (set, get) => ({ + bubbles: new Map(), + + 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(); + 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 }), + }, + ), +);