diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index c32defc3..1845e301 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -60,9 +60,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const toggleReaction = (emoji: string) => { const hasReacted = message.reactions?.some(r => r.userId === currentUser?.id && r.emoji === emoji); if (hasReacted) { - removeReaction(message.id, emoji); + removeReaction(message.id, emoji, channelKey); } else { - addReaction(message.id, emoji); + addReaction(message.id, emoji, channelKey); } }; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index d8d17fd5..02e15ece 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -7,16 +7,23 @@ import { useSocialStore } from '../stores/socialStore'; import { useSettingsStore } from '../stores/settingsStore'; import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared'; -let globalWs: WebSocket | null = null; -let reconnectAttempts = 0; -let reconnectTimer: ReturnType | undefined; -let currentToken: string | null = null; -let isInitialized = false; +// ─── Connection state ───────────────────────────────────────────────────────── -// Worker-based heartbeat: Safari throttles main-thread setInterval in -// background tabs, causing ping timeouts. A Web Worker's timers run on a -// separate thread and are not subject to the same throttling. -let heartbeatWorker: Worker | null = null; +interface ConnectionState { + ws: WebSocket | null; + heartbeatWorker: Worker | null; + reconnectAttempts: number; + reconnectTimer: ReturnType | undefined; + token: string; +} + +// '' = home instance, 'https://remote.example.com' = remote +const connections = new Map(); + +// Track whether the home connection has been initialized via the React hook +let homeInitialized = false; + +// ─── Heartbeat (Web Worker) ─────────────────────────────────────────────────── function createHeartbeatWorker(): Worker { const blob = new Blob([` @@ -33,26 +40,45 @@ function createHeartbeatWorker(): Worker { return new Worker(URL.createObjectURL(blob)); } -function startHeartbeat(ws: WebSocket): void { - stopHeartbeat(); - heartbeatWorker = createHeartbeatWorker(); - heartbeatWorker.onmessage = () => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })); +function startHeartbeat(conn: ConnectionState): void { + stopHeartbeat(conn); + conn.heartbeatWorker = createHeartbeatWorker(); + conn.heartbeatWorker.onmessage = () => { + if (conn.ws && conn.ws.readyState === WebSocket.OPEN) { + conn.ws.send(JSON.stringify({ type: 'ping' })); } }; - heartbeatWorker.postMessage('start'); + conn.heartbeatWorker.postMessage('start'); } -function stopHeartbeat(): void { - if (heartbeatWorker) { - heartbeatWorker.postMessage('stop'); - heartbeatWorker.terminate(); - heartbeatWorker = null; +function stopHeartbeat(conn: ConnectionState): void { + if (conn.heartbeatWorker) { + conn.heartbeatWorker.postMessage('stop'); + conn.heartbeatWorker.terminate(); + conn.heartbeatWorker = null; } } -function handleEvent(event: ServerEvent): void { +// ─── WS URL construction ───────────────────────────────────────────────────── + +function buildWsUrl(origin: string): string { + if (!origin) { + // Home instance — derive from current page + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/ws`; + } + // Remote instance — derive from origin URL + const url = new URL(origin); + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${url.host}/ws`; +} + +// ─── Event handling ─────────────────────────────────────────────────────────── + +const HOME_ORIGIN = ''; + +function handleEvent(origin: string, event: ServerEvent): void { + const isHome = origin === HOME_ORIGIN; const { setUser } = useAuthStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState(); const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); @@ -60,15 +86,20 @@ function handleEvent(event: ServerEvent): void { switch (event.type) { case 'ready': - setUser(event.user); - useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false); - useSettingsStore.getState().fetchStreamingLimits(); - populateFromReady(event.servers, event.folders, event.dmChannels); - if (currentServerId) { + if (isHome) { + setUser(event.user); + useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false); + useSettingsStore.getState().fetchStreamingLimits(); + } + + populateFromReady(origin, event.servers, event.folders, event.dmChannels); + + if (isHome && currentServerId) { loadServerDetail(currentServerId); } + // Only force-reload the current channel on reconnect; other channels keep their cache - { + if (isHome) { const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState(); if (currentChannelId) { reloadMessages(currentChannelId, true); @@ -79,8 +110,11 @@ function handleEvent(event: ServerEvent): void { setReadStates(event.readStates, channelLastMessageIds); } } - // Clear stale voice state, then populate from server truth - clearAllVoiceUsers(); + + // Voice states — process for all origins (shows who's in voice on remote servers) + if (isHome) { + clearAllVoiceUsers(); + } if (event.voiceStates) { for (const [channelId, userIds] of Object.entries(event.voiceStates)) { setVoiceUsers(channelId, userIds); @@ -92,9 +126,9 @@ function handleEvent(event: ServerEvent): void { setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); } } - // Re-register in voice channel if we're still connected to LiveKit - // (WebSocket reconnect causes server to drop our voice tracking) - { + + // Re-register in voice channel if we're still connected to LiveKit (home only) + if (isHome) { const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen } = useVoiceStore.getState(); if (currentVoiceChannelId) { console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened, curCamera, curScreen }); @@ -102,20 +136,18 @@ function handleEvent(event: ServerEvent): void { wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen }); } } - // Restore DM call state from server (handles reconnect and page refresh) - { + + // Restore DM call state from server (home only) + if (isHome) { const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall } = useVoiceStore.getState(); const myId = event.user.id; if (event.activeCalls && event.activeCalls.length > 0) { for (const call of event.activeCalls) { const isParticipant = call.participants.includes(myId); if (call.state === 'active' && isParticipant) { - // Restore active DM call setActiveDmCall({ dmChannelId: call.dmChannelId }); break; } else if (call.state === 'ringing' && call.callerId !== myId) { - // Restore incoming call (we're the callee) - // Look up caller name from DM channel members const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId); const callerUser = dmCh?.members?.find((m: any) => m.id === call.callerId); setIncomingCall({ @@ -126,7 +158,6 @@ function handleEvent(event: ServerEvent): void { } } } else { - // No active calls on server — clear stale local state if (activeDmCall) { setActiveDmCall(null); } @@ -161,7 +192,9 @@ function handleEvent(event: ServerEvent): void { case 'presence_update': updateMemberPresence(event.userId, event.status); - useSocialStore.getState().updateFriendPresence(event.userId, event.status); + if (isHome) { + useSocialStore.getState().updateFriendPresence(event.userId, event.status); + } break; case 'voice_state_update': @@ -184,13 +217,14 @@ function handleEvent(event: ServerEvent): void { removeMember(event.userId); break; + // ─── DM events (home-only) ────────────────────────────────────────────── + case 'dm_message_created': { + if (!isHome) break; addRealtimeMessage(event.message.dmChannelId, event.message as any); - // If DM channel is unknown (first-ever message safety net), add a minimal one const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState(); const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId); if (!knownDm) { - // Construct a minimal DmChannel from the message so the sidebar shows it addDmCh({ id: event.message.dmChannelId, createdAt: event.message.createdAt, @@ -198,13 +232,11 @@ function handleEvent(event: ServerEvent): void { lastMessage: event.message, }); } else { - // Update lastMessage on the DM channel so the sidebar sorts correctly const updatedDms = currentDmChannels.map(dm => dm.id === event.message.dmChannelId ? { ...dm, lastMessage: event.message } : dm ); - // Re-sort by most recent message updatedDms.sort((a, b) => { const aTime = a.lastMessage?.createdAt ?? a.createdAt; const bTime = b.lastMessage?.createdAt ?? b.createdAt; @@ -212,7 +244,6 @@ function handleEvent(event: ServerEvent): void { }); setDms(updatedDms); } - // Mark DM as unread if not currently viewing it { const { currentChannelId, markChannelUnread } = useChatStore.getState(); if (event.message.dmChannelId !== currentChannelId) { @@ -223,17 +254,22 @@ function handleEvent(event: ServerEvent): void { } case 'dm_message_updated': + if (!isHome) break; updateMessage(event.message as any); break; case 'dm_message_deleted': + if (!isHome) break; removeMessage(event.messageId, event.dmChannelId); break; case 'dm_typing': + if (!isHome) break; setTyping(event.dmChannelId, event.userId, event.username); break; + // ─── Reactions (all origins) ──────────────────────────────────────────── + case 'reaction_added': onReactionAdded(event.messageId, event.reaction); break; @@ -242,25 +278,41 @@ function handleEvent(event: ServerEvent): void { onReactionRemoved(event.messageId, event.userId, event.emoji); break; + // ─── Social events (home-only) ────────────────────────────────────────── + case 'friend_request_received': { + if (!isHome) break; const { addIncomingRequest } = useSocialStore.getState(); addIncomingRequest(event.request); break; } case 'friend_request_accepted': { + if (!isHome) break; const { addFriendFromAccepted } = useSocialStore.getState(); addFriendFromAccepted(event.friend, event.requestId); break; } + case 'friend_removed': { + if (!isHome) break; + const { removeFriendLocally } = useSocialStore.getState(); + removeFriendLocally(event.userId); + break; + } + + // ─── Channel ack (all origins) ────────────────────────────────────────── + case 'channel_ack': { const { onChannelAck } = useChatStore.getState(); onChannelAck(event.channelId, event.messageId); break; } + // ─── DM call events (home-only) ───────────────────────────────────────── + case 'dm_call_incoming': { + if (!isHome) break; const { setIncomingCall } = useVoiceStore.getState(); setIncomingCall({ dmChannelId: event.dmChannelId, @@ -271,6 +323,7 @@ function handleEvent(event: ServerEvent): void { } case 'dm_call_accepted': { + if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -279,6 +332,7 @@ function handleEvent(event: ServerEvent): void { } case 'dm_call_rejected': { + if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -287,6 +341,7 @@ function handleEvent(event: ServerEvent): void { } case 'dm_call_ended': { + if (!isHome) break; const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); setIncomingCall(null); setOutgoingCall(null); @@ -294,42 +349,43 @@ function handleEvent(event: ServerEvent): void { break; } + // ─── DM channel events (home-only) ────────────────────────────────────── + case 'dm_channel_created': + if (!isHome) break; addDmChannel(event.dmChannel); break; case 'dm_channel_closed': + if (!isHome) break; removeDmChannel(event.dmChannelId); break; case 'dm_member_added': { + if (!isHome) break; const { addDmMember } = useServerStore.getState(); addDmMember(event.dmChannelId, event.user); break; } case 'dm_member_removed': { + if (!isHome) break; const { removeDmMember } = useServerStore.getState(); removeDmMember(event.dmChannelId, event.userId); break; } - case 'friend_removed': { - const { removeFriendLocally } = useSocialStore.getState(); - removeFriendLocally(event.userId); - break; - } + // ─── Channel/server events (all origins) ──────────────────────────────── case 'channel_created': { - const { currentServerId: curServerId, channels: curChannels, setChannels, channelToServerMap, channelPermissions } = useServerStore.getState(); + const { currentServerId: curServerId, channels: curChannels, setChannels, channelToServerMap, channelPermissions, channelOriginMap } = useServerStore.getState(); if (event.serverId === curServerId) { - // Deduplicate: only add if not already present if (!curChannels.find(c => c.id === event.channel.id)) { setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position)); } } - // Update auxiliary maps channelToServerMap.set(event.channel.id, event.serverId); + channelOriginMap.set(event.channel.id, origin); if (event.channel.myPermissions) { channelPermissions.set(event.channel.id, event.channel.myPermissions); } @@ -341,17 +397,14 @@ function handleEvent(event: ServerEvent): void { if (event.serverId === curServerId2) { const exists = curChannels2.some(c => c.id === event.channel.id); if (exists) { - // Replace existing channel data setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position)); } else { - // Upsert: user just gained access to this channel setChannels2([...curChannels2, event.channel].sort((a, b) => a.position - b.position)); - // Populate channelToServerMap for the new channel - const { channelToServerMap: ctsMmap } = useServerStore.getState(); + const { channelToServerMap: ctsMmap, channelOriginMap: coMap } = useServerStore.getState(); ctsMmap.set(event.channel.id, event.serverId); + coMap.set(event.channel.id, origin); } } - // Sync channelPermissions with the server's computed value if (event.channel.myPermissions) { chPermsMap2.set(event.channel.id, event.channel.myPermissions); } @@ -359,18 +412,16 @@ function handleEvent(event: ServerEvent): void { } case 'channel_deleted': { - const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToServerMap: ctsMap3 } = useServerStore.getState(); + const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToServerMap: ctsMap3, channelOriginMap: coMap3 } = useServerStore.getState(); if (event.serverId === curServerId3) { setChannels3(curChannels3.filter(c => c.id !== event.channelId)); } - // Clean up auxiliary maps chPermsMap3.delete(event.channelId); ctsMap3.delete(event.channelId); - // If the user is currently viewing this channel, navigate away + coMap3.delete(event.channelId); { const { currentChannelId } = useChatStore.getState(); if (currentChannelId === event.channelId) { - // Find the first remaining text channel to navigate to const { channels: remainingChannels } = useServerStore.getState(); const firstText = remainingChannels.find(c => c.type === 'text'); if (firstText) { @@ -390,50 +441,67 @@ function handleEvent(event: ServerEvent): void { } case 'pong': - // Heartbeat response — no action needed break; case 'error': - console.error('WebSocket error:', event.message); + console.error(`WebSocket error (${origin || 'home'}):`, event.message); break; } } -function connect(): void { - if (!currentToken) return; - if (globalWs && (globalWs.readyState === WebSocket.OPEN || globalWs.readyState === WebSocket.CONNECTING)) { +// ─── Connection management ──────────────────────────────────────────────────── + +function getOrCreateConnection(origin: string, token: string): ConnectionState { + let conn = connections.get(origin); + if (!conn) { + conn = { + ws: null, + heartbeatWorker: null, + reconnectAttempts: 0, + reconnectTimer: undefined, + token, + }; + connections.set(origin, conn); + } else { + conn.token = token; + } + return conn; +} + +function connectToOrigin(origin: string, token: string): void { + const conn = getOrCreateConnection(origin, token); + + if (conn.ws && (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING)) { return; } - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws`; + const wsUrl = buildWsUrl(origin); const ws = new WebSocket(wsUrl); - globalWs = ws; + conn.ws = ws; ws.onopen = () => { - reconnectAttempts = 0; - ws.send(JSON.stringify({ type: 'auth', token: currentToken })); - - // Start heartbeat via Web Worker (immune to Safari background throttling) - startHeartbeat(ws); + conn.reconnectAttempts = 0; + ws.send(JSON.stringify({ type: 'auth', token: conn.token })); + startHeartbeat(conn); }; ws.onmessage = (e) => { try { const event = JSON.parse(e.data as string) as ServerEvent; - handleEvent(event); + handleEvent(origin, event); } catch { - console.error('Failed to parse WebSocket message'); + console.error(`Failed to parse WebSocket message (${origin || 'home'})`); } }; ws.onclose = () => { - globalWs = null; - stopHeartbeat(); - if (currentToken) { - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); - reconnectAttempts++; - reconnectTimer = setTimeout(connect, delay); + conn.ws = null; + stopHeartbeat(conn); + // Only reconnect if the connection is still registered (not explicitly disconnected) + if (connections.has(origin) && conn.token) { + const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 30000); + conn.reconnectAttempts++; + conn.reconnectTimer = setTimeout(() => connectToOrigin(origin, conn.token), delay); } }; @@ -442,31 +510,61 @@ function connect(): void { }; } -function disconnect(): void { - currentToken = null; - isInitialized = false; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = undefined; +function disconnectFromOrigin(origin: string): void { + const conn = connections.get(origin); + if (!conn) return; + + // Clear token to prevent reconnect + conn.token = ''; + + if (conn.reconnectTimer) { + clearTimeout(conn.reconnectTimer); + conn.reconnectTimer = undefined; } - stopHeartbeat(); - if (globalWs) { - globalWs.close(); - globalWs = null; + stopHeartbeat(conn); + if (conn.ws) { + conn.ws.close(); + conn.ws = null; + } + connections.delete(origin); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** Connect to a remote instance's WebSocket. Called by instanceStore. */ +export function connectInstance(origin: string, token: string): void { + connectToOrigin(origin, token); +} + +/** Disconnect from a remote instance's WebSocket. Called by instanceStore. */ +export function disconnectInstance(origin: string): void { + disconnectFromOrigin(origin); +} + +/** Disconnect all remote (non-home) WebSocket connections. Called on logout. */ +export function disconnectAllRemote(): void { + for (const origin of [...connections.keys()]) { + if (origin !== HOME_ORIGIN) { + disconnectFromOrigin(origin); + } } } /** Send an event over the WebSocket. Can be used outside of React components. */ -export function wsSend(event: ClientEvent): void { - if (globalWs && globalWs.readyState === WebSocket.OPEN) { - globalWs.send(JSON.stringify(event)); +export function wsSend(event: ClientEvent, origin: string = HOME_ORIGIN): void { + const conn = connections.get(origin); + if (conn?.ws && conn.ws.readyState === WebSocket.OPEN) { + conn.ws.send(JSON.stringify(event)); } } /** - * Hook to initialize the WebSocket connection. Should only be called ONCE + * Hook to initialize the home WebSocket connection. Should only be called ONCE * from the top-level layout component (AppLayout). Other components should * use the exported `wsSend` function directly. + * + * Remote instance connections are managed by instanceStore via + * connectInstance/disconnectInstance — not by this hook. */ export function useWebSocket() { const token = useAuthStore((s) => s.token); @@ -474,23 +572,25 @@ export function useWebSocket() { const [isConnected, setIsConnected] = React.useState(false); useEffect(() => { - if (token && (!isInitialized || token !== prevToken.current)) { - currentToken = token; - isInitialized = true; - connect(); - } else if (!token && isInitialized) { - disconnect(); + if (token && (!homeInitialized || token !== prevToken.current)) { + homeInitialized = true; + connectToOrigin(HOME_ORIGIN, token); + } else if (!token && homeInitialized) { + homeInitialized = false; + disconnectFromOrigin(HOME_ORIGIN); } prevToken.current = token; }, [token]); useEffect(() => { const checkStatus = setInterval(() => { - setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN); + const conn = connections.get(HOME_ORIGIN); + setIsConnected(!!conn?.ws && conn.ws.readyState === WebSocket.OPEN); }, 500); return () => { clearInterval(checkStatus); - disconnect(); + homeInitialized = false; + disconnectFromOrigin(HOME_ORIGIN); }; }, []); diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts index 9f24b683..2491e9f8 100644 --- a/packages/web/src/stores/authStore.ts +++ b/packages/web/src/stores/authStore.ts @@ -57,7 +57,7 @@ export const useAuthStore = create((set, get) => ({ localStorage.removeItem('backspace_token'); // Clear all user-scoped state to prevent data leaking between sessions useChatStore.getState().clearAllMessages(); - useServerStore.getState().populateFromReady([], [], []); + useServerStore.getState().populateFromReady('', [], [], []); useSocialStore.getState().reset(); useVoiceStore.getState().clearAllVoiceUsers(); useInstanceStore.getState().reset(); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 1914f86d..48a9cab9 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -1,8 +1,7 @@ import { create } from 'zustand'; import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared'; -import { api } from '../api/client'; import { wsSend } from '../hooks/useWebSocket'; -import { isDmChannel, useServerStore } from './serverStore'; +import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore'; import { useAuthStore } from './authStore'; const MAX_MESSAGES_PER_CHANNEL = 200; @@ -44,8 +43,8 @@ interface ChatState { addRealtimeMessage: (channelId: string, message: MessageWithUser) => void; updateMessage: (message: MessageWithUser) => void; removeMessage: (messageId: string, channelId: string) => void; - addReaction: (messageId: string, emoji: string) => void; - removeReaction: (messageId: string, emoji: string) => void; + addReaction: (messageId: string, emoji: string, channelId: string) => void; + removeReaction: (messageId: string, emoji: string, channelId: string) => void; onReactionAdded: (messageId: string, reaction: any) => void; onReactionRemoved: (messageId: string, userId: string, emoji: string) => void; setTyping: (channelId: string, userId: string, username: string) => void; @@ -125,9 +124,11 @@ export const useChatStore = create((set, get) => ({ set({ isLoading: true, loadError: null }); try { const isDm = isDmChannel(channelId); + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); const messages = isDm - ? await api.dm.messages(channelId) - : await api.channels.messages(channelId); + ? await client.dm.messages(channelId) + : await client.channels.messages(channelId); set((state) => { const newMessages = new Map(state.messages); @@ -153,9 +154,11 @@ export const useChatStore = create((set, get) => ({ try { const isDm = isDmChannel(channelId); + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); const olderMessages = isDm - ? await api.dm.messages(channelId, oldestMessage.id) - : await api.channels.messages(channelId, oldestMessage.id); + ? await client.dm.messages(channelId, oldestMessage.id) + : await client.channels.messages(channelId, oldestMessage.id); set((state) => { const newMessages = new Map(state.messages); @@ -175,6 +178,8 @@ export const useChatStore = create((set, get) => ({ const replyToId = get().replyTo?.id; const isDm = isDmChannel(channelId); const currentUser = useAuthStore.getState().user; + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); // Generate optimistic message const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; @@ -219,9 +224,9 @@ export const useChatStore = create((set, get) => ({ try { if (isDm) { - await api.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); + await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); } else { - await api.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); + await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId }); } // Real message will arrive via WebSocket and replace the temp one } catch { @@ -232,6 +237,9 @@ export const useChatStore = create((set, get) => ({ editMessage: async (messageId: string, content: string, channelId: string) => { const isDm = isDmChannel(channelId); + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); + // Optimistic: update content locally first const messages = get().messages.get(channelId); const originalMessage = messages?.find(m => m.id === messageId); @@ -240,9 +248,9 @@ export const useChatStore = create((set, get) => ({ } try { if (isDm) { - await api.dm.updateMessage(messageId, { content }); + await client.dm.updateMessage(messageId, { content }); } else { - await api.messages.update(messageId, { content }); + await client.messages.update(messageId, { content }); } // Real update will arrive via WebSocket } catch { @@ -255,15 +263,18 @@ export const useChatStore = create((set, get) => ({ deleteMessage: async (messageId: string, channelId: string) => { const isDm = isDmChannel(channelId); + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); + // Optimistic: remove locally first const messages = get().messages.get(channelId); const savedMessage = messages?.find(m => m.id === messageId); get().removeMessage(messageId, channelId); try { if (isDm) { - await api.dm.deleteMessage(messageId); + await client.dm.deleteMessage(messageId); } else { - await api.messages.delete(messageId); + await client.messages.delete(messageId); } // Real deletion will arrive via WebSocket (already removed locally) } catch { @@ -345,12 +356,14 @@ export const useChatStore = create((set, get) => ({ }); }, - addReaction: (messageId: string, emoji: string) => { - wsSend({ type: 'reaction_add', messageId, emoji }); + addReaction: (messageId: string, emoji: string, channelId: string) => { + const origin = getChannelOrigin(channelId); + wsSend({ type: 'reaction_add', messageId, emoji }, origin); }, - removeReaction: (messageId: string, emoji: string) => { - wsSend({ type: 'reaction_remove', messageId, emoji }); + removeReaction: (messageId: string, emoji: string, channelId: string) => { + const origin = getChannelOrigin(channelId); + wsSend({ type: 'reaction_remove', messageId, emoji }, origin); }, onReactionAdded: (messageId: string, reaction: Reaction) => { @@ -469,8 +482,9 @@ export const useChatStore = create((set, get) => ({ return { readStates: newReadStates, unreadChannels: newUnread }; }); - // Send to server - wsSend({ type: 'channel_ack', channelId, messageId }); + // Send to the correct instance + const origin = getChannelOrigin(channelId); + wsSend({ type: 'channel_ack', channelId, messageId }, origin); }, onChannelAck: (channelId: string, messageId: string) => { diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 7355e0f5..840ed1f2 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import type { User, InstanceInfoResponse, ReplicatedInstance } from '@backspace/shared'; import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { useAuthStore } from './authStore'; +import { setApiForOriginResolver, useServerStore } from './serverStore'; +import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -66,6 +68,8 @@ function normalizeOrigin(url: string): string { } } +// ─── API client resolution ─────────────────────────────────────────────────── + // ─── Store ─────────────────────────────────────────────────────────────────── interface InstanceState { @@ -166,6 +170,9 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, isLoading: false }; }); + // Open WebSocket connection to the remote instance + connectInstance(origin, response.token); + // Sync instance list to all instances (fire-and-forget) get().syncInstanceList().catch(() => {}); } catch (err) { @@ -202,6 +209,9 @@ export const useInstanceStore = create((set, get) => ({ return { instances: updated, isLoading: false }; }); + // Open WebSocket connection to the remote instance + connectInstance(origin, response.token); + // Sync instance list to all instances (fire-and-forget) get().syncInstanceList().catch(() => {}); } catch (err) { @@ -211,12 +221,18 @@ export const useInstanceStore = create((set, get) => ({ }, removeInstance: (origin: string) => { + // Tear down WebSocket connection + disconnectInstance(origin); + set((state) => { const updated = state.instances.filter(i => i.origin !== origin); saveCachedTokens(updated); return { instances: updated }; }); + // Remove servers from this instance from the server store + useServerStore.getState().removeInstanceServers(origin); + // Sync updated list to remaining instances (fire-and-forget) get().syncInstanceList().catch(() => {}); }, @@ -313,6 +329,9 @@ export const useInstanceStore = create((set, get) => ({ set((state) => ({ instances: state.instances.map(i => i.origin === origin ? connectedInstance : i), })); + + // Open WebSocket connection now that we've verified the token + connectInstance(origin, cachedEntry.token); } catch { // Token expired or instance unreachable set((state) => ({ @@ -337,7 +356,23 @@ export const useInstanceStore = create((set, get) => ({ }, reset: () => { + // Tear down all remote WebSocket connections + disconnectAllRemote(); + set({ instances: [], isLoading: false, error: null }); localStorage.removeItem(STORAGE_KEY); }, })); + +// ─── API client resolution ─────────────────────────────────────────────────── +// Register the resolver with serverStore so getApiForOrigin() works everywhere. +// Placed after store creation so useInstanceStore is definitely initialized. +// This breaks the circular dependency: chatStore → serverStore ← instanceStore +// instead of: chatStore → instanceStore → useWebSocket → chatStore (cycle). + +setApiForOriginResolver((origin: string): BackspaceApiClient => { + if (!origin) return api; + const instance = useInstanceStore.getState().instances.find(i => i.origin === origin); + if (!instance) return api; + return instance.api; +}); diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index 66f05c47..c3e53545 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -1,9 +1,16 @@ import { create } from 'zustand'; import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@backspace/shared'; -import { api } from '../api/client'; +import { api, BackspaceApiClient } from '../api/client'; + +// ─── Instance-aware types ───────────────────────────────────────────────────── + +/** Server augmented with instance origin tracking (client-only, not in shared types). */ +export type TaggedServer = Server & { _instanceOrigin: string }; + +// ─── Store interface ────────────────────────────────────────────────────────── interface ServerState { - servers: Server[]; + servers: TaggedServer[]; currentServerId: string | null; channels: Channel[]; members: MemberWithUser[]; @@ -14,7 +21,8 @@ interface ServerState { channelLastMessageIds: Map; serverPermissions: Map; // serverId → myPermissions decimal string channelPermissions: Map; // channelId → myPermissions decimal string - setServers: (servers: Server[]) => void; + channelOriginMap: Map; // channelId → instance origin ('' = home) + setServers: (servers: TaggedServer[]) => void; setCurrentServer: (serverId: string | null) => void; setChannels: (channels: Channel[]) => void; setMembers: (members: MemberWithUser[]) => void; @@ -41,7 +49,8 @@ interface ServerState { updateMemberPresence: (userId: string, status: string) => void; addMember: (member: MemberWithUser) => void; removeMember: (userId: string) => void; - populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void; + populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void; + removeInstanceServers: (origin: string) => void; } export const useServerStore = create((set, get) => ({ @@ -56,6 +65,7 @@ export const useServerStore = create((set, get) => ({ channelLastMessageIds: new Map(), serverPermissions: new Map(), channelPermissions: new Map(), + channelOriginMap: new Map(), setServers: (servers) => set({ servers }), setCurrentServer: (serverId) => set({ currentServerId: serverId }), @@ -63,7 +73,7 @@ export const useServerStore = create((set, get) => ({ setMembers: (members) => set({ members }), setRoles: (roles) => set({ roles }), setDmChannels: (dmChannels) => set({ dmChannels }), - + addDmChannel: (channel) => set((state) => ({ dmChannels: [channel, ...state.dmChannels.filter(c => c.id !== channel.id)] })), @@ -98,7 +108,9 @@ export const useServerStore = create((set, get) => ({ loadServers: async () => { try { const servers = await api.servers.list(); - set({ servers }); + set((state) => ({ + servers: servers.map(s => ({ ...s, _instanceOrigin: '' })) as TaggedServer[], + })); } catch { // Silently fail - will be populated from WS ready } @@ -106,12 +118,17 @@ export const useServerStore = create((set, get) => ({ loadServerDetail: async (serverId: string) => { try { - const detail = await api.servers.get(serverId); + // 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 ?? ''; + const client = getApiForOrigin(origin); + + const detail = await client.servers.get(serverId); set({ currentServerId: serverId, channels: detail.channels.sort((a, b) => a.position - b.position), members: detail.members, - roles: detail.roles.sort((a, b) => b.position - a.position), // Higher position = higher in list + roles: detail.roles.sort((a, b) => b.position - a.position), }); } catch { // Handle error silently @@ -129,7 +146,8 @@ export const useServerStore = create((set, get) => ({ createServer: async (name: string, icon?: string) => { const server = await api.servers.create({ name, icon }); - set((state) => ({ servers: [...state.servers, server] })); + const tagged: TaggedServer = { ...server, _instanceOrigin: '' }; + set((state) => ({ servers: [...state.servers, tagged] })); return server; }, @@ -152,7 +170,7 @@ export const useServerStore = create((set, get) => ({ const server = await api.servers.join(serverId, { inviteCode }); set((state) => { if (state.servers.find(s => s.id === server.id)) return state; - return { servers: [...state.servers, server] }; + return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] }; }); }, @@ -160,7 +178,7 @@ export const useServerStore = create((set, get) => ({ const server = await api.servers.joinByCode(inviteCode); set((state) => { if (state.servers.find(s => s.id === server.id)) return state; - return { servers: [...state.servers, server] }; + return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] }; }); return server; }, @@ -189,7 +207,7 @@ export const useServerStore = create((set, get) => ({ addServer: (server: Server) => { set((state) => { if (state.servers.find(s => s.id === server.id)) return state; - return { servers: [...state.servers, server] }; + return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] }; }); }, @@ -220,28 +238,72 @@ export const useServerStore = create((set, get) => ({ })); }, - populateFromReady: (servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => { - const simpleServers: Server[] = servers.map(s => ({ + populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => { + const isHome = !origin; + + // Tag all incoming servers with their instance origin + const taggedServers: TaggedServer[] = servers.map(s => ({ id: s.id, name: s.name, icon: s.icon, ownerId: s.ownerId, inviteCode: s.inviteCode, createdAt: s.createdAt, + _instanceOrigin: origin, })); - // Build channel→server map, channel→lastMessageId map, and permission maps - const channelToServerMap = new Map(); - const channelLastMessageIds = new Map(); - const serverPermissions = new Map(); - const channelPermissions = new Map(); + // Merge by origin: keep servers from other origins, replace all from this origin + const existingFromOtherOrigins = get().servers.filter(s => s._instanceOrigin !== origin); + const mergedServers = [...existingFromOtherOrigins, ...taggedServers]; + // Build/merge maps for incoming channels + const channelToServerMap = new Map(get().channelToServerMap); + const channelLastMessageIds = new Map(get().channelLastMessageIds); + const serverPermissions = new Map(get().serverPermissions); + const channelPermissions = new Map(get().channelPermissions); + const channelOriginMap = new Map(get().channelOriginMap); + + // If home, clear home-origin entries first to avoid stale data + if (isHome) { + for (const [key, val] of get().channelOriginMap) { + if (val === origin) { + channelToServerMap.delete(key); + channelLastMessageIds.delete(key); + channelPermissions.delete(key); + channelOriginMap.delete(key); + } + } + // Also clear server permissions for this origin + for (const s of get().servers) { + if (s._instanceOrigin === origin) { + serverPermissions.delete(s.id); + } + } + } else { + // Remote: clear entries that belonged to this origin + for (const [key, val] of get().channelOriginMap) { + if (val === origin) { + channelToServerMap.delete(key); + channelLastMessageIds.delete(key); + channelPermissions.delete(key); + channelOriginMap.delete(key); + } + } + for (const s of get().servers) { + if (s._instanceOrigin === origin) { + serverPermissions.delete(s.id); + } + } + } + + // Populate maps from incoming servers for (const srv of servers) { if (srv.myPermissions) { serverPermissions.set(srv.id, srv.myPermissions); } for (const ch of srv.channels) { channelToServerMap.set(ch.id, srv.id); + channelOriginMap.set(ch.id, origin); if (ch.lastMessageId) { channelLastMessageIds.set(ch.id, ch.lastMessageId); } @@ -250,22 +312,71 @@ export const useServerStore = create((set, get) => ({ } } } - // Also map DM channels - const dms = dmChannels || []; - for (const dm of dms) { - if (dm.lastMessage?.id) { - channelLastMessageIds.set(dm.id, dm.lastMessage.id); + + // DM channels are home-only + const dms = isHome ? (dmChannels || []) : get().dmChannels; + if (isHome) { + for (const dm of dms) { + if (dm.lastMessage?.id) { + channelLastMessageIds.set(dm.id, dm.lastMessage.id); + } } } - set({ - servers: simpleServers, - folders: folders || [], - dmChannels: dms, + const update: Partial = { + servers: mergedServers, channelToServerMap, channelLastMessageIds, serverPermissions, channelPermissions, + channelOriginMap, + }; + + // Only set folders and dmChannels from home origin + if (isHome) { + update.folders = folders || []; + update.dmChannels = dms; + } + + set(update as any); + }, + + removeInstanceServers: (origin: string) => { + set((state) => { + const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin); + + // Clean up maps for channels that belonged to this origin + const channelToServerMap = new Map(state.channelToServerMap); + const channelLastMessageIds = new Map(state.channelLastMessageIds); + const channelPermissions = new Map(state.channelPermissions); + const channelOriginMap = new Map(state.channelOriginMap); + const serverPermissions = new Map(state.serverPermissions); + + for (const [channelId, chOrigin] of state.channelOriginMap) { + if (chOrigin === origin) { + channelToServerMap.delete(channelId); + channelLastMessageIds.delete(channelId); + channelPermissions.delete(channelId); + channelOriginMap.delete(channelId); + } + } + for (const s of state.servers) { + if (s._instanceOrigin === origin) { + serverPermissions.delete(s.id); + } + } + + return { + servers: remainingServers, + channelToServerMap, + channelLastMessageIds, + channelPermissions, + channelOriginMap, + serverPermissions, + currentServerId: remainingServers.find(s => s.id === state.currentServerId) + ? state.currentServerId + : null, + }; }); }, })); @@ -286,3 +397,31 @@ export function isDmChannel(channelId: string): boolean { } return false; } + +/** + * Returns the instance origin for a given channel ID. + * '' = home instance, 'https://...' = remote instance. + */ +export function getChannelOrigin(channelId: string): string { + return useServerStore.getState().channelOriginMap.get(channelId) ?? ''; +} + +// ─── API client resolution ──────────────────────────────────────────────────── +// The actual resolver is registered by instanceStore on import, avoiding a +// circular dependency (instanceStore → useWebSocket → chatStore → serverStore). + +let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null; + +export function setApiForOriginResolver(resolver: (origin: string) => BackspaceApiClient): void { + _getApiForOrigin = resolver; +} + +/** + * Returns the correct API client for a given instance origin. + * '' or falsy = home instance, 'https://...' = remote instance. + * The resolver is registered by instanceStore on import. + */ +export function getApiForOrigin(origin: string): BackspaceApiClient { + if (!origin || !_getApiForOrigin) return api; + return _getApiForOrigin(origin); +}