feat(federation): dmAlternatives fallback in dm_message_created
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.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', async () => {
|
||||
const actual = await vi.importActual<any>('../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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user