feat(web): composerStore (per-channel draft + staged transfer ids)

This commit is contained in:
Jannis Braun
2026-05-02 15:41:55 +02:00
parent 2864d84a0f
commit c986f734ee
3 changed files with 156 additions and 1 deletions
@@ -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']);
});
});
+115
View File
@@ -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 }),
},
),
);
+5 -1
View File
@@ -109,7 +109,11 @@ const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return;
const entries = Array.from(value.state.transfers.entries()); const entries = Array.from(value.state.transfers.entries());
const payload = JSON.stringify({ state: { transfers: entries }, version: value.version }); 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) => { removeItem: (name) => {
if (typeof localStorage !== 'undefined') localStorage.removeItem(name); if (typeof localStorage !== 'undefined') localStorage.removeItem(name);