feat(web): composerStore (per-channel draft + staged transfer ids)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string, ComposerState>;
|
||||
}
|
||||
|
||||
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<string, ComposerState> as an array of entries.
|
||||
// Mirrors transferStore's mapAwareStorage pattern; only the `states` slice is persisted.
|
||||
const mapAwareStorage: PersistStorage<Pick<ComposerStoreState, 'states'>> = {
|
||||
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<ComposerStoreState, 'states'> = {
|
||||
states: new Map<string, ComposerState>(parsed.state.states ?? []),
|
||||
};
|
||||
return { state: stateOut, version: parsed.version } as StorageValue<Pick<ComposerStoreState, 'states'>>;
|
||||
} 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<ComposerStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
states: new Map<string, ComposerState>(),
|
||||
|
||||
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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -109,7 +109,11 @@ const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const entries = Array.from(value.state.transfers.entries());
|
||||
const payload = JSON.stringify({ state: { transfers: entries }, version: value.version });
|
||||
try {
|
||||
localStorage.setItem(name, payload);
|
||||
} catch (err) {
|
||||
console.warn(`[transferStore] persist failed:`, err);
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(name);
|
||||
|
||||
Reference in New Issue
Block a user