diff --git a/packages/web/src/stores/chatStore.rekey.test.ts b/packages/web/src/stores/chatStore.rekey.test.ts new file mode 100644 index 00000000..1e61b5ae --- /dev/null +++ b/packages/web/src/stores/chatStore.rekey.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../hooks/useWebSocket', () => ({ + wsSend: vi.fn(), + wsSendAll: vi.fn(), +})); + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +vi.mock('./instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +// Stub authStore to avoid localStorage access during module init +vi.mock('./authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + } + ), +})); + +import { useChatStore } from './chatStore'; +import type { MessageWithUser } from '@backspace/shared'; + +function msg(id: string): MessageWithUser { + return { + id, + channelId: 'c', + userId: 'u', + content: 'hi', + createdAt: 1, + user: { id: 'u', username: 'u', displayName: null, avatar: null, homeInstance: null, homeUserId: null, accentColor: null, banner: null, bio: null, status: 'online', activities: [], createdAt: 1 } as any, + attachments: [], + embeds: [], + reactions: [], + }; +} + +beforeEach(() => { + const s = useChatStore.getState(); + // Reset every map/set/scalar this test touches. + useChatStore.setState({ + messages: new Map(), + typingUsers: new Map(), + hasMore: new Map(), + readStates: new Map(), + unreadChannels: new Set(), + channelAccessTimes: new Map(), + scrollPositions: new Map(), + currentChannelId: null, + }); + void s; +}); + +describe('chatStore.rekeyChannelState', () => { + it('removes all channel-keyed state for oldId', () => { + useChatStore.setState({ + messages: new Map([['A1', [msg('m1')]]]), + typingUsers: new Map([['A1', [{ userId: 'u', username: 'u', timestamp: 1 }]]]), + hasMore: new Map([['A1', true]]), + readStates: new Map([['A1', 'msg-last']]), + channelAccessTimes: new Map([['A1', 123]]), + scrollPositions: new Map([['A1', 'msg-scroll']]), + }); + + useChatStore.getState().rekeyChannelState('A1', 'B1'); + + const s = useChatStore.getState(); + expect(s.messages.has('A1')).toBe(false); + expect(s.typingUsers.has('A1')).toBe(false); + expect(s.hasMore.has('A1')).toBe(false); + expect(s.readStates.has('A1')).toBe(false); + expect(s.channelAccessTimes.has('A1')).toBe(false); + expect(s.scrollPositions.has('A1')).toBe(false); + // newId entries are NOT seeded for messages/hasMore/etc — they refetch naturally. + expect(s.messages.has('B1')).toBe(false); + }); + + it('transfers unreadChannels membership only when oldId was unread', () => { + useChatStore.setState({ unreadChannels: new Set(['A1']) }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().unreadChannels.has('A1')).toBe(false); + expect(useChatStore.getState().unreadChannels.has('B1')).toBe(true); + }); + + it('does not add newId to unreadChannels if oldId was not unread', () => { + useChatStore.setState({ unreadChannels: new Set(['other']) }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().unreadChannels.has('B1')).toBe(false); + expect(useChatStore.getState().unreadChannels.has('other')).toBe(true); + }); + + it('updates currentChannelId when it matches oldId', () => { + useChatStore.setState({ currentChannelId: 'A1' }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().currentChannelId).toBe('B1'); + }); + + it('leaves currentChannelId alone when it does not match oldId', () => { + useChatStore.setState({ currentChannelId: 'other' }); + useChatStore.getState().rekeyChannelState('A1', 'B1'); + expect(useChatStore.getState().currentChannelId).toBe('other'); + }); + + it('no-ops cleanly when oldId is not present anywhere', () => { + expect(() => useChatStore.getState().rekeyChannelState('missing', 'B1')).not.toThrow(); + }); +}); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 3d75cb9a..cd48831e 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -63,6 +63,7 @@ interface ChatState { onChannelAck: (channelId: string, messageId: string) => void; onMarkUnread: (channelId: string, messageId: string) => void; removeChannelStates: (channelIds: Set) => void; + rekeyChannelState: (oldId: string, newId: string) => void; updateUserInMessages: (user: { id: string; [key: string]: any }) => void; clearTypingForUser: (userId: string) => void; } @@ -708,6 +709,44 @@ export const useChatStore = create((set, get) => ({ }); }, + rekeyChannelState: (oldId: string, newId: string) => { + set((state) => { + const copyDelete = (src: Map): Map => { + if (!src.has(oldId)) return src; + const next = new Map(src); + next.delete(oldId); + return next; + }; + + const messages = copyDelete(state.messages); + const typingUsers = copyDelete(state.typingUsers); + const hasMore = copyDelete(state.hasMore); + const readStates = copyDelete(state.readStates); + const channelAccessTimes = copyDelete(state.channelAccessTimes); + const scrollPositions = copyDelete(state.scrollPositions); + + let unreadChannels = state.unreadChannels; + if (state.unreadChannels.has(oldId)) { + unreadChannels = new Set(state.unreadChannels); + unreadChannels.delete(oldId); + unreadChannels.add(newId); + } + + const currentChannelId = state.currentChannelId === oldId ? newId : state.currentChannelId; + + return { + messages, + typingUsers, + hasMore, + readStates, + channelAccessTimes, + scrollPositions, + unreadChannels, + currentChannelId, + }; + }); + }, + updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => { set((state) => { const newMessages = new Map(state.messages);