diff --git a/packages/web/src/stores/spaceStore.dmAlternatives.test.ts b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts new file mode 100644 index 00000000..d08b7e78 --- /dev/null +++ b/packages/web/src/stores/spaceStore.dmAlternatives.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Stub AudioManager to avoid AudioWorkletNode reference error in jsdom +// (spaceStore → authStore → voiceStore → AudioManager → AudioWorkletNode) +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore to avoid initialization ordering issues +// (spaceStore → authStore → socialStore → instanceStore → setApiForOriginResolver) +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 { useSpaceStore } from './spaceStore'; +import type { DmChannel } from '@backspace/shared'; + +function makeDm(id: string, federatedId: string | null, extras: Partial = {}): DmChannel { + return { + id, + federatedId, + createdAt: 1000, + members: [], + ...extras, + }; +} + +beforeEach(() => { + useSpaceStore.getState().reset(); +}); + +describe('spaceStore.dmAlternatives', () => { + it('records origin + local channel id for each DM with federatedId on populateFromReady', () => { + const dmsFromHome: DmChannel[] = [ + makeDm('home-1', 'fed-aaa'), + makeDm('home-2', 'fed-bbb'), + makeDm('home-3', null), // no federatedId — not indexed + ]; + useSpaceStore.getState().populateFromReady('', [], [], dmsFromHome, null, 0); + + const alts = useSpaceStore.getState().dmAlternatives; + expect(alts.get('fed-aaa')?.get('')).toBe('home-1'); + expect(alts.get('fed-bbb')?.get('')).toBe('home-2'); + expect(alts.has(null as any)).toBe(false); + }); + + it('accumulates entries across multiple origins for the same federatedId', () => { + useSpaceStore.getState().populateFromReady( + '', + [], + [], + [makeDm('home-1', 'fed-aaa')], + null, + 0, + ); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-1', 'fed-aaa')], + null, + 0, + ); + + const byOrigin = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + expect(byOrigin?.get('')).toBe('home-1'); + expect(byOrigin?.get('https://remote.example')).toBe('remote-1'); + }); + + it('updates the local id if the same origin reports a different id for a federatedId', () => { + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-old', 'fed-aaa')], + null, + 0, + ); + useSpaceStore.getState().populateFromReady( + 'https://remote.example', + [], + [], + [makeDm('remote-new', 'fed-aaa')], + null, + 0, + ); + + const byOrigin = useSpaceStore.getState().dmAlternatives.get('fed-aaa'); + expect(byOrigin?.get('https://remote.example')).toBe('remote-new'); + expect(byOrigin?.size).toBe(1); + }); +}); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index cafec7a1..e01d1d93 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -41,6 +41,8 @@ interface SpaceState { channelOriginMap: Map; // channelId → instance origin ('' = home) voiceChannelIds: Set; // channelIds that are voice channels (excluded from unread) categoryOriginMap: Map; // categoryId → instance origin ('' = home) + /** federatedId → (origin → localChannelId). Every DM from every origin's ready payload is recorded here regardless of dedup outcome, so failover can re-point to an alternate origin's local channel ID. */ + dmAlternatives: Map>; loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching _layoutUpdatedAt: number; setSpaces: (spaces: TaggedSpace[]) => void; @@ -132,6 +134,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap: new Map(), voiceChannelIds: new Set(), categoryOriginMap: new Map(), + dmAlternatives: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, @@ -154,6 +157,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap: new Map(), voiceChannelIds: new Set(), categoryOriginMap: new Map(), + dmAlternatives: new Map(), loadingSpaceId: null, _layoutUpdatedAt: 0, }); @@ -584,6 +588,10 @@ export const useSpaceStore = create((set, get) => ({ const channelOriginMap = new Map(get().channelOriginMap); const voiceChannelIds = new Set(get().voiceChannelIds); const categoryOriginMap = new Map(get().categoryOriginMap); + const dmAlternatives = new Map>(); + for (const [fid, byOrigin] of get().dmAlternatives) { + dmAlternatives.set(fid, new Map(byOrigin)); + } // If home, clear home-origin entries first to avoid stale data if (isHome) { @@ -688,6 +696,18 @@ export const useSpaceStore = create((set, get) => ({ } } + // Record every DM's (origin → localChannelId) for failover lookup, + // regardless of whether the dedup pass kept this copy in dmChannels. + for (const dm of incomingDms) { + if (!dm.federatedId) continue; + let byOrigin = dmAlternatives.get(dm.federatedId); + if (!byOrigin) { + byOrigin = new Map(); + dmAlternatives.set(dm.federatedId, byOrigin); + } + byOrigin.set(origin, dm.id); + } + // Merge: remove DMs belonging to this origin from existing state, then append incoming const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => { const dmOrigin = get().channelOriginMap.get(dm.id); @@ -711,6 +731,7 @@ export const useSpaceStore = create((set, get) => ({ channelOriginMap, voiceChannelIds, categoryOriginMap, + dmAlternatives, }; // LWW layout merge: accept incoming layout only if its timestamp is >= ours