feat(chat): rekeyChannelState moves channel state from oldId to newId
Deletes every channel-keyed entry under oldId (messages, hasMore, typingUsers, readStates, channelAccessTimes, scrollPositions) without seeding newId — subscribers refetch naturally from the new origin. Transfers unreadChannels membership only if oldId was already unread (mirror state, don't over-badge). Updates currentChannelId if it matched oldId. Groundwork for DM origin failover rekey.
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,7 @@ interface ChatState {
|
|||||||
onChannelAck: (channelId: string, messageId: string) => void;
|
onChannelAck: (channelId: string, messageId: string) => void;
|
||||||
onMarkUnread: (channelId: string, messageId: string) => void;
|
onMarkUnread: (channelId: string, messageId: string) => void;
|
||||||
removeChannelStates: (channelIds: Set<string>) => void;
|
removeChannelStates: (channelIds: Set<string>) => void;
|
||||||
|
rekeyChannelState: (oldId: string, newId: string) => void;
|
||||||
updateUserInMessages: (user: { id: string; [key: string]: any }) => void;
|
updateUserInMessages: (user: { id: string; [key: string]: any }) => void;
|
||||||
clearTypingForUser: (userId: string) => void;
|
clearTypingForUser: (userId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -708,6 +709,44 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
rekeyChannelState: (oldId: string, newId: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const copyDelete = <V,>(src: Map<string, V>): Map<string, V> => {
|
||||||
|
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 }) => {
|
updateUserInMessages: (user: { id: string; homeUserId?: string | null; [key: string]: any }) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newMessages = new Map(state.messages);
|
const newMessages = new Map(state.messages);
|
||||||
|
|||||||
Reference in New Issue
Block a user