fix: resolve duplicate voice user in federated channel sidebar
Use origin-aware user ID resolution for optimistic voice add/remove instead of always using the home instance ID. Adds getMyUserIdForOrigin resolver (same pattern as getApiForOrigin) so joinVoiceChannel and leaveVoice use the correct federated user ID. Also fixes WS reconnect voice re-registration, invite URL for remote servers, and chatStore early-load guard for unmapped channels.
This commit is contained in:
@@ -12,9 +12,12 @@ export function InviteModal() {
|
|||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const generateInvite = useServerStore((s) => s.generateInvite);
|
const generateInvite = useServerStore((s) => s.generateInvite);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
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 isOpen = activeModal === 'invite';
|
||||||
const inviteUrl = inviteCode ? `${window.location.origin}/join/${inviteCode}` : '';
|
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && currentServerId) {
|
if (isOpen && currentServerId) {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
if (currentVoiceChannelId && isLiveKitConnected) {
|
if (currentVoiceChannelId && isLiveKitConnected) {
|
||||||
const voiceOrigin = getChannelOrigin(currentVoiceChannelId);
|
const voiceOrigin = getChannelOrigin(currentVoiceChannelId);
|
||||||
if (voiceOrigin === origin) {
|
if (voiceOrigin === origin) {
|
||||||
const myId = isHome ? event.user.id : useAuthStore.getState().user?.id;
|
const myId = event.user.id;
|
||||||
if (myId) addVoiceUser(currentVoiceChannelId, myId);
|
if (myId) addVoiceUser(currentVoiceChannelId, myId);
|
||||||
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin);
|
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin);
|
||||||
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing }, origin);
|
wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing }, origin);
|
||||||
|
|||||||
@@ -132,9 +132,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
loadMessages: async (channelId: string, force?: boolean) => {
|
loadMessages: async (channelId: string, force?: boolean) => {
|
||||||
if (!force && get().hasMore.has(channelId)) return;
|
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 });
|
set({ isLoading: true, loadError: null });
|
||||||
try {
|
try {
|
||||||
const isDm = isDmChannel(channelId);
|
|
||||||
const origin = getChannelOrigin(channelId);
|
const origin = getChannelOrigin(channelId);
|
||||||
const client = getApiForOrigin(origin);
|
const client = getApiForOrigin(origin);
|
||||||
const messages = isDm
|
const messages = isDm
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
|
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
|
||||||
import { BackspaceApiClient, createApiClient, api } from '../api/client';
|
import { BackspaceApiClient, createApiClient, api } from '../api/client';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { setApiForOriginResolver, useServerStore } from './serverStore';
|
import { setApiForOriginResolver, setUserIdForOriginResolver, useServerStore } from './serverStore';
|
||||||
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
|
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
@@ -505,3 +505,12 @@ setApiForOriginResolver((origin: string): BackspaceApiClient => {
|
|||||||
if (!instance) return api;
|
if (!instance) return api;
|
||||||
return instance.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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
// Resolve the correct API client based on the server's instance origin
|
// Resolve the correct API client based on the server's instance origin
|
||||||
const server = get().servers.find(s => s.id === serverId);
|
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 client = getApiForOrigin(origin);
|
||||||
|
|
||||||
const detail = await client.servers.get(serverId);
|
const detail = await client.servers.get(serverId);
|
||||||
@@ -233,7 +234,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
generateInvite: async (serverId: string) => {
|
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;
|
return result.inviteCode;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -550,3 +554,23 @@ export function getApiForOrigin(origin: string): BackspaceApiClient {
|
|||||||
if (!origin || !_getApiForOrigin) return api;
|
if (!origin || !_getApiForOrigin) return api;
|
||||||
return _getApiForOrigin(origin);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { useServerStore } from './serverStore';
|
import { useServerStore, getChannelOrigin, getMyUserIdForOrigin } from './serverStore';
|
||||||
import { useAuthStore } from './authStore';
|
|
||||||
|
|
||||||
export interface ScreenShareConfig {
|
export interface ScreenShareConfig {
|
||||||
height: 1080 | 720 | 540;
|
height: 1080 | 720 | 540;
|
||||||
@@ -294,7 +293,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||||
leaveVoice: () => {
|
leaveVoice: () => {
|
||||||
const channelId = get().currentVoiceChannelId;
|
const channelId = get().currentVoiceChannelId;
|
||||||
const myId = useAuthStore.getState().user?.id;
|
const myId = channelId ? getMyUserIdForOrigin(getChannelOrigin(channelId)) : undefined;
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Optimistic: immediately remove self from the channel's voice users
|
// Optimistic: immediately remove self from the channel's voice users
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/serverStore';
|
||||||
import { getChannelOrigin } from '../stores/serverStore';
|
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,8 +12,6 @@ export function joinVoiceChannel(channelId: string): void {
|
|||||||
const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||||
if (currentVoiceChannelId === channelId) return;
|
if (currentVoiceChannelId === channelId) return;
|
||||||
|
|
||||||
const myId = useAuthStore.getState().user?.id;
|
|
||||||
|
|
||||||
// Leave old instance if switching cross-origin
|
// Leave old instance if switching cross-origin
|
||||||
if (currentVoiceChannelId) {
|
if (currentVoiceChannelId) {
|
||||||
const oldOrigin = getChannelOrigin(currentVoiceChannelId);
|
const oldOrigin = getChannelOrigin(currentVoiceChannelId);
|
||||||
@@ -22,12 +19,14 @@ export function joinVoiceChannel(channelId: string): void {
|
|||||||
if (oldOrigin !== newOrigin) {
|
if (oldOrigin !== newOrigin) {
|
||||||
wsSend({ type: 'voice_leave' }, oldOrigin);
|
wsSend({ type: 'voice_leave' }, oldOrigin);
|
||||||
}
|
}
|
||||||
// Optimistic: immediately remove self from old channel
|
// Optimistic: immediately remove self from old channel (using origin-aware ID)
|
||||||
if (myId) removeVoiceUser(currentVoiceChannelId, myId);
|
const myOldId = getMyUserIdForOrigin(oldOrigin);
|
||||||
|
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentVoiceChannel(channelId);
|
setCurrentVoiceChannel(channelId);
|
||||||
// voice_join is now sent by useLiveKit after successful LiveKit connection
|
// voice_join is now sent by useLiveKit after successful LiveKit connection
|
||||||
// Optimistic: immediately show self in new channel
|
// Optimistic: immediately show self in new channel (using origin-aware ID)
|
||||||
if (myId) addVoiceUser(channelId, myId);
|
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
|
||||||
|
if (myNewId) addVoiceUser(channelId, myNewId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user