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:
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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':
|
||||||
|
if (isHome) {
|
||||||
setUser(event.user);
|
setUser(event.user);
|
||||||
useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false);
|
useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false);
|
||||||
useSettingsStore.getState().fetchStreamingLimits();
|
useSettingsStore.getState().fetchStreamingLimits();
|
||||||
populateFromReady(event.servers, event.folders, event.dmChannels);
|
}
|
||||||
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
|
|
||||||
|
// Voice states — process for all origins (shows who's in voice on remote servers)
|
||||||
|
if (isHome) {
|
||||||
clearAllVoiceUsers();
|
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);
|
||||||
|
if (isHome) {
|
||||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
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(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);
|
||||||
}
|
}
|
||||||
stopHeartbeat();
|
|
||||||
if (globalWs) {
|
|
||||||
globalWs.close();
|
|
||||||
globalWs = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }),
|
||||||
@@ -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
|
||||||
|
const dms = isHome ? (dmChannels || []) : get().dmChannels;
|
||||||
|
if (isHome) {
|
||||||
for (const dm of dms) {
|
for (const dm of dms) {
|
||||||
if (dm.lastMessage?.id) {
|
if (dm.lastMessage?.id) {
|
||||||
channelLastMessageIds.set(dm.id, 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user