feat: multi-WebSocket connection manager for instance federation

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