Merge branch 'feat/dm-origin-failover'
Client-side DM origin failover on WS disconnect (backlog #10). When a remote instance's WebSocket drops mid-session, every DM pinned to that origin is re-keyed to a connected sibling that mirrors the same federated DM via S2S replication. Covers the channel-ID-per-origin reality (each instance assigns its own local Snowflake; only federatedId is shared) by keeping a `dmAlternatives: Map<federatedId, Map<origin, localChannelId>>` on spaceStore, populated by every `ready` payload regardless of dedup outcome. On transition, `rekeyDmChannel` atomically renames the DM across spaceStore (dmChannels / channelOriginMap / channelLastMessageIds / dmAlternatives), chatStore (messages / hasMore / scrollPositions / channelAccessTimes / typingUsers / readStates / unreadChannels / currentChannelId), and the URL (history.replaceState when viewing the rekeyed DM). Triggers: setInstanceStatus on connected→disconnected|error, and disconnectInstance / forceRemoveEntry before removeInstanceSpaces. Voice state (activeDmCall / outgoingCall / incomingCall) is intentionally not rewritten — LiveKit rooms can't migrate across origins. As an in-scope adjacent fix (§3.11 of the spec), `dm_message_created` now consults `dmAlternatives` before its legacy 2-member-identity fallback via a new `resolveDmChannelId(rawId)` helper. This 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. Live verification on Pi+VM (youruser@nova.ddns.net with Orbit.Backspace as remote): (1) baseline — DM pinned to home, WS drop of remote is a no-op, reconnect clean, no flap; (2) forced-rekey path — WebSocket construction delayed on wss://nova.ddns.net via a client-side patch so orbit's `ready` arrived first, pinning the Nova DM to orbit with orbit's local id. Stopping the orbit container triggered failoverDmOriginsFromDisconnected; URL auto-swapped from `/channels/@me/<orbit-local-id>` to `/channels/@me/<nova-local-id>` via history.replaceState, the chat view re-fetched from nova via the new primary id, and subsequent message sends routed to nova. Restart of orbit left the pin on nova — no re-home flap (§3.6). Group-DM phantom fix covered by unit tests (9 in dmOriginFailover.test.ts plus contract test); not exercised live because it requires concurrent delivery from the non-primary origin's WS, which the forced-rekey session didn't naturally produce. Design: internal notes Plan: internal notes Pre-existing test failures on main (keybindStore, FriendsPage, InviteModal, JoinSpace — 12 tests) are unchanged by this branch.
This commit is contained in:
@@ -153,6 +153,29 @@ Built during `populateFromReady()` when WS ready events arrive from each instanc
|
|||||||
|
|
||||||
> **DM channels** are mapped to the origin of the instance that delivered them in the `ready` event. For 1-on-1 DMs created locally this is typically `''` (home), but federated DMs may arrive from any connected instance. DM read/write operations are routed to the channel's origin via `getApiForOrigin(getChannelOrigin(channelId))`. S2S relay then propagates changes to all other instances that have the same channel.
|
> **DM channels** are mapped to the origin of the instance that delivered them in the `ready` event. For 1-on-1 DMs created locally this is typically `''` (home), but federated DMs may arrive from any connected instance. DM read/write operations are routed to the channel's origin via `getApiForOrigin(getChannelOrigin(channelId))`. S2S relay then propagates changes to all other instances that have the same channel.
|
||||||
|
|
||||||
|
### DM Origin Failover
|
||||||
|
|
||||||
|
When a remote instance's WebSocket drops mid-session, every DM pinned to that origin is re-keyed to a connected sibling that mirrors the same federated DM (via S2S replication). This keeps DM operations working through a transient disconnect, at the cost of a brief message-cache flush on the rekeyed DMs.
|
||||||
|
|
||||||
|
**Mechanism:**
|
||||||
|
|
||||||
|
- `dmAlternatives: Map<federatedId, Map<origin, localChannelId>>` on `spaceStore` records every origin's local channel ID observed in any `ready` payload, regardless of whether the dedup pass kept that copy in `dmChannels`.
|
||||||
|
- `failoverDmOriginsFromDisconnected(origin)` in `utils/dmOriginFailover.ts` walks DMs pinned to the disconnected origin, looks up a connected alternate in `dmAlternatives` (preference: home first, then any connected remote in insertion order), and calls `rekeyDmChannel` to atomically rename the DM across `spaceStore`, `chatStore`, and the URL.
|
||||||
|
- `chatStore.rekeyChannelState(oldId, newId)` deletes all channel-keyed entries for `oldId` (messages, hasMore, scrollPositions, channelAccessTimes, typingUsers, readStates) without seeding `newId` — subscribers re-fetch from the new origin. `unreadChannels` membership transfers only if `oldId` was already unread. `currentChannelId` updates when it matches `oldId`.
|
||||||
|
- URL: `history.replaceState` swaps the path segment in place when the user is viewing the rekeyed DM — no router navigation.
|
||||||
|
|
||||||
|
**Triggers:** `instanceStore.setInstanceStatus` on `connected → disconnected|error`; `disconnectInstance` and `forceRemoveEntry` call failover before `removeInstanceSpaces` so DMs with connected alternatives survive user-initiated disconnect.
|
||||||
|
|
||||||
|
**Intentional UX trade-off:** on failover, the active DM's message cache is flushed (origin-local message IDs don't match the new origin's responses). A brief "loading" state appears while the chat view re-fetches. Documented intentionally — failover is a recovery path, not the hot path.
|
||||||
|
|
||||||
|
**Voice is out of scope.** LiveKit rooms are bound to the hosting origin and cannot migrate. `voiceStore.activeDmCall` / `outgoingCall` / `incomingCall` are not rewritten by failover; voice state clears through existing LiveKit disconnect paths.
|
||||||
|
|
||||||
|
**No re-home on reconnect:** when the originally pinned origin comes back, its `ready` re-adds its local id to `dmAlternatives` but leaves the new primary in place. Avoids flapping.
|
||||||
|
|
||||||
|
**WS event routing contract:** every DM WS event handler either routes via the primary `dmChannels` id (using `resolveDmChannelId(rawId)`) or silently no-ops on unknown ids. Only `dm_channel_created` creates new `dmChannels` entries — and it dedups by `federatedId` first.
|
||||||
|
|
||||||
|
Source: `utils/dmOriginFailover.ts` + extensions in `stores/spaceStore.ts`, `stores/chatStore.ts`, `stores/instanceStore.ts`, `hooks/useWebSocket.ts`. Design spec: `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`.
|
||||||
|
|
||||||
### API Client Resolution
|
### API Client Resolution
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ The format difference (32-char hex vs 36-char UUID with dashes) allows detecting
|
|||||||
|
|
||||||
**No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed.
|
**No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed.
|
||||||
|
|
||||||
**Client routing:** DM creation always goes to the home instance. For federated users, the client passes `{ homeUserId, homeInstance }` and the server resolves the target via `resolveOrCreateReplicatedUser()`. The `federatedId` is computed at creation time when either participant has `homeInstance` set.
|
**Client routing:** DM creation always goes to the home instance. For federated users, the client passes `{ homeUserId, homeInstance }` and the server resolves the target via `resolveOrCreateReplicatedUser()`. The `federatedId` is computed at creation time when either participant has `homeInstance` set. Post-creation, every DM operation (message send/edit/delete, close/leave, typing, reactions, read-state acks) routes through `getChannelOrigin(channelId)` → `getApiForOrigin(origin)`. If the pinned origin drops mid-session, client-side failover re-keys the DM to a connected sibling that mirrors the same `federatedId` — see `docs/systems/client-federation.md` "DM Origin Failover".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 React, { useEffect, useRef } from 'react';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
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 { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useSocialStore } from '../stores/socialStore';
|
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
|
// (same conversation, different channel ID). If so, skip adding a new sidebar entry
|
||||||
// and route the message to the existing channel instead.
|
// and route the message to the existing channel instead.
|
||||||
if (!knownDm) {
|
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 msgUser = event.message.user;
|
||||||
const msgHomeUserId = msgUser?.homeUserId || msgUser?.id;
|
const msgHomeUserId = msgUser?.homeUserId || msgUser?.id;
|
||||||
if (msgHomeUserId) {
|
if (msgHomeUserId) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockFailover = vi.fn();
|
||||||
|
vi.mock('../utils/dmOriginFailover', () => ({
|
||||||
|
failoverDmOriginsFromDisconnected: (o: string) => mockFailover(o),
|
||||||
|
}));
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({
|
||||||
|
connectInstance: vi.fn(),
|
||||||
|
disconnectInstance: vi.fn(),
|
||||||
|
disconnectAllRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: 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 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 { useInstanceStore } from './instanceStore';
|
||||||
|
import type { ConnectedInstance } from './instanceStore';
|
||||||
|
|
||||||
|
function inst(origin: string, status: ConnectedInstance['status']): ConnectedInstance {
|
||||||
|
return {
|
||||||
|
origin, label: origin, token: 'tok', username: 'u', status,
|
||||||
|
user: { id: 'u', username: 'u' } as any,
|
||||||
|
api: {} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFailover.mockClear();
|
||||||
|
useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instanceStore failover triggers', () => {
|
||||||
|
it('fires failover on connected → disconnected transition', () => {
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
|
||||||
|
useInstanceStore.getState().setInstanceStatus('https://b.example', 'disconnected');
|
||||||
|
expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires failover on connected → error transition', () => {
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
|
||||||
|
useInstanceStore.getState().setInstanceStatus('https://b.example', 'error');
|
||||||
|
expect(mockFailover).toHaveBeenCalledExactlyOnceWith('https://b.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire on connecting → connected', () => {
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'connecting')] });
|
||||||
|
useInstanceStore.getState().setInstanceStatus('https://b.example', 'connected');
|
||||||
|
expect(mockFailover).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire on disconnected → error (no connected source)', () => {
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'disconnected')] });
|
||||||
|
useInstanceStore.getState().setInstanceStatus('https://b.example', 'error');
|
||||||
|
expect(mockFailover).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when instance is not in the list', () => {
|
||||||
|
useInstanceStore.getState().setInstanceStatus('https://unknown.example', 'disconnected');
|
||||||
|
expect(mockFailover).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnectInstance runs failover before removeInstanceSpaces', async () => {
|
||||||
|
// Seed a DM pinned to b.example with home as alternative — removeInstanceSpaces
|
||||||
|
// uses spaceStore, which we let run; we just check failover ran first (call order).
|
||||||
|
const spaceModule = await import('./spaceStore');
|
||||||
|
const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces');
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
mockFailover.mockImplementation(() => { callOrder.push('failover'); });
|
||||||
|
spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); });
|
||||||
|
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
|
||||||
|
useInstanceStore.getState().disconnectInstance('https://b.example');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']);
|
||||||
|
spaceSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forceRemoveEntry runs failover before removeInstanceSpaces', async () => {
|
||||||
|
const spaceModule = await import('./spaceStore');
|
||||||
|
const spaceSpy = vi.spyOn(spaceModule.useSpaceStore.getState(), 'removeInstanceSpaces');
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
mockFailover.mockImplementation(() => { callOrder.push('failover'); });
|
||||||
|
spaceSpy.mockImplementation(() => { callOrder.push('removeInstanceSpaces'); });
|
||||||
|
|
||||||
|
useInstanceStore.setState({ instances: [inst('https://b.example', 'connected')] });
|
||||||
|
useInstanceStore.getState().forceRemoveEntry('https://b.example');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['failover', 'removeInstanceSpaces']);
|
||||||
|
spaceSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,9 @@ import { connectInstance, disconnectInstance as disconnectWs, disconnectAllRemot
|
|||||||
// Safe because both modules access each other lazily (at call time, not import time).
|
// Safe because both modules access each other lazily (at call time, not import time).
|
||||||
// clearPasswordSyncTimers itself does not reference useInstanceStore.
|
// clearPasswordSyncTimers itself does not reference useInstanceStore.
|
||||||
import { clearPasswordSyncTimers } from '../utils/federationOps';
|
import { clearPasswordSyncTimers } from '../utils/federationOps';
|
||||||
|
// dmOriginFailover lazily reads useInstanceStore/useSpaceStore/useChatStore at call time,
|
||||||
|
// so a static import here does not create an import-time cycle.
|
||||||
|
import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover';
|
||||||
import { useUIStore } from './uiStore';
|
import { useUIStore } from './uiStore';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -428,11 +431,15 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setInstanceStatus: (origin, status, error) => {
|
setInstanceStatus: (origin, status, error) => {
|
||||||
|
const prev = get().instances.find(i => i.origin === origin)?.status;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
instances: state.instances.map(i =>
|
instances: state.instances.map(i =>
|
||||||
i.origin === origin ? { ...i, status, error } : i
|
i.origin === origin ? { ...i, status, error } : i
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
if (prev === 'connected' && (status === 'disconnected' || status === 'error')) {
|
||||||
|
failoverDmOriginsFromDisconnected(origin);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnectInstance: (origin: string) => {
|
disconnectInstance: (origin: string) => {
|
||||||
@@ -459,7 +466,10 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
return { instances: updated, registry, registryUpdatedAt };
|
return { instances: updated, registry, registryUpdatedAt };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove spaces from this instance from the space store
|
// Failover DMs to a connected sibling BEFORE removeInstanceSpaces wipes
|
||||||
|
// this origin's pins. DMs with a connected alternative survive via rekey;
|
||||||
|
// DMs without one are removed alongside the rest of the instance's content.
|
||||||
|
failoverDmOriginsFromDisconnected(origin);
|
||||||
useSpaceStore.getState().removeInstanceSpaces(origin);
|
useSpaceStore.getState().removeInstanceSpaces(origin);
|
||||||
|
|
||||||
// Sync updated lists to remaining instances (fire-and-forget)
|
// Sync updated lists to remaining instances (fire-and-forget)
|
||||||
@@ -749,7 +759,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
return { instances: updated, registry, registryUpdatedAt };
|
return { instances: updated, registry, registryUpdatedAt };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up spaces belonging to this instance
|
// Same rationale as disconnectInstance — preserve DMs with connected alts.
|
||||||
|
failoverDmOriginsFromDisconnected(origin);
|
||||||
useSpaceStore.getState().removeInstanceSpaces(origin);
|
useSpaceStore.getState().removeInstanceSpaces(origin);
|
||||||
|
|
||||||
get().syncRegistry().catch(() => {});
|
get().syncRegistry().catch(() => {});
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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> = {}): 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeInstanceSpaces drops the origin from every inner map', () => {
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('home-1', 'fed-aaa'), makeDm('home-2', 'fed-bbb')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'https://remote.example',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('remote-1', 'fed-aaa'), makeDm('remote-2', 'fed-bbb')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
useSpaceStore.getState().removeInstanceSpaces('https://remote.example');
|
||||||
|
|
||||||
|
const alts = useSpaceStore.getState().dmAlternatives;
|
||||||
|
expect(alts.get('fed-aaa')?.has('https://remote.example')).toBe(false);
|
||||||
|
expect(alts.get('fed-aaa')?.get('')).toBe('home-1');
|
||||||
|
expect(alts.get('fed-bbb')?.has('https://remote.example')).toBe(false);
|
||||||
|
expect(alts.get('fed-bbb')?.get('')).toBe('home-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeInstanceSpaces deletes federatedId entry if its inner map becomes empty', () => {
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'https://remote.example',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('remote-only', 'fed-solo')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
useSpaceStore.getState().removeInstanceSpaces('https://remote.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().dmAlternatives.has('fed-solo')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveDmChannelId returns the id itself if it is already a primary dmChannels entry', async () => {
|
||||||
|
const { resolveDmChannelId } = await import('./spaceStore');
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('home-1', 'fed-aaa')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(resolveDmChannelId('home-1')).toBe('home-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveDmChannelId resolves an alternative id to the primary via federatedId', async () => {
|
||||||
|
const { resolveDmChannelId } = await import('./spaceStore');
|
||||||
|
// Home loads first → home-1 becomes the primary in dmChannels.
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('home-1', 'fed-aaa')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Remote ready later → remote-1 recorded in dmAlternatives but deduped out of dmChannels.
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'https://remote.example',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('remote-1', 'fed-aaa')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(resolveDmChannelId('remote-1')).toBe('home-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveDmChannelId returns null if the id is unknown everywhere', async () => {
|
||||||
|
const { resolveDmChannelId } = await import('./spaceStore');
|
||||||
|
expect(resolveDmChannelId('nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveDmChannelId returns null if the alternative points to a federatedId no longer in dmChannels', async () => {
|
||||||
|
const { resolveDmChannelId } = await import('./spaceStore');
|
||||||
|
useSpaceStore.getState().populateFromReady(
|
||||||
|
'https://remote.example',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[makeDm('remote-1', 'fed-aaa')],
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Simulate the dmChannels entry getting removed without clearing dmAlternatives —
|
||||||
|
// resolveDmChannelId should gracefully return null rather than a stale pointer.
|
||||||
|
useSpaceStore.setState({ dmChannels: [] });
|
||||||
|
expect(resolveDmChannelId('remote-1')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,8 @@ interface SpaceState {
|
|||||||
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
||||||
voiceChannelIds: Set<string>; // channelIds that are voice channels (excluded from unread)
|
voiceChannelIds: Set<string>; // channelIds that are voice channels (excluded from unread)
|
||||||
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
categoryOriginMap: Map<string, string>; // 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<string, Map<string, string>>;
|
||||||
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
|
loadingSpaceId: string | null; // non-null while loadSpaceDetail is fetching
|
||||||
_layoutUpdatedAt: number;
|
_layoutUpdatedAt: number;
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
@@ -132,6 +134,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
voiceChannelIds: new Set(),
|
voiceChannelIds: new Set(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
|
|
||||||
@@ -154,6 +157,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
voiceChannelIds: new Set(),
|
voiceChannelIds: new Set(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
loadingSpaceId: null,
|
loadingSpaceId: null,
|
||||||
_layoutUpdatedAt: 0,
|
_layoutUpdatedAt: 0,
|
||||||
});
|
});
|
||||||
@@ -584,6 +588,10 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
const channelOriginMap = new Map(get().channelOriginMap);
|
const channelOriginMap = new Map(get().channelOriginMap);
|
||||||
const voiceChannelIds = new Set(get().voiceChannelIds);
|
const voiceChannelIds = new Set(get().voiceChannelIds);
|
||||||
const categoryOriginMap = new Map(get().categoryOriginMap);
|
const categoryOriginMap = new Map(get().categoryOriginMap);
|
||||||
|
const dmAlternatives = new Map<string, Map<string, string>>();
|
||||||
|
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 home, clear home-origin entries first to avoid stale data
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
@@ -688,6 +696,18 @@ export const useSpaceStore = create<SpaceState>((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
|
// Merge: remove DMs belonging to this origin from existing state, then append incoming
|
||||||
const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => {
|
const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => {
|
||||||
const dmOrigin = get().channelOriginMap.get(dm.id);
|
const dmOrigin = get().channelOriginMap.get(dm.id);
|
||||||
@@ -711,6 +731,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
voiceChannelIds,
|
voiceChannelIds,
|
||||||
categoryOriginMap,
|
categoryOriginMap,
|
||||||
|
dmAlternatives,
|
||||||
};
|
};
|
||||||
|
|
||||||
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
|
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
|
||||||
@@ -849,6 +870,14 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prune dmAlternatives: drop this origin from every inner map.
|
||||||
|
const dmAlternatives = new Map<string, Map<string, string>>();
|
||||||
|
for (const [fid, byOrigin] of state.dmAlternatives) {
|
||||||
|
const nextInner = new Map(byOrigin);
|
||||||
|
nextInner.delete(origin);
|
||||||
|
if (nextInner.size > 0) dmAlternatives.set(fid, nextInner);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces: remainingSpaces,
|
spaces: remainingSpaces,
|
||||||
channelToSpaceMap,
|
channelToSpaceMap,
|
||||||
@@ -856,6 +885,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelPermissions,
|
channelPermissions,
|
||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
spacePermissions,
|
spacePermissions,
|
||||||
|
dmAlternatives,
|
||||||
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
||||||
? state.currentSpaceId
|
? state.currentSpaceId
|
||||||
: null,
|
: null,
|
||||||
@@ -894,6 +924,33 @@ export function getChannelOrigin(channelId: string): string {
|
|||||||
return useSpaceStore.getState().channelOriginMap.get(channelId) ?? '';
|
return useSpaceStore.getState().channelOriginMap.get(channelId) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a raw DM channel ID to its primary `dmChannels` entry ID.
|
||||||
|
*
|
||||||
|
* - If `rawId` is already a primary entry: returns `rawId` unchanged.
|
||||||
|
* - If `rawId` is recorded in `dmAlternatives` as an alternate-origin local ID
|
||||||
|
* for a DM whose primary is present in `dmChannels`: returns the primary's ID.
|
||||||
|
* - Otherwise: returns `null` (unknown ID — caller should no-op).
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - `dm_message_created` WS handler to route messages arriving from alternate
|
||||||
|
* origins to the primary entry (§3.11 of the failover spec).
|
||||||
|
* - Future DM WS handlers that need to dedup alternate-origin deliveries.
|
||||||
|
*/
|
||||||
|
export function resolveDmChannelId(rawId: string): string | null {
|
||||||
|
const { dmChannels, dmAlternatives } = useSpaceStore.getState();
|
||||||
|
if (dmChannels.some(dm => dm.id === rawId)) return rawId;
|
||||||
|
|
||||||
|
for (const [federatedId, byOrigin] of dmAlternatives) {
|
||||||
|
for (const localId of byOrigin.values()) {
|
||||||
|
if (localId !== rawId) continue;
|
||||||
|
const primary = dmChannels.find(dm => dm.federatedId === federatedId);
|
||||||
|
return primary ? primary.id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── API client resolution ────────────────────────────────────────────────────
|
// ─── API client resolution ────────────────────────────────────────────────────
|
||||||
// The actual resolver is registered by instanceStore on import, avoiding a
|
// The actual resolver is registered by instanceStore on import, avoiding a
|
||||||
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).
|
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock useWebSocket imports that instanceStore / chatStore transitively depend on.
|
||||||
|
vi.mock('../hooks/useWebSocket', () => ({
|
||||||
|
wsSend: vi.fn(),
|
||||||
|
wsSendAll: vi.fn(),
|
||||||
|
connectInstance: vi.fn(),
|
||||||
|
disconnectInstance: vi.fn(),
|
||||||
|
disconnectAllRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../utils/federationOps', () => ({
|
||||||
|
clearPasswordSyncTimers: 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 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(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub instanceStore to avoid TDZ crash from the module-level
|
||||||
|
// setApiForOriginResolver() call in instanceStore.ts.
|
||||||
|
// We use a factory so vitest hoisting sees a self-contained mock.
|
||||||
|
vi.mock('../stores/instanceStore', async () => {
|
||||||
|
const { create } = await import('zustand');
|
||||||
|
const store = create<{ instances: unknown[] }>()(() => ({ instances: [] }));
|
||||||
|
return { useInstanceStore: store };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stub voiceStore to avoid Zustand persist localStorage issues in jsdom.
|
||||||
|
// The test only needs getState()/setState() to verify voice state is untouched.
|
||||||
|
vi.mock('../stores/voiceStore', async () => {
|
||||||
|
const { create } = await import('zustand');
|
||||||
|
const store = create<{
|
||||||
|
activeDmCall: { dmChannelId: string } | null;
|
||||||
|
outgoingCall: { dmChannelId: string } | null;
|
||||||
|
incomingCall: unknown;
|
||||||
|
}>()(() => ({
|
||||||
|
activeDmCall: null,
|
||||||
|
outgoingCall: null,
|
||||||
|
incomingCall: null,
|
||||||
|
}));
|
||||||
|
return { useVoiceStore: store };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useSpaceStore } from '../stores/spaceStore';
|
||||||
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
import { useInstanceStore } from '../stores/instanceStore';
|
||||||
|
import { failoverDmOriginsFromDisconnected } from './dmOriginFailover';
|
||||||
|
import type { DmChannel, User } from '@backspace/shared';
|
||||||
|
import type { ConnectedInstance as Inst } from '../stores/instanceStore';
|
||||||
|
|
||||||
|
function makeDm(id: string, federatedId: string | null): DmChannel {
|
||||||
|
return { id, federatedId, createdAt: 1000, members: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeUser(id: string): User {
|
||||||
|
return {
|
||||||
|
id, username: id, displayName: null, avatar: null, accentColor: null,
|
||||||
|
banner: null, bio: null, status: 'online', activities: [], createdAt: 1,
|
||||||
|
homeInstance: null, homeUserId: null,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeInstance(origin: string, status: Inst['status']): Inst {
|
||||||
|
return {
|
||||||
|
origin,
|
||||||
|
label: origin,
|
||||||
|
token: 'tok',
|
||||||
|
user: fakeUser(`u-${origin}`),
|
||||||
|
username: 'u',
|
||||||
|
status,
|
||||||
|
api: {} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useSpaceStore.getState().reset();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [] });
|
||||||
|
// Replace history.replaceState to observe URL writes in tests.
|
||||||
|
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failoverDmOriginsFromDisconnected', () => {
|
||||||
|
it('no-ops when disconnected origin has no DMs pinned to it', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('home-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['home-1', '']]),
|
||||||
|
dmAlternatives: new Map([['fed-aaa', new Map([['', 'home-1']])]]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-keys a DM to the connected home alternative', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
channelLastMessageIds: new Map([['b-1', 'msg-last-on-b']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
useChatStore.setState({
|
||||||
|
messages: new Map([['b-1', []]]),
|
||||||
|
readStates: new Map([['b-1', 'prev']]),
|
||||||
|
unreadChannels: new Set(['b-1']),
|
||||||
|
currentChannelId: 'b-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const sp = useSpaceStore.getState();
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'home-1')).toBeTruthy();
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'b-1')).toBeUndefined();
|
||||||
|
expect(sp.channelOriginMap.get('home-1')).toBe('');
|
||||||
|
expect(sp.channelOriginMap.has('b-1')).toBe(false);
|
||||||
|
expect(sp.channelLastMessageIds.has('b-1')).toBe(false);
|
||||||
|
|
||||||
|
const ch = useChatStore.getState();
|
||||||
|
expect(ch.messages.has('b-1')).toBe(false);
|
||||||
|
expect(ch.readStates.has('b-1')).toBe(false);
|
||||||
|
expect(ch.unreadChannels.has('home-1')).toBe(true);
|
||||||
|
expect(ch.unreadChannels.has('b-1')).toBe(false);
|
||||||
|
expect(ch.currentChannelId).toBe('home-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers home (empty-string origin) when multiple alternatives are connected', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'connected'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('home-1')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a connected remote when home is not an alternative', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'connected'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const sp = useSpaceStore.getState();
|
||||||
|
expect(sp.channelOriginMap.get('c-1')).toBe('https://c.example');
|
||||||
|
expect(sp.dmChannels.find(d => d.id === 'c-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the pin untouched when no alternatives are connected', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['https://c.example', 'c-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({
|
||||||
|
instances: [
|
||||||
|
fakeInstance('https://b.example', 'disconnected'),
|
||||||
|
fakeInstance('https://c.example', 'error'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('b-1')).toBe('https://b.example');
|
||||||
|
expect(useSpaceStore.getState().dmChannels.find(d => d.id === 'b-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips DMs without a federatedId', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-local', null)],
|
||||||
|
channelOriginMap: new Map([['b-local', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map(),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useSpaceStore.getState().channelOriginMap.get('b-local')).toBe('https://b.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains oldOrigin → oldLocalId in dmAlternatives after rekey for fail-back', () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
const alts = useSpaceStore.getState().dmAlternatives.get('fed-aaa');
|
||||||
|
// Old primary (b) retained as alternative for possible future fail-back.
|
||||||
|
expect(alts?.get('https://b.example')).toBe('b-1');
|
||||||
|
// Alternative map entry for the new primary's own origin is removed — it IS the primary now.
|
||||||
|
expect(alts?.has('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the URL via history.replaceState when rekeying the current channel', () => {
|
||||||
|
window.history.pushState({}, '', '/channels/@me/b-1');
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([
|
||||||
|
['https://b.example', 'b-1'],
|
||||||
|
['', 'home-1'],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
useChatStore.setState({ currentChannelId: 'b-1' });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'',
|
||||||
|
expect.stringContaining('/channels/@me/home-1'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch voice state during failover (voice is handled separately)', async () => {
|
||||||
|
useSpaceStore.setState({
|
||||||
|
dmChannels: [makeDm('b-1', 'fed-aaa')],
|
||||||
|
channelOriginMap: new Map([['b-1', 'https://b.example']]),
|
||||||
|
dmAlternatives: new Map([
|
||||||
|
['fed-aaa', new Map([['https://b.example', 'b-1'], ['', 'home-1']])],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
useInstanceStore.setState({ instances: [fakeInstance('https://b.example', 'disconnected')] });
|
||||||
|
|
||||||
|
const { useVoiceStore } = await import('../stores/voiceStore');
|
||||||
|
const beforeActive = useVoiceStore.getState().activeDmCall;
|
||||||
|
const beforeOutgoing = useVoiceStore.getState().outgoingCall;
|
||||||
|
useVoiceStore.setState({ activeDmCall: { dmChannelId: 'b-1' } });
|
||||||
|
|
||||||
|
failoverDmOriginsFromDisconnected('https://b.example');
|
||||||
|
|
||||||
|
expect(useVoiceStore.getState().activeDmCall?.dmChannelId).toBe('b-1');
|
||||||
|
// Restore
|
||||||
|
useVoiceStore.setState({ activeDmCall: beforeActive, outgoingCall: beforeOutgoing });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useSpaceStore } from '../stores/spaceStore';
|
||||||
|
import { useChatStore } from '../stores/chatStore';
|
||||||
|
import { useInstanceStore } from '../stores/instanceStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DM origin failover: when a remote instance disconnects mid-session,
|
||||||
|
* re-point every DM pinned to it onto a connected sibling that holds the
|
||||||
|
* same federated DM (via S2S mirroring). See
|
||||||
|
* `docs/superpowers/specs/2026-04-23-dm-origin-failover-design.md`.
|
||||||
|
*
|
||||||
|
* No-op when:
|
||||||
|
* - no DMs are pinned to the disconnected origin
|
||||||
|
* - pinned DMs have no `federatedId` (never-federated)
|
||||||
|
* - no alternative origin is currently connected
|
||||||
|
*
|
||||||
|
* Voice state (activeDmCall / outgoingCall / incomingCall) is intentionally
|
||||||
|
* NOT rewritten — a LiveKit session bound to the disconnected origin cannot
|
||||||
|
* migrate; voice cleans up through its own disconnect paths.
|
||||||
|
*/
|
||||||
|
export function failoverDmOriginsFromDisconnected(disconnectedOrigin: string): void {
|
||||||
|
const spaceState = useSpaceStore.getState();
|
||||||
|
const { dmChannels, channelOriginMap, dmAlternatives } = spaceState;
|
||||||
|
|
||||||
|
// Build the set of connected-origin candidates (home is always considered
|
||||||
|
// connected while auth is live — its WS disconnect path doesn't go through
|
||||||
|
// instanceStore at all).
|
||||||
|
const instances = useInstanceStore.getState().instances;
|
||||||
|
const connectedRemotes = new Set(
|
||||||
|
instances.filter(i => i.status === 'connected').map(i => i.origin),
|
||||||
|
);
|
||||||
|
const isOriginConnected = (o: string): boolean => o === '' || connectedRemotes.has(o);
|
||||||
|
|
||||||
|
type Rekey = { oldId: string; newId: string; newOrigin: string; federatedId: string };
|
||||||
|
const rekeys: Rekey[] = [];
|
||||||
|
|
||||||
|
for (const dm of dmChannels) {
|
||||||
|
if (channelOriginMap.get(dm.id) !== disconnectedOrigin) continue;
|
||||||
|
if (!dm.federatedId) continue;
|
||||||
|
|
||||||
|
const byOrigin = dmAlternatives.get(dm.federatedId);
|
||||||
|
if (!byOrigin) continue;
|
||||||
|
|
||||||
|
// Preference order: home ('') first, then any connected remote in
|
||||||
|
// insertion order (Map preserves insertion order in ES2015+).
|
||||||
|
let chosenOrigin: string | null = null;
|
||||||
|
let chosenLocalId: string | null = null;
|
||||||
|
if (byOrigin.has('') && isOriginConnected('') && '' !== disconnectedOrigin) {
|
||||||
|
chosenOrigin = '';
|
||||||
|
chosenLocalId = byOrigin.get('')!;
|
||||||
|
} else {
|
||||||
|
for (const [altOrigin, altLocalId] of byOrigin) {
|
||||||
|
if (altOrigin === disconnectedOrigin) continue;
|
||||||
|
if (!isOriginConnected(altOrigin)) continue;
|
||||||
|
chosenOrigin = altOrigin;
|
||||||
|
chosenLocalId = altLocalId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosenOrigin === null || chosenLocalId === null) continue;
|
||||||
|
if (chosenLocalId === dm.id) continue; // shouldn't happen, but guard
|
||||||
|
|
||||||
|
rekeys.push({
|
||||||
|
oldId: dm.id,
|
||||||
|
newId: chosenLocalId,
|
||||||
|
newOrigin: chosenOrigin,
|
||||||
|
federatedId: dm.federatedId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rekeys.length === 0) return;
|
||||||
|
|
||||||
|
for (const r of rekeys) {
|
||||||
|
rekeyDmChannel(r.oldId, r.newId, r.newOrigin, r.federatedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic rename of a DM across spaceStore + chatStore + URL.
|
||||||
|
* Exported for unit tests; not part of the public failover API.
|
||||||
|
*/
|
||||||
|
export function rekeyDmChannel(
|
||||||
|
oldId: string,
|
||||||
|
newId: string,
|
||||||
|
newOrigin: string,
|
||||||
|
federatedId: string,
|
||||||
|
): void {
|
||||||
|
useSpaceStore.setState((state) => {
|
||||||
|
const dmChannels = state.dmChannels.map(dm =>
|
||||||
|
dm.id === oldId ? { ...dm, id: newId } : dm,
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelOriginMap = new Map(state.channelOriginMap);
|
||||||
|
channelOriginMap.delete(oldId);
|
||||||
|
channelOriginMap.set(newId, newOrigin);
|
||||||
|
|
||||||
|
const channelLastMessageIds = new Map(state.channelLastMessageIds);
|
||||||
|
channelLastMessageIds.delete(oldId); // old id's stored last-message id is origin-local
|
||||||
|
|
||||||
|
// dmAlternatives update: remove the chosen origin's entry from the inner map
|
||||||
|
// (it IS the primary now — not an alternative), but RETAIN every other origin's
|
||||||
|
// entry including the old origin (so a later fail-back can use it without
|
||||||
|
// needing another ready round-trip).
|
||||||
|
const dmAlternatives = new Map<string, Map<string, string>>();
|
||||||
|
for (const [fid, byOrigin] of state.dmAlternatives) {
|
||||||
|
const next = new Map(byOrigin);
|
||||||
|
if (fid === federatedId) next.delete(newOrigin);
|
||||||
|
if (next.size > 0) dmAlternatives.set(fid, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dmChannels, channelOriginMap, channelLastMessageIds, dmAlternatives };
|
||||||
|
});
|
||||||
|
|
||||||
|
useChatStore.getState().rekeyChannelState(oldId, newId);
|
||||||
|
|
||||||
|
// URL update: if the user is viewing the DM whose id just changed, swap the
|
||||||
|
// last path segment in place. No router navigation — the chat view re-renders
|
||||||
|
// from the updated currentChannelId.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const marker = `/channels/@me/`;
|
||||||
|
const idx = path.indexOf(marker);
|
||||||
|
if (idx !== -1 && path.slice(idx + marker.length) === oldId) {
|
||||||
|
const nextPath = path.slice(0, idx + marker.length) + newId;
|
||||||
|
window.history.replaceState(window.history.state, '', nextPath + window.location.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user