From b1844e126f385ff143d175d9b3566ebfe1f32a42 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:29:28 +0200 Subject: [PATCH] feat(federation): dmAlternatives fallback in dm_message_created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new resolution step before the legacy 2-member-identity fallback: if the event's dmChannelId is an alternate-origin local id for a DM whose primary is in dmChannels, route the message to the primary via resolveDmChannelId. Covers 1-on-1 AND group DMs uniformly — closes a pre-existing phantom-sidebar-entry bug for group DMs in multi-instance sessions and handles post-failover routing when the reconnected original origin's WS still addresses the DM by its old local id. --- .../hooks/dmMessageCreated.fallback.test.ts | 70 +++++++++++++++++++ packages/web/src/hooks/useWebSocket.ts | 26 ++++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/hooks/dmMessageCreated.fallback.test.ts diff --git a/packages/web/src/hooks/dmMessageCreated.fallback.test.ts b/packages/web/src/hooks/dmMessageCreated.fallback.test.ts new file mode 100644 index 00000000..81230cd2 --- /dev/null +++ b/packages/web/src/hooks/dmMessageCreated.fallback.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../hooks/useWebSocket', async () => { + const actual = await vi.importActual('../hooks/useWebSocket'); + return actual; +}); + +// 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('../stores/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('../stores/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, resolveDmChannelId } from '../stores/spaceStore'; +import type { DmChannel } from '@backspace/shared'; + +function dm(id: string, federatedId: string | null, members: any[] = []): DmChannel { + return { id, federatedId, createdAt: 1000, members }; +} + +beforeEach(() => { useSpaceStore.getState().reset(); }); + +describe('resolveDmChannelId (contract for dm_message_created fallback)', () => { + it('resolves alternate-origin group-DM id to primary (closes pre-existing phantom-entry bug)', () => { + // Group DM: primary pinned to home (home-gdm), alternate on remote (remote-gdm). + useSpaceStore.setState({ + dmChannels: [dm('home-gdm', 'fed-group', [{ id: 'u1' } as any, { id: 'u2' } as any, { id: 'u3' } as any])], + channelOriginMap: new Map([['home-gdm', '']]), + dmAlternatives: new Map([ + ['fed-group', new Map([ + ['', 'home-gdm'], + ['https://remote.example', 'remote-gdm'], + ])], + ]), + }); + + // A dm_message_created event from remote.example carries dmChannelId = 'remote-gdm'. + // The handler's first lookup (dmChannels.find(id === 'remote-gdm')) returns undefined. + // The new dmAlternatives fallback must resolve this to 'home-gdm'. + expect(resolveDmChannelId('remote-gdm')).toBe('home-gdm'); + }); +}); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 5cd33c53..80fc875c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from 'react'; import { useAuthStore } from '../stores/authStore'; -import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin } from '../stores/spaceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore'; import { useChatStore } from '../stores/chatStore'; import { useVoiceStore } from '../stores/voiceStore'; import { useSocialStore } from '../stores/socialStore'; @@ -679,6 +679,30 @@ function handleEvent(origin: string, event: ServerEvent): void { // (same conversation, different channel ID). If so, skip adding a new sidebar entry // and route the message to the existing channel instead. if (!knownDm) { + // dmAlternatives-based resolution: if this channelId is an alternate-origin + // local id for a DM whose primary is in dmChannels, reroute to the primary. + // Covers 1-on-1 AND group DMs uniformly; also the post-failover path where + // the reconnected original origin's WS still uses its old local id. + const primaryId = resolveDmChannelId(event.message.dmChannelId); + if (primaryId && primaryId !== event.message.dmChannelId) { + addRealtimeMessage(primaryId, { ...event.message, dmChannelId: primaryId } as any); + const updatedDms = currentDmChannels.map(dm => + dm.id === primaryId ? { ...dm, lastMessage: event.message } : dm, + ); + const { unreadChannels: u1, currentChannelId: c1 } = useChatStore.getState(); + setDms(sortDmChannels(updatedDms, u1, c1)); + { + const { currentChannelId: u1cc, markChannelUnread: u1mu } = useChatStore.getState(); + const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); + if (primaryId !== u1cc && event.message.userId !== myId) { + u1mu(primaryId); + } + } + break; + } + + // Legacy 2-member-identity fallback: covers DMs without a federatedId + // (pre-federation or never-federated 1-on-1 DMs). const msgUser = event.message.user; const msgHomeUserId = msgUser?.homeUserId || msgUser?.id; if (msgHomeUserId) {