diff --git a/packages/web/src/stores/composerStore.test.ts b/packages/web/src/stores/composerStore.test.ts new file mode 100644 index 00000000..694a8684 --- /dev/null +++ b/packages/web/src/stores/composerStore.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useComposerStore } from './composerStore'; + +describe('composerStore', () => { + beforeEach(() => useComposerStore.setState({ states: new Map() })); + + it('starts empty for unseen channels', () => { + expect(useComposerStore.getState().get('ch-1')).toEqual({ draftText: '', replyTo: null, stagedTransferIds: [] }); + }); + + it('attaches a transferId to the per-channel staged list', () => { + useComposerStore.getState().attach('ch-1', 't-1'); + expect(useComposerStore.getState().get('ch-1').stagedTransferIds).toEqual(['t-1']); + }); + + it('removes a staged transferId', () => { + useComposerStore.getState().attach('ch-1', 't-1'); + useComposerStore.getState().attach('ch-1', 't-2'); + useComposerStore.getState().removeStaged('ch-1', 't-1'); + expect(useComposerStore.getState().get('ch-1').stagedTransferIds).toEqual(['t-2']); + }); + + it('clear empties draft + replyTo + staged', () => { + useComposerStore.getState().attach('ch-1', 't-1'); + useComposerStore.getState().setDraft('ch-1', 'hi'); + useComposerStore.getState().setReplyTo('ch-1', { id: 'm', userId: 'u', content: 'x' }); + useComposerStore.getState().clear('ch-1'); + expect(useComposerStore.getState().get('ch-1')).toEqual({ draftText: '', replyTo: null, stagedTransferIds: [] }); + }); + + it('attach is idempotent for the same transferId', () => { + useComposerStore.getState().attach('ch-1', 't-1'); + useComposerStore.getState().attach('ch-1', 't-1'); + expect(useComposerStore.getState().get('ch-1').stagedTransferIds).toEqual(['t-1']); + }); +}); diff --git a/packages/web/src/stores/composerStore.ts b/packages/web/src/stores/composerStore.ts new file mode 100644 index 00000000..16fcebe8 --- /dev/null +++ b/packages/web/src/stores/composerStore.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand'; +import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware'; + +export interface ComposerState { + draftText: string; + replyTo: { id: string; userId: string; content: string | null } | null; + stagedTransferIds: string[]; +} + +interface ComposerStoreState { + states: Map; +} + +interface ComposerStoreActions { + get: (channelId: string) => ComposerState; + attach: (channelId: string, transferId: string) => void; + removeStaged: (channelId: string, transferId: string) => void; + setDraft: (channelId: string, draft: string) => void; + setReplyTo: (channelId: string, replyTo: ComposerState['replyTo']) => void; + clear: (channelId: string) => void; +} + +type ComposerStore = ComposerStoreState & ComposerStoreActions; + +const EMPTY: ComposerState = { draftText: '', replyTo: null, stagedTransferIds: [] }; + +// Custom storage that serializes Map as an array of entries. +// Mirrors transferStore's mapAwareStorage pattern; only the `states` 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: { states: [string, ComposerState][] }; version?: number }; + const stateOut: Pick = { + states: new Map(parsed.state.states ?? []), + }; + return { state: stateOut, version: parsed.version } as StorageValue>; + } catch { + return null; + } + }, + setItem: (name, value) => { + if (typeof localStorage === 'undefined') return; + const entries = Array.from(value.state.states.entries()); + const payload = JSON.stringify({ state: { states: entries }, version: value.version }); + try { + localStorage.setItem(name, payload); + } catch (err) { + console.warn(`[composerStore] persist failed:`, err); + } + }, + removeItem: (name) => { + if (typeof localStorage !== 'undefined') localStorage.removeItem(name); + }, +}; + +export const useComposerStore = create()( + persist( + (set, get) => ({ + states: new Map(), + + get: (channelId) => get().states.get(channelId) ?? EMPTY, + + attach: (channelId, transferId) => + set((s) => { + const cur = s.states.get(channelId) ?? EMPTY; + if (cur.stagedTransferIds.includes(transferId)) return s; + const next = new Map(s.states); + next.set(channelId, { ...cur, stagedTransferIds: [...cur.stagedTransferIds, transferId] }); + return { states: next }; + }), + + removeStaged: (channelId, transferId) => + set((s) => { + const cur = s.states.get(channelId) ?? EMPTY; + const next = new Map(s.states); + next.set(channelId, { + ...cur, + stagedTransferIds: cur.stagedTransferIds.filter((t) => t !== transferId), + }); + return { states: next }; + }), + + setDraft: (channelId, draftText) => + set((s) => { + const cur = s.states.get(channelId) ?? EMPTY; + const next = new Map(s.states); + next.set(channelId, { ...cur, draftText }); + return { states: next }; + }), + + setReplyTo: (channelId, replyTo) => + set((s) => { + const cur = s.states.get(channelId) ?? EMPTY; + const next = new Map(s.states); + next.set(channelId, { ...cur, replyTo }); + return { states: next }; + }), + + clear: (channelId) => + set((s) => { + const next = new Map(s.states); + next.set(channelId, EMPTY); + return { states: next }; + }), + }), + { + name: 'composerStore@v1', + version: 1, + storage: mapAwareStorage, + partialize: (s) => ({ states: s.states }), + }, + ), +); diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index 548ee1d2..b6c638a1 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -109,7 +109,11 @@ const mapAwareStorage: PersistStorage> = { if (typeof localStorage === 'undefined') return; const entries = Array.from(value.state.transfers.entries()); const payload = JSON.stringify({ state: { transfers: entries }, version: value.version }); - localStorage.setItem(name, payload); + try { + localStorage.setItem(name, payload); + } catch (err) { + console.warn(`[transferStore] persist failed:`, err); + } }, removeItem: (name) => { if (typeof localStorage !== 'undefined') localStorage.removeItem(name);