diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index c21c745c..32ef6d99 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -12,9 +12,12 @@ export function InviteModal() { const closeModal = useUIStore((s) => s.closeModal); const generateInvite = useServerStore((s) => s.generateInvite); const currentServerId = useServerStore((s) => s.currentServerId); + const servers = useServerStore((s) => s.servers); + const currentServer = servers.find(s => s.id === currentServerId); + const instanceOrigin = currentServer?._instanceOrigin ?? ''; const isOpen = activeModal === 'invite'; - const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : ''; + const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : ''; useEffect(() => { if (isOpen && currentServerId) { diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 6583142a..0164cf77 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -172,7 +172,7 @@ function handleEvent(origin: string, event: ServerEvent): void { if (currentVoiceChannelId && isLiveKitConnected) { const voiceOrigin = getChannelOrigin(currentVoiceChannelId); if (voiceOrigin === origin) { - const myId = isHome ? event.user.id : useAuthStore.getState().user?.id; + const myId = event.user.id; if (myId) addVoiceUser(currentVoiceChannelId, myId); wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin); wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing }, origin); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 77bf33bc..1b28e2f7 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -132,9 +132,12 @@ export const useChatStore = create((set, get) => ({ loadMessages: async (channelId: string, force?: boolean) => { if (!force && get().hasMore.has(channelId)) return; + const isDm = isDmChannel(channelId); + // For server channels, bail if we don't know which instance owns this channel yet. + // The remote WS ready handler will call loadMessages once the map is populated. + if (!isDm && !useServerStore.getState().channelOriginMap.has(channelId)) return; set({ isLoading: true, loadError: null }); try { - const isDm = isDmChannel(channelId); const origin = getChannelOrigin(channelId); const client = getApiForOrigin(origin); const messages = isDm diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 3926e605..5ba57afb 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared'; import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { useAuthStore } from './authStore'; -import { setApiForOriginResolver, useServerStore } from './serverStore'; +import { setApiForOriginResolver, setUserIdForOriginResolver, useServerStore } from './serverStore'; import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -505,3 +505,12 @@ setApiForOriginResolver((origin: string): BackspaceApiClient => { if (!instance) return api; return instance.api; }); + +// ─── User ID resolution (federation) ────────────────────────────────────────── +// Maps an origin to the local user's ID on that remote instance. +// Used by voice join/leave to optimistically add/remove the correct user ID. + +setUserIdForOriginResolver((origin: string): string | undefined => { + const instance = useInstanceStore.getState().instances.find(i => i.origin === origin); + return instance?.user.id; +}); diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index 8a38f15b..cbd76746 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -144,7 +144,8 @@ export const useServerStore = create((set, get) => ({ try { // Resolve the correct API client based on the server's instance origin const server = get().servers.find(s => s.id === serverId); - const origin = server?._instanceOrigin ?? ''; + if (!server) return; // Not populated yet — remote WS ready will trigger reload + const origin = server._instanceOrigin ?? ''; const client = getApiForOrigin(origin); const detail = await client.servers.get(serverId); @@ -233,7 +234,10 @@ export const useServerStore = create((set, get) => ({ }, generateInvite: async (serverId: string) => { - const result = await api.servers.invite(serverId); + const server = get().servers.find(s => s.id === serverId); + const origin = server?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + const result = await client.servers.invite(serverId); return result.inviteCode; }, @@ -550,3 +554,23 @@ export function getApiForOrigin(origin: string): BackspaceApiClient { if (!origin || !_getApiForOrigin) return api; return _getApiForOrigin(origin); } + +// ─── User ID resolution (federation) ────────────────────────────────────────── +// Same resolver pattern as getApiForOrigin — registered by instanceStore on +// import to break the circular dependency chain. + +let _getUserIdForOrigin: ((origin: string) => string | undefined) | null = null; + +export function setUserIdForOriginResolver(resolver: (origin: string) => string | undefined): void { + _getUserIdForOrigin = resolver; +} + +/** + * Returns the local user's ID on a given instance origin. + * '' or falsy = home instance (returns authStore user ID). + * 'https://...' = remote instance (returns the federated user ID on that instance). + */ +export function getMyUserIdForOrigin(origin: string): string | undefined { + if (!origin) return useAuthStore.getState().user?.id; + return _getUserIdForOrigin?.(origin); +} diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 7b54728f..0f76ce39 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -2,8 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; import { AudioManager } from '../audio/AudioManager'; -import { useServerStore } from './serverStore'; -import { useAuthStore } from './authStore'; +import { useServerStore, getChannelOrigin, getMyUserIdForOrigin } from './serverStore'; export interface ScreenShareConfig { height: 1080 | 720 | 540; @@ -294,7 +293,7 @@ export const useVoiceStore = create()( // Leave voice without wiping the voiceUsers map (so sidebar still shows others) leaveVoice: () => { const channelId = get().currentVoiceChannelId; - const myId = useAuthStore.getState().user?.id; + const myId = channelId ? getMyUserIdForOrigin(getChannelOrigin(channelId)) : undefined; set((state) => { // Optimistic: immediately remove self from the channel's voice users diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts index 51c1b6b9..8bf56412 100644 --- a/packages/web/src/utils/voice.ts +++ b/packages/web/src/utils/voice.ts @@ -1,6 +1,5 @@ import { useVoiceStore } from '../stores/voiceStore'; -import { useAuthStore } from '../stores/authStore'; -import { getChannelOrigin } from '../stores/serverStore'; +import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/serverStore'; import { wsSend } from '../hooks/useWebSocket'; /** @@ -13,8 +12,6 @@ export function joinVoiceChannel(channelId: string): void { const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState(); if (currentVoiceChannelId === channelId) return; - const myId = useAuthStore.getState().user?.id; - // Leave old instance if switching cross-origin if (currentVoiceChannelId) { const oldOrigin = getChannelOrigin(currentVoiceChannelId); @@ -22,12 +19,14 @@ export function joinVoiceChannel(channelId: string): void { if (oldOrigin !== newOrigin) { wsSend({ type: 'voice_leave' }, oldOrigin); } - // Optimistic: immediately remove self from old channel - if (myId) removeVoiceUser(currentVoiceChannelId, myId); + // Optimistic: immediately remove self from old channel (using origin-aware ID) + const myOldId = getMyUserIdForOrigin(oldOrigin); + if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId); } setCurrentVoiceChannel(channelId); // voice_join is now sent by useLiveKit after successful LiveKit connection - // Optimistic: immediately show self in new channel - if (myId) addVoiceUser(channelId, myId); + // Optimistic: immediately show self in new channel (using origin-aware ID) + const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId)); + if (myNewId) addVoiceUser(channelId, myNewId); }