Two mirror-image bugs from voice/DM-call transitions leaving stale state. DM call → space channel (stuck "Connecting…"): The last participant to leave a DM call for a space channel receives a `dm_call_ended` echo (server empties the DM room on their `voice_join`). The handlers called `disconnectFn()` unconditionally, tearing down the space room they had just connected to. Route `dm_call_ended` / `dm_call_rejected` / terminal `dm_call_undeliverable` through a new `teardownDmCall()` that only disconnects LiveKit when not in a space channel (`currentVoiceChannelId` null). Space channel → DM call (still shown as "in" the voice channel): 1. Entering a DM call never cleared `currentVoiceChannelId`, so `VoiceChannel` mapped the DM call's live LiveKit participants onto the old space channel. Add `clearSpaceVoiceForDmCall()`, called in `connect()` when `isDm`, restoring the invariant that a DM call has no `currentVoiceChannelId`. 2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`, so a caller already in a space channel was never connected to the DM room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room). Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts. Docs: docs/systems/voice.md.
1510 lines
61 KiB
TypeScript
1510 lines
61 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
|
|
import { useChatStore } from '../stores/chatStore';
|
|
import { useVoiceStore } from '../stores/voiceStore';
|
|
import { useSocialStore } from '../stores/socialStore';
|
|
import { useSettingsStore } from '../stores/settingsStore';
|
|
import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@backspace/shared';
|
|
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
|
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
|
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
|
import { sortDmChannels } from '../utils/dmSorting';
|
|
import { registerSelfId } from '../utils/identity';
|
|
import { getActiveRoom } from './useLiveKit';
|
|
import { useUIStore } from '../stores/uiStore';
|
|
import { useActivityStore } from '../stores/activityStore';
|
|
import { useDiscoverStore } from '../stores/discoverStore';
|
|
import { useFederationStore } from '../stores/federationStore';
|
|
|
|
// ─── Rejected peer origins (for unreachable member indicators) ───────────────
|
|
const rejectedPeerOrigins = new Set<string>();
|
|
|
|
export function getRejectedPeerOrigins(): Set<string> {
|
|
return rejectedPeerOrigins;
|
|
}
|
|
|
|
const awaitingApprovalPeerOrigins = new Set<string>();
|
|
|
|
export function getAwaitingApprovalPeerOrigins(): Set<string> {
|
|
return awaitingApprovalPeerOrigins;
|
|
}
|
|
|
|
// Active peer origins — allowlist for processing DM events from remote instances.
|
|
// Only DMs from peered origins (or the home instance) are processed.
|
|
const activePeerOrigins = new Set<string>();
|
|
|
|
export function getActivePeerOrigins(): Set<string> {
|
|
return activePeerOrigins;
|
|
}
|
|
|
|
// ─── Federation change listeners (for real-time panel updates) ───────────────
|
|
const federationChangeListeners = new Set<() => void>();
|
|
|
|
export function onFederationPeersChanged(cb: () => void): () => void {
|
|
federationChangeListeners.add(cb);
|
|
return () => { federationChangeListeners.delete(cb); };
|
|
}
|
|
|
|
function notifyFederationChangeListeners(): void {
|
|
for (const cb of federationChangeListeners) cb();
|
|
}
|
|
|
|
// ─── Connection state ─────────────────────────────────────────────────────────
|
|
|
|
interface ConnectionState {
|
|
ws: WebSocket | null;
|
|
heartbeatWorker: Worker | 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 {
|
|
const blob = new Blob([`
|
|
let timerId = null;
|
|
self.onmessage = function(e) {
|
|
if (e.data === 'start') {
|
|
if (timerId) clearInterval(timerId);
|
|
timerId = setInterval(function() { self.postMessage('tick'); }, 15000);
|
|
} else if (e.data === 'stop') {
|
|
if (timerId) { clearInterval(timerId); timerId = null; }
|
|
}
|
|
};
|
|
`], { type: 'application/javascript' });
|
|
return new Worker(URL.createObjectURL(blob));
|
|
}
|
|
|
|
function startHeartbeat(conn: ConnectionState): void {
|
|
stopHeartbeat(conn);
|
|
conn.heartbeatWorker = createHeartbeatWorker();
|
|
conn.heartbeatWorker.onmessage = () => {
|
|
if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
|
|
conn.ws.send(JSON.stringify({ type: 'ping' }));
|
|
}
|
|
};
|
|
conn.heartbeatWorker.postMessage('start');
|
|
}
|
|
|
|
function stopHeartbeat(conn: ConnectionState): void {
|
|
if (conn.heartbeatWorker) {
|
|
conn.heartbeatWorker.postMessage('stop');
|
|
conn.heartbeatWorker.terminate();
|
|
conn.heartbeatWorker = null;
|
|
}
|
|
}
|
|
|
|
// ─── 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`;
|
|
}
|
|
|
|
// ─── Call relay helpers ───────────────────────────────────────────────────────
|
|
|
|
import { buildCallUndeliverableToast } from '../utils/callUndeliverableToast';
|
|
|
|
export { buildCallUndeliverableToast };
|
|
|
|
// ─── Event handling ───────────────────────────────────────────────────────────
|
|
|
|
const HOME_ORIGIN = '';
|
|
|
|
/**
|
|
* Tear down local state for a DM call that ended, was rejected, or became
|
|
* terminally undeliverable. Clears the call UI/federation state, and tears
|
|
* down the LiveKit session **only when the active voice connection still
|
|
* belongs to the DM call**.
|
|
*
|
|
* The guard is load-bearing: `disconnectFn()` tears down whatever LiveKit room
|
|
* is currently active, regardless of which channel it is. Once the user has
|
|
* joined a *space* voice channel, `currentVoiceChannelId` is set and the active
|
|
* room is the space channel — NOT the DM call (the two are mutually exclusive;
|
|
* `setCurrentVoiceChannel` clears `activeDmCall`). A stale `dm_call_ended` echo
|
|
* must never disconnect that space connection.
|
|
*
|
|
* This is exactly what happens to the **last** participant to leave a DM call
|
|
* for a space channel: their `voice_join` empties the server-side DM room, the
|
|
* server broadcasts `dm_call_ended` back to every DM member (including them),
|
|
* and an unguarded `disconnectFn()` would tear down the space room they just
|
|
* connected to — stranding the UI on "Connecting…" until a manual rejoin.
|
|
*/
|
|
export function teardownDmCall(): void {
|
|
const voice = useVoiceStore.getState();
|
|
voice.setIncomingCall(null);
|
|
voice.setOutgoingCall(null);
|
|
voice.setActiveDmCall(null);
|
|
voice.clearFederatedCallData();
|
|
// Never tear down a space voice connection in response to a DM-call signal.
|
|
if (voice.disconnectFn && !voice.currentVoiceChannelId) voice.disconnectFn();
|
|
}
|
|
|
|
function handleEvent(origin: string, event: ServerEvent): void {
|
|
const isHome = origin === HOME_ORIGIN;
|
|
const { setUser } = useAuthStore.getState();
|
|
const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel, upsertUserView } = useSpaceStore.getState();
|
|
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, clearTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
|
const { addVoiceUser, removeVoiceUser, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
|
|
|
switch (event.type) {
|
|
case 'ready':
|
|
// Register this user's ID for cross-instance self-identification
|
|
registerSelfId(event.user.id);
|
|
|
|
if (isHome) {
|
|
setUser(event.user);
|
|
useSettingsStore.getState().setIsAdmin(event.user.isAdmin ?? false);
|
|
useSettingsStore.getState().fetchStreamingLimits();
|
|
useSettingsStore.getState().fetchGifEnabled();
|
|
}
|
|
|
|
// Normalize asset URLs for remote origins before dispatching to stores
|
|
if (!isHome) {
|
|
for (const space of event.spaces) {
|
|
if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
|
|
if (space.banner) space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner;
|
|
if ((space as any).members) {
|
|
for (const member of (space as any).members) {
|
|
if (member.user) normalizeUserAssets(member.user, origin);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout, event.layoutUpdatedAt);
|
|
|
|
// Cache authoritative identity for this origin (federation-safe)
|
|
if (!isHome) {
|
|
setMyUserIdForOrigin(origin, event.user.id);
|
|
}
|
|
|
|
// Mark remote instance as connected in instanceStore
|
|
if (!isHome) {
|
|
import('../stores/instanceStore').then(({ useInstanceStore }) => {
|
|
useInstanceStore.getState().setInstanceStatus(origin, 'connected');
|
|
});
|
|
}
|
|
|
|
if (isHome && currentSpaceId) {
|
|
loadSpaceDetail(currentSpaceId);
|
|
}
|
|
|
|
// For remote instances: if user was viewing one of these servers, load its details
|
|
// (fixes race condition on page reload — route params effect fires before remote WS connects)
|
|
if (!isHome) {
|
|
const { currentSpaceId: curSpaceId, loadSpaceDetail: loadDetail } = useSpaceStore.getState();
|
|
if (curSpaceId && event.spaces.some((s: any) => s.id === curSpaceId)) {
|
|
loadDetail(curSpaceId);
|
|
const { currentChannelId, loadMessages } = useChatStore.getState();
|
|
if (currentChannelId) {
|
|
loadMessages(currentChannelId, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear stale message cache for all channels on this origin so the next
|
|
// visit does a fresh fetch (and scroll-to-bottom fires correctly).
|
|
// Force-reload the currently open channel immediately.
|
|
{
|
|
const chatState = useChatStore.getState();
|
|
const { channelOriginMap } = useSpaceStore.getState();
|
|
const newMessages = new Map(chatState.messages);
|
|
const newHasMore = new Map(chatState.hasMore);
|
|
for (const [channelId, chOrigin] of channelOriginMap) {
|
|
if (chOrigin === origin) {
|
|
newMessages.delete(channelId);
|
|
newHasMore.delete(channelId);
|
|
}
|
|
}
|
|
if (isHome) {
|
|
for (const key of [...newMessages.keys()]) {
|
|
if (key.startsWith('dm-')) {
|
|
newMessages.delete(key);
|
|
newHasMore.delete(key);
|
|
}
|
|
}
|
|
}
|
|
useChatStore.setState({ messages: newMessages, hasMore: newHasMore });
|
|
if (chatState.currentChannelId) {
|
|
chatState.loadMessages(chatState.currentChannelId, true);
|
|
}
|
|
}
|
|
|
|
// Initialize/update unread tracking for this origin (home or remote)
|
|
if (event.readStates) {
|
|
const { channelLastMessageIds, channelOriginMap } = useSpaceStore.getState();
|
|
const originChannelIds = new Set<string>();
|
|
for (const [channelId, chOrigin] of channelOriginMap) {
|
|
if (chOrigin === origin) originChannelIds.add(channelId);
|
|
}
|
|
useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds);
|
|
}
|
|
|
|
// Prune orphaned unreads: channels in unreadChannels that don't map to
|
|
// any known space channel or DM (e.g. deleted channels, revoked permissions)
|
|
{
|
|
const { unreadChannels: uc } = useChatStore.getState();
|
|
const { channelToSpaceMap: ctsMap, dmChannels: dms } = useSpaceStore.getState();
|
|
const dmIds = new Set(dms.map(d => d.id));
|
|
const orphanIds = new Set<string>();
|
|
for (const id of uc) {
|
|
if (!ctsMap.has(id) && !dmIds.has(id)) orphanIds.add(id);
|
|
}
|
|
if (orphanIds.size > 0) {
|
|
useChatStore.getState().removeChannelStates(orphanIds);
|
|
}
|
|
}
|
|
|
|
// Clear voice state only for the reconnecting origin before repopulating
|
|
clearVoiceUsersForOrigin(origin);
|
|
if (event.voiceStates) {
|
|
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
|
setVoiceUsers(channelId, userIds);
|
|
}
|
|
}
|
|
// Initialize activity data from ready payload
|
|
if (event.userActivities) {
|
|
useActivityStore.getState().initActivities(event.userActivities);
|
|
}
|
|
if (event.user.showActivity !== undefined) {
|
|
useActivityStore.setState({ showActivity: event.user.showActivity });
|
|
}
|
|
|
|
// Re-push local Electron activities after reconnect (sleep/wake, network blip, etc.)
|
|
// The process scanner keeps running but onActivityDetected only fires on change,
|
|
// so if the same app was active before and after sleep, nothing would re-push.
|
|
if (isHome && window.backspace?.getCurrentActivity) {
|
|
window.backspace.getCurrentActivity().then((activity: unknown) => {
|
|
if (activity) {
|
|
useActivityStore.getState().pushActivities([activity as Activity]);
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// Re-push current activities to newly connected instances so their
|
|
// in-memory activity store is populated immediately (covers the case
|
|
// where a user starts a game, then a remote instance connects later).
|
|
{
|
|
const { myActivities, showActivity } = useActivityStore.getState();
|
|
if (showActivity && myActivities && myActivities.length > 0) {
|
|
wsSend({ type: 'activity_update', activities: myActivities }, origin);
|
|
}
|
|
}
|
|
|
|
// Populate voice user statuses (mute/deafen/camera/screenshare) from server
|
|
if (event.voiceUserStates) {
|
|
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
|
|
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
|
|
}
|
|
}
|
|
// Build new restriction Sets atomically from ready payload, then apply in one setState
|
|
{
|
|
const vsState = useVoiceStore.getState();
|
|
const spaceStoreState = useSpaceStore.getState();
|
|
|
|
// Find all space IDs that belong to the current origin
|
|
const originSpaceIds = new Set<string>();
|
|
for (const s of spaceStoreState.spaces) {
|
|
if (s._instanceOrigin === origin) {
|
|
originSpaceIds.add(s.id);
|
|
}
|
|
}
|
|
|
|
const nextSpaceMuted = new Set(vsState.spaceMutedUserIds);
|
|
const nextSpaceDeafened = new Set(vsState.spaceDeafenedUserIds);
|
|
const nextPermissionMuted = new Set(vsState.permissionMutedUserIds);
|
|
|
|
// Clear existing restrictions that belong to spaces on THIS origin
|
|
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
|
|
for (const key of nextSpaceMuted) {
|
|
const spaceId = key.split(':')[0];
|
|
if (spaceId && originSpaceIds.has(spaceId)) nextSpaceMuted.delete(key);
|
|
}
|
|
for (const key of nextSpaceDeafened) {
|
|
const spaceId = key.split(':')[0];
|
|
if (spaceId && originSpaceIds.has(spaceId)) nextSpaceDeafened.delete(key);
|
|
}
|
|
for (const key of nextPermissionMuted) {
|
|
const spaceId = key.split(':')[0];
|
|
if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key);
|
|
}
|
|
|
|
if (event.spaceVoiceStates) {
|
|
for (const [uid, state] of Object.entries(event.spaceVoiceStates as Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted?: boolean }>)) {
|
|
if (state.spaceMuted) nextSpaceMuted.add(uid);
|
|
if (state.spaceDeafened) nextSpaceDeafened.add(uid);
|
|
if (state.permissionMuted) nextPermissionMuted.add(uid);
|
|
}
|
|
}
|
|
// Single atomic update
|
|
useVoiceStore.setState({ spaceMutedUserIds: nextSpaceMuted, spaceDeafenedUserIds: nextSpaceDeafened, permissionMutedUserIds: nextPermissionMuted });
|
|
|
|
// With decoupled state, user intent is never force-set by the server.
|
|
// Effective state (intent || serverEnforcement) is computed reactively
|
|
// at broadcast and hardware time.
|
|
}
|
|
|
|
// Server-authoritative voice session check: if we had a voice connection
|
|
// but the server's voiceStates doesn't include us (server restarted and
|
|
// lost in-memory voiceRooms), tear down the stale LiveKit session cleanly.
|
|
// If the server still knows about us (WS blip, not a restart), do nothing —
|
|
// useLiveKit's ConnectionStateChanged handler will re-register if needed.
|
|
{
|
|
const { currentVoiceChannelId } = useVoiceStore.getState();
|
|
if (currentVoiceChannelId) {
|
|
const voiceOrigin = getChannelOrigin(currentVoiceChannelId);
|
|
if (voiceOrigin === origin) {
|
|
const serverKnowsUs = event.voiceStates?.[currentVoiceChannelId]?.includes(event.user.id);
|
|
if (!serverKnowsUs) {
|
|
// leaveVoice() clears currentVoiceChannelId first to prevent
|
|
// AppLayout from auto-reconnecting (disconnect() fires with
|
|
// CLIENT_INITIATED which skips handleForceDisconnect).
|
|
useVoiceStore.getState().leaveVoice();
|
|
getActiveRoom()?.disconnect();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore DM call state from server (all origins — federated DMs live on remote instances)
|
|
{
|
|
const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall, connectFn, disconnectFn, setFederatedCallData, setFederatedCallId } = useVoiceStore.getState();
|
|
const myId = event.user.id;
|
|
if (event.activeCalls && event.activeCalls.length > 0) {
|
|
for (const call of event.activeCalls) {
|
|
// For federated calls, check membership via token presence (participants may be empty)
|
|
const isParticipant = call.participants.includes(myId) || !!call.livekitToken;
|
|
if (call.state === 'active' && isParticipant) {
|
|
// Clear any stuck ringing UI from a ringing→active transition during refresh
|
|
setIncomingCall(null);
|
|
// Do NOT set activeDmCall or auto-connect. On refresh/restart, the user
|
|
// is no longer in LiveKit — showing "Connecting..." with no connection
|
|
// is broken UX. The call exists on the server but this client session
|
|
// has no active LiveKit connection. The user can re-initiate if needed.
|
|
break;
|
|
} else if (call.state === 'ringing' && call.callerId !== myId) {
|
|
const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId);
|
|
const callerUser = dmCh?.members?.find((m: any) => m.id === call.callerId);
|
|
setIncomingCall({
|
|
dmChannelId: call.dmChannelId,
|
|
callerId: call.callerId,
|
|
callerName: callerUser?.displayName || callerUser?.username || call.callerId,
|
|
});
|
|
// Store federated call data for ringing calls too
|
|
if (call.livekitUrl && call.livekitToken) {
|
|
setFederatedCallData(call.livekitToken, call.livekitUrl);
|
|
}
|
|
if (call.federatedCallId) {
|
|
setFederatedCallId(call.federatedCallId);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (activeDmCall) {
|
|
setActiveDmCall(null);
|
|
if (disconnectFn) disconnectFn();
|
|
}
|
|
if (incomingCall) {
|
|
setIncomingCall(null);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load social data so profile modals show correct friendship state.
|
|
// Runs on each ready (home + remote) — loadFriends/loadRequests fan out
|
|
// across all connected instances and replace the arrays (idempotent).
|
|
{
|
|
const { loadFriends, loadRequests } = useSocialStore.getState();
|
|
loadFriends();
|
|
loadRequests();
|
|
}
|
|
|
|
// Populate rejected peer origins for DM member indicators
|
|
if (isHome) {
|
|
rejectedPeerOrigins.clear();
|
|
if (Array.isArray(event.rejectedPeerOrigins)) {
|
|
for (const o of event.rejectedPeerOrigins) {
|
|
rejectedPeerOrigins.add(o);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Populate awaiting-approval peer origins
|
|
if (isHome) {
|
|
awaitingApprovalPeerOrigins.clear();
|
|
if (Array.isArray(event.awaitingApprovalPeerOrigins)) {
|
|
for (const o of event.awaitingApprovalPeerOrigins) {
|
|
awaitingApprovalPeerOrigins.add(o);
|
|
}
|
|
}
|
|
|
|
// Populate active peer origins (allowlist for remote DM events)
|
|
activePeerOrigins.clear();
|
|
if (Array.isArray(event.activePeerOrigins)) {
|
|
for (const o of event.activePeerOrigins) {
|
|
activePeerOrigins.add(o);
|
|
}
|
|
}
|
|
|
|
// Admin toast for pending approval requests
|
|
if (event.pendingApprovalCount && event.pendingApprovalCount > 0) {
|
|
const { addToast } = useUIStore.getState();
|
|
const count = event.pendingApprovalCount;
|
|
addToast(
|
|
`You have ${count} pending peering request${count === 1 ? '' : 's'}`,
|
|
'info',
|
|
5000,
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'message_created':
|
|
if (!isHome) {
|
|
normalizeMessageAssets(event.message, origin);
|
|
if (event.message.embeds) {
|
|
for (const embed of event.message.embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (event.message.user) upsertUserView(event.message.user, origin);
|
|
if (event.message.replyTo?.user) upsertUserView(event.message.replyTo.user, origin);
|
|
addRealtimeMessage(event.message.channelId, event.message);
|
|
{
|
|
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
|
const { voiceChannelIds } = useSpaceStore.getState();
|
|
const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
// Skip voice channels — they have no text reading/acking UI
|
|
if (event.message.channelId !== currentChannelId && event.message.userId !== myId && !voiceChannelIds.has(event.message.channelId)) {
|
|
markChannelUnread(event.message.channelId);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'message_updated':
|
|
if (!isHome) {
|
|
normalizeMessageAssets(event.message, origin);
|
|
if (event.message.embeds) {
|
|
for (const embed of event.message.embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (event.message.user) upsertUserView(event.message.user, origin);
|
|
if (event.message.replyTo?.user) upsertUserView(event.message.replyTo.user, origin);
|
|
updateMessage(event.message);
|
|
break;
|
|
|
|
case 'message_deleted':
|
|
removeMessage(event.messageId, event.channelId);
|
|
break;
|
|
|
|
case 'typing': {
|
|
let typingUsername = event.username as string;
|
|
if (!isHome && typingUsername && !typingUsername.includes('@')) {
|
|
try { typingUsername = `${typingUsername}@${new URL(origin).host}`; } catch {}
|
|
}
|
|
setTyping(event.channelId, event.userId, typingUsername);
|
|
break;
|
|
}
|
|
|
|
case 'presence_update':
|
|
updateMemberPresence(event.userId, event.status);
|
|
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
|
if (event.activities) {
|
|
useActivityStore.getState().setUserActivities(event.userId, event.activities);
|
|
}
|
|
break;
|
|
|
|
case 'user_updated': {
|
|
if (!isHome) normalizeUserAssets(event.user, origin);
|
|
upsertUserView(event.user, origin);
|
|
useSpaceStore.getState().updateUserEverywhere(event.user);
|
|
useSocialStore.getState().updateFriendProfile(event.user);
|
|
useChatStore.getState().updateUserInMessages(event.user);
|
|
// If this is the current user (other tab changed profile), update authStore
|
|
const myId = isHome
|
|
? useAuthStore.getState().user?.id
|
|
: getMyUserIdForOrigin(origin);
|
|
if (event.user.id === myId && isHome) {
|
|
// Self-deletion detected on another tab — log out
|
|
if (event.user.isDeleted) {
|
|
useAuthStore.getState().logout();
|
|
break;
|
|
}
|
|
setUser(event.user);
|
|
}
|
|
|
|
// Deleted user cleanup: remove from caches the existing pipeline doesn't cover
|
|
if (event.user.isDeleted) {
|
|
useSocialStore.getState().removeFriendLocally(event.user.id, origin);
|
|
useSocialStore.getState().removeRequestsForUser(event.user.id);
|
|
useActivityStore.getState().clearUserActivities(event.user.id);
|
|
useDiscoverStore.getState().removeUser(event.user.id);
|
|
useChatStore.getState().clearTypingForUser(event.user.id);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'voice_state_update':
|
|
if (event.action === 'join') {
|
|
addVoiceUser(event.channelId, event.userId);
|
|
} else {
|
|
removeVoiceUser(event.channelId, event.userId);
|
|
clearVoiceUserStatus(event.userId);
|
|
}
|
|
break;
|
|
|
|
case 'voice_status_update':
|
|
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
|
|
break;
|
|
|
|
case 'space_voice_state':
|
|
// A space the user just joined mid-session — bootstrap its current voice
|
|
// presence (occupants, statuses, space/permission mutes). The `ready`
|
|
// payload only carries this at connect time, so without it the new
|
|
// member's channel sidebar shows empty voice channels until a reload.
|
|
// Scoped to event.spaceId; never disturbs other spaces' live voice state.
|
|
applySpaceVoiceState(event);
|
|
break;
|
|
|
|
case 'voice_space_muted': {
|
|
const { setSpaceMutedUser } = useVoiceStore.getState();
|
|
setSpaceMutedUser(event.spaceId, event.userId, event.muted);
|
|
// Broadcast effective state if this targets the current user
|
|
const myMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.userId === myMuteId) broadcastVoiceStatus();
|
|
break;
|
|
}
|
|
|
|
case 'voice_permission_muted': {
|
|
const { setPermissionMutedUser } = useVoiceStore.getState();
|
|
setPermissionMutedUser(event.spaceId, event.userId, event.muted);
|
|
const myPermMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.userId === myPermMuteId) broadcastVoiceStatus();
|
|
break;
|
|
}
|
|
|
|
case 'voice_space_deafened': {
|
|
const { setSpaceDeafenedUser } = useVoiceStore.getState();
|
|
setSpaceDeafenedUser(event.spaceId, event.userId, event.deafened);
|
|
// Broadcast effective state if this targets the current user
|
|
const myDeafenId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.userId === myDeafenId) {
|
|
broadcastVoiceStatus();
|
|
broadcastDeafenViaLiveKit();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'voice_moved': {
|
|
// The local user was moved to a different channel by a moderator
|
|
const myMovedId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.userId === myMovedId) {
|
|
// Import dynamically to avoid circular deps — joinVoiceChannel handles
|
|
// leaving old channel, setting new channel, and triggering LiveKit reconnect
|
|
import('../utils/voice').then(({ joinVoiceChannel }) => {
|
|
// Force-set the channel (joinVoiceChannel skips if same channel)
|
|
const vs = useVoiceStore.getState();
|
|
// Clear current channel first so joinVoiceChannel doesn't bail
|
|
vs.setCurrentVoiceChannel(null);
|
|
joinVoiceChannel(event.newChannelId, vs.connectFn ?? undefined);
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'voice_disconnected': {
|
|
const myDisconnectId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.userId === myDisconnectId) {
|
|
const { currentVoiceChannelId } = useVoiceStore.getState();
|
|
if (event.channelId === currentVoiceChannelId) {
|
|
useVoiceStore.getState().handleForceDisconnect();
|
|
getActiveRoom()?.disconnect();
|
|
if (event.reason === 'displaced') {
|
|
useUIStore.getState().addToast('Voice disconnected — joined from another session', 'info');
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'member_joined':
|
|
if (!isHome) normalizeUserAssets(event.member.user, origin);
|
|
upsertUserView(event.member.user, origin);
|
|
addMember(event.member);
|
|
break;
|
|
|
|
case 'member_left':
|
|
removeMember(event.userId);
|
|
break;
|
|
|
|
case 'member_banned': {
|
|
// The current user has been banned from a space — remove it from the sidebar
|
|
const { removeSpace: rmSpace } = useSpaceStore.getState();
|
|
rmSpace(event.spaceId);
|
|
break;
|
|
}
|
|
|
|
// ─── DM events (all origins) ────────────────────────────────────────────
|
|
|
|
case 'dm_message_created': {
|
|
// Gate: only process DM events from the home instance or actively peered origins.
|
|
// This prevents notifications/previews from remote instances where S2S peering
|
|
// was revoked, deleted, or never established — even if the client still has a
|
|
// direct WS connection to that instance via Connections.
|
|
if (!isHome && !activePeerOrigins.has(origin)) {
|
|
break;
|
|
}
|
|
if (!isHome) {
|
|
normalizeMessageAssets(event.message as any, origin);
|
|
if ((event.message as any).embeds) {
|
|
for (const embed of (event.message as any).embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ((event.message as any).user) upsertUserView((event.message as any).user, origin);
|
|
if ((event.message as any).replyTo?.user) upsertUserView((event.message as any).replyTo.user, origin);
|
|
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useSpaceStore.getState();
|
|
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
|
|
|
// Check if this is a relay-created channel that duplicates an existing DM
|
|
// (same conversation, different channel ID). If so, skip adding a new sidebar entry
|
|
// and route the message to the existing channel instead.
|
|
if (!knownDm) {
|
|
// dmAlternatives-based resolution: if this channelId is an alternate-origin
|
|
// local id for a DM whose primary is in dmChannels, reroute to the primary.
|
|
// Covers 1-on-1 AND group DMs uniformly; also the post-failover path where
|
|
// the reconnected original origin's WS still uses its old local id.
|
|
const primaryId = resolveDmChannelId(event.message.dmChannelId);
|
|
if (primaryId && primaryId !== event.message.dmChannelId) {
|
|
addRealtimeMessage(primaryId, { ...event.message, dmChannelId: primaryId } as any);
|
|
const updatedDms = currentDmChannels.map(dm =>
|
|
dm.id === primaryId ? { ...dm, lastMessage: event.message } : dm,
|
|
);
|
|
const { unreadChannels: u1, currentChannelId: c1 } = useChatStore.getState();
|
|
setDms(sortDmChannels(updatedDms, u1, c1));
|
|
{
|
|
const { currentChannelId: u1cc, markChannelUnread: u1mu } = useChatStore.getState();
|
|
const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (primaryId !== u1cc && event.message.userId !== myId) {
|
|
u1mu(primaryId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Legacy 2-member-identity fallback: covers DMs without a federatedId
|
|
// (pre-federation or never-federated 1-on-1 DMs).
|
|
const msgUser = event.message.user;
|
|
const msgHomeUserId = msgUser?.homeUserId || msgUser?.id;
|
|
if (msgHomeUserId) {
|
|
const existingDm = currentDmChannels.find(dm =>
|
|
dm.members.length === 2 &&
|
|
dm.members.some(m => (m.homeUserId || m.id) === msgHomeUserId),
|
|
);
|
|
if (existingDm) {
|
|
// Route message to the existing channel instead of creating a duplicate
|
|
addRealtimeMessage(existingDm.id, { ...event.message, dmChannelId: existingDm.id } as any);
|
|
const updatedDms = currentDmChannels.map(dm =>
|
|
dm.id === existingDm.id ? { ...dm, lastMessage: event.message } : dm,
|
|
);
|
|
const { unreadChannels, currentChannelId } = useChatStore.getState();
|
|
setDms(sortDmChannels(updatedDms, unreadChannels, currentChannelId));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
addRealtimeMessage(event.message.dmChannelId, event.message as any);
|
|
if (!knownDm) {
|
|
addDmCh({
|
|
id: event.message.dmChannelId,
|
|
createdAt: event.message.createdAt,
|
|
members: event.message.user ? [event.message.user] : [],
|
|
lastMessage: event.message,
|
|
}, origin);
|
|
} else {
|
|
const updatedDms = currentDmChannels.map(dm =>
|
|
dm.id === event.message.dmChannelId
|
|
? { ...dm, lastMessage: event.message }
|
|
: dm
|
|
);
|
|
const { unreadChannels: unread, currentChannelId: curCh } = useChatStore.getState();
|
|
setDms(sortDmChannels(updatedDms, unread, curCh));
|
|
}
|
|
{
|
|
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
|
const myId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
|
|
if (event.message.dmChannelId !== currentChannelId && event.message.userId !== myId) {
|
|
markChannelUnread(event.message.dmChannelId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'dm_message_updated':
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
if (!isHome) {
|
|
normalizeMessageAssets(event.message as any, origin);
|
|
if ((event.message as any).embeds) {
|
|
for (const embed of (event.message as any).embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ((event.message as any).user) upsertUserView((event.message as any).user, origin);
|
|
if ((event.message as any).replyTo?.user) upsertUserView((event.message as any).replyTo.user, origin);
|
|
updateMessage(event.message as any);
|
|
break;
|
|
|
|
case 'federation_file_rejected': {
|
|
const { addToast } = useUIStore.getState();
|
|
const users = event.affectedUsers;
|
|
if (users && users.length > 0) {
|
|
const parts = users.map(u => {
|
|
const limitMb = Math.round(u.limit / (1024 * 1024));
|
|
return `${u.username}'s instance (limit: ${limitMb} MB)`;
|
|
});
|
|
const msg = `File couldn't be cached on ${parts.join(' and ')}. They can still view it from yours.`;
|
|
addToast(msg, 'warning', 7000);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'federation_peer_rejected': {
|
|
const { addToast } = useUIStore.getState();
|
|
rejectedPeerOrigins.add(event.peerOrigin);
|
|
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
|
activePeerOrigins.delete(event.peerOrigin);
|
|
const label = event.peerLabel || event.peerOrigin;
|
|
addToast(
|
|
`Cannot relay messages to ${label} — ${event.reason}`,
|
|
'warning',
|
|
10000,
|
|
);
|
|
notifyFederationChangeListeners();
|
|
break;
|
|
}
|
|
|
|
case 'federation_peer_active': {
|
|
rejectedPeerOrigins.delete(event.peerOrigin);
|
|
awaitingApprovalPeerOrigins.delete(event.peerOrigin);
|
|
activePeerOrigins.add(event.peerOrigin);
|
|
notifyFederationChangeListeners();
|
|
break;
|
|
}
|
|
|
|
case 'federation_peers_changed': {
|
|
notifyFederationChangeListeners();
|
|
break;
|
|
}
|
|
|
|
case 'federation_approval_request_received': {
|
|
notifyFederationChangeListeners();
|
|
break;
|
|
}
|
|
|
|
case 'peering_subscription_changed': {
|
|
// The user's pending peering-subscription set changed (admin approved/
|
|
// denied/expired the parent request, or the user cancelled a row from
|
|
// another tab). Refetch — the server is the source of truth.
|
|
void useFederationStore.getState().refetchPeeringSubscriptions();
|
|
break;
|
|
}
|
|
|
|
case 'peering_notification_received': {
|
|
// Terminal-state outcome arrived for one of the user's outbound peering
|
|
// requests. Refetch the notifications list and surface a transient
|
|
// toast — the inline list in the Connections panel is the persistent
|
|
// surface; the toast is opportunistic for online users.
|
|
void useFederationStore.getState().refetchPeeringNotifications();
|
|
const message =
|
|
event.kind === 'approved'
|
|
? 'Your peering request was approved'
|
|
: event.kind === 'denied'
|
|
? 'Your peering request was denied'
|
|
: 'Your peering request expired';
|
|
// uiStore exposes 'info' | 'warning' | 'success' — use 'success' for
|
|
// approved, 'warning' for denied/expired (no error severity exists).
|
|
const severity: 'success' | 'warning' = event.kind === 'approved' ? 'success' : 'warning';
|
|
useUIStore.getState().addToast(message, severity, 4500);
|
|
break;
|
|
}
|
|
|
|
case 'dm_message_deleted':
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
removeMessage(event.messageId, event.dmChannelId);
|
|
break;
|
|
|
|
case 'embeds_resolved': {
|
|
if (!isHome) {
|
|
for (const embed of event.embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
const resolvedMsgs = useChatStore.getState().messages.get(event.channelId);
|
|
if (resolvedMsgs) {
|
|
const newResolvedMsgs = resolvedMsgs.map(m =>
|
|
m.id === event.messageId ? { ...m, embeds: event.embeds } : m
|
|
);
|
|
const newResolvedMessages = new Map(useChatStore.getState().messages);
|
|
newResolvedMessages.set(event.channelId, newResolvedMsgs);
|
|
useChatStore.setState({ messages: newResolvedMessages });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'dm_embeds_resolved': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
if (!isHome) {
|
|
for (const embed of event.embeds) {
|
|
if (embed.image && !embed.image.startsWith('http')) {
|
|
embed.image = resolveAssetUrl(embed.image, origin) ?? embed.image;
|
|
}
|
|
}
|
|
}
|
|
const dmResolvedMsgs = useChatStore.getState().messages.get(event.dmChannelId);
|
|
if (dmResolvedMsgs) {
|
|
const newDmResolvedMsgs = dmResolvedMsgs.map(m =>
|
|
m.id === event.messageId ? { ...m, embeds: event.embeds } : m
|
|
);
|
|
const newDmResolvedMessages = new Map(useChatStore.getState().messages);
|
|
newDmResolvedMessages.set(event.dmChannelId, newDmResolvedMsgs);
|
|
useChatStore.setState({ messages: newDmResolvedMessages });
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'dm_typing': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
let dmTypingUsername = event.username as string;
|
|
if (!isHome && dmTypingUsername && !dmTypingUsername.includes('@')) {
|
|
try { dmTypingUsername = `${dmTypingUsername}@${new URL(origin).host}`; } catch {}
|
|
}
|
|
setTyping(event.dmChannelId, event.userId, dmTypingUsername);
|
|
break;
|
|
}
|
|
|
|
case 'dm_typing_stop': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
clearTyping(event.dmChannelId as string, event.userId as string);
|
|
break;
|
|
}
|
|
|
|
// ─── Reactions (all origins) ────────────────────────────────────────────
|
|
|
|
case 'reaction_added':
|
|
onReactionAdded(event.messageId, event.reaction);
|
|
break;
|
|
|
|
case 'reaction_removed':
|
|
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
|
break;
|
|
|
|
// ─── Social events (all origins — federation) ──────────────────────────
|
|
|
|
case 'friend_request_received': {
|
|
if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin);
|
|
if (event.request.user) upsertUserView(event.request.user, origin);
|
|
const { addIncomingRequest } = useSocialStore.getState();
|
|
addIncomingRequest(event.request, origin);
|
|
break;
|
|
}
|
|
|
|
case 'friend_request_sent': {
|
|
// Multi-tab sync: another tab/device of the same user just created an outbound request.
|
|
if (!isHome && event.request.user) normalizeUserAssets(event.request.user, origin);
|
|
if (event.request.user) upsertUserView(event.request.user, origin);
|
|
const { addOutboundRequest } = useSocialStore.getState();
|
|
addOutboundRequest(event.request, origin);
|
|
break;
|
|
}
|
|
|
|
case 'friend_request_relay_failed': {
|
|
// Async rollback notification: the federated friend_request_create was permanently rejected.
|
|
// Drop the optimistic row and surface a warning toast.
|
|
const { removeRequestById } = useSocialStore.getState();
|
|
removeRequestById(event.requestId, origin);
|
|
const { addToast } = useUIStore.getState();
|
|
addToast(
|
|
`Friend request to ${event.targetHandle} could not be delivered: ${event.message}`,
|
|
'warning',
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'friend_request_accepted': {
|
|
if (!isHome) normalizeUserAssets(event.friend, origin);
|
|
// Friend carries the identity fields the cache needs; cast to User for upsert.
|
|
upsertUserView(event.friend as unknown as User, origin);
|
|
const { addFriendFromAccepted } = useSocialStore.getState();
|
|
addFriendFromAccepted(event.friend, event.requestId, origin);
|
|
import('../stores/discoverStore').then(({ useDiscoverStore }) => {
|
|
useDiscoverStore.getState().updateRelationship(event.friend.id, origin, 'friends');
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'friend_removed': {
|
|
const { removeFriendLocally } = useSocialStore.getState();
|
|
removeFriendLocally(event.userId, origin);
|
|
import('../stores/discoverStore').then(({ useDiscoverStore }) => {
|
|
useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none');
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'friend_request_cancelled': {
|
|
const { removeRequestById } = useSocialStore.getState();
|
|
removeRequestById(event.requestId, origin, event.userId);
|
|
import('../stores/discoverStore').then(({ useDiscoverStore }) => {
|
|
useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none');
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'friend_request_declined': {
|
|
const { removeRequestById } = useSocialStore.getState();
|
|
removeRequestById(event.requestId, origin, event.userId);
|
|
import('../stores/discoverStore').then(({ useDiscoverStore }) => {
|
|
useDiscoverStore.getState().updateRelationship(event.userId, origin, 'none');
|
|
});
|
|
break;
|
|
}
|
|
|
|
// ─── Channel ack (all origins) ──────────────────────────────────────────
|
|
|
|
case 'channel_ack': {
|
|
const { onChannelAck } = useChatStore.getState();
|
|
onChannelAck(event.channelId, event.messageId);
|
|
break;
|
|
}
|
|
|
|
case 'mark_unread': {
|
|
const { onMarkUnread } = useChatStore.getState();
|
|
onMarkUnread(event.channelId, event.messageId);
|
|
break;
|
|
}
|
|
|
|
// ─── DM call events (all origins) ──────────────────────────────────────
|
|
|
|
case 'dm_call_incoming': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
// Batch ALL call state into a single set() to prevent:
|
|
// 1. Ringtone multiplication (multiple subscription triggers from separate set() calls)
|
|
// 2. Stale callOrigin/federatedCallId from previous calls (always overwritten)
|
|
// callOrigin = the WS origin that delivered this event, NOT event.callOrigin (the host).
|
|
// Routing accept/reject through this WS ensures the message reaches a connected server,
|
|
// which then relays to the host via S2S HTTP. Using event.callOrigin (the host URL)
|
|
// would route through the multi-instance WS, which may not be connected.
|
|
useVoiceStore.setState({
|
|
incomingCall: {
|
|
dmChannelId: event.dmChannelId ?? null,
|
|
callerId: event.callerId,
|
|
callerName: event.callerName,
|
|
},
|
|
federatedCallToken: event.livekitToken ?? null,
|
|
federatedCallUrl: event.livekitUrl ?? null,
|
|
federatedCallId: event.federatedCallId ?? null,
|
|
callOrigin: origin,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'dm_call_accepted': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
const { setIncomingCall, setOutgoingCall, outgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
|
|
const wasOutgoingCall = !!outgoingCall;
|
|
setIncomingCall(null);
|
|
setOutgoingCall(null);
|
|
|
|
// Only enter active call state if:
|
|
// - We're the caller (wasOutgoingCall) → will connect via connectFn below
|
|
// - We already connected to LiveKit (clicked accept in handleAccept)
|
|
// Other instances of the same user must NOT enter call state — they'd show
|
|
// "Connecting..." forever with no actual LiveKit connection.
|
|
const callDmId = event.dmChannelId || event.federatedCallId || '';
|
|
if (wasOutgoingCall || isLiveKitConnected) {
|
|
setActiveDmCall({ dmChannelId: callDmId });
|
|
}
|
|
// The caller connects to the DM room. `wasOutgoingCall` alone identifies
|
|
// the caller session (other sessions/tabs never set outgoingCall), and
|
|
// `connect()` de-dupes an already-connected same room — so we must NOT
|
|
// also gate on `!isLiveKitConnected`: a caller who is currently sitting in
|
|
// a space voice channel is LiveKit-connected, and gating on it would skip
|
|
// the DM connect entirely, stranding them in the space channel.
|
|
if (connectFn && wasOutgoingCall && callDmId) {
|
|
connectFn(callDmId, true).catch((err: unknown) => {
|
|
console.error('[WS] DM call connect failed:', err);
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'dm_call_rejected': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
teardownDmCall();
|
|
break;
|
|
}
|
|
|
|
case 'dm_call_ended': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
teardownDmCall();
|
|
break;
|
|
}
|
|
|
|
case 'dm_call_undeliverable': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
|
|
const { addToast } = useUIStore.getState();
|
|
|
|
if (event.terminal) {
|
|
// Tear down local outbound call state — mirrors dm_call_ended.
|
|
teardownDmCall();
|
|
}
|
|
|
|
const msg = buildCallUndeliverableToast(event.failures, event.terminal, event.phase);
|
|
addToast(msg, event.terminal ? 'warning' : 'info', 8_000);
|
|
break;
|
|
}
|
|
|
|
// ─── DM channel events (all origins) ────────────────────────────────────
|
|
|
|
case 'dm_channel_created': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
if (!isHome) {
|
|
for (const m of event.dmChannel.members) {
|
|
normalizeUserAssets(m, origin);
|
|
}
|
|
}
|
|
for (const m of event.dmChannel.members) {
|
|
upsertUserView(m, origin);
|
|
}
|
|
// Dedup: skip if a channel with the same federatedId already exists
|
|
const fid = event.dmChannel.federatedId;
|
|
if (fid) {
|
|
const existing = useSpaceStore.getState().dmChannels.find(dm => dm.federatedId === fid);
|
|
if (existing) break;
|
|
}
|
|
addDmChannel(event.dmChannel, origin);
|
|
break;
|
|
}
|
|
|
|
case 'dm_channel_closed':
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
removeDmChannel(event.dmChannelId);
|
|
break;
|
|
|
|
case 'dm_member_added': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
if (!isHome) normalizeUserAssets(event.user, origin);
|
|
upsertUserView(event.user, origin);
|
|
const { addDmMember } = useSpaceStore.getState();
|
|
addDmMember(event.dmChannelId, event.user);
|
|
break;
|
|
}
|
|
|
|
case 'dm_member_removed': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
const { removeDmMember } = useSpaceStore.getState();
|
|
removeDmMember(event.dmChannelId, event.userId);
|
|
break;
|
|
}
|
|
|
|
case 'dm_owner_updated': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
const { updateDmOwner } = useSpaceStore.getState();
|
|
// Pass the federation routing fields so the DM's `ownerHomeInstance`
|
|
// stays in sync with the server. Without this, `getOwnerInstanceForDm`
|
|
// routes the next owner-only API call (rename, icon, kick, transfer)
|
|
// through the PREVIOUS owner's home instance and the receiving peer
|
|
// rejects it as `unauthorized_source`. Older servers omit these fields
|
|
// — the store leaves the existing values untouched in that case.
|
|
updateDmOwner(
|
|
event.dmChannelId,
|
|
event.newOwnerId,
|
|
event.newOwnerHomeUserId ?? undefined,
|
|
event.newOwnerHomeInstance ?? undefined,
|
|
);
|
|
break;
|
|
}
|
|
|
|
case 'dm_channel_updated': {
|
|
if (!isHome && !activePeerOrigins.has(origin)) break;
|
|
const { dmChannelId, name, icon } = event;
|
|
useSpaceStore.getState().updateDmMetadata(dmChannelId, { name, icon });
|
|
break;
|
|
}
|
|
|
|
// ─── Channel/space events (all origins) ─────────────────────────────────
|
|
|
|
case 'channel_created': {
|
|
useSpaceStore.getState().upsertChannel(event.channel, event.spaceId, origin);
|
|
break;
|
|
}
|
|
|
|
case 'channel_updated': {
|
|
const { currentSpaceId: curSpaceId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useSpaceStore.getState();
|
|
if (event.spaceId === curSpaceId2) {
|
|
const exists = curChannels2.some(c => c.id === event.channel.id);
|
|
if (exists) {
|
|
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
|
|
} else {
|
|
setChannels2([...curChannels2, event.channel].sort((a, b) => a.position - b.position));
|
|
const { channelToSpaceMap: ctsMmap, channelOriginMap: coMap } = useSpaceStore.getState();
|
|
ctsMmap.set(event.channel.id, event.spaceId);
|
|
coMap.set(event.channel.id, origin);
|
|
}
|
|
}
|
|
if (event.channel.myPermissions) {
|
|
chPermsMap2.set(event.channel.id, event.channel.myPermissions);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'channel_deleted': {
|
|
const { currentSpaceId: curSpaceId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToSpaceMap: ctsMap3, channelOriginMap: coMap3 } = useSpaceStore.getState();
|
|
if (event.spaceId === curSpaceId3) {
|
|
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
|
|
}
|
|
// If the user is currently viewing the deleted channel, clear it
|
|
const { currentChannelId: deletedViewChannelId } = useChatStore.getState();
|
|
if (deletedViewChannelId === event.channelId) {
|
|
useChatStore.getState().setCurrentChannel(null);
|
|
}
|
|
chPermsMap3.delete(event.channelId);
|
|
ctsMap3.delete(event.channelId);
|
|
coMap3.delete(event.channelId);
|
|
// Clean up unread and read state for the deleted channel
|
|
{
|
|
const { channelLastMessageIds: clmIds } = useSpaceStore.getState();
|
|
clmIds.delete(event.channelId);
|
|
const cs = useChatStore.getState();
|
|
if (cs.unreadChannels.has(event.channelId) || cs.readStates.has(event.channelId)) {
|
|
const newUnread = new Set(cs.unreadChannels);
|
|
newUnread.delete(event.channelId);
|
|
const newRS = new Map(cs.readStates);
|
|
newRS.delete(event.channelId);
|
|
useChatStore.setState({ unreadChannels: newUnread, readStates: newRS });
|
|
}
|
|
}
|
|
// Clean up voice users for the deleted channel
|
|
{
|
|
const vs = useVoiceStore.getState();
|
|
if (vs.voiceUsers.has(event.channelId)) {
|
|
const newVoiceUsers = new Map(vs.voiceUsers);
|
|
newVoiceUsers.delete(event.channelId);
|
|
useVoiceStore.setState({ voiceUsers: newVoiceUsers });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'space_updated': {
|
|
if (!isHome && event.space.icon) {
|
|
event.space.icon = resolveAssetUrl(event.space.icon, origin) ?? event.space.icon;
|
|
}
|
|
if (!isHome && event.space.banner) {
|
|
event.space.banner = resolveAssetUrl(event.space.banner, origin) ?? event.space.banner;
|
|
}
|
|
const { spaces: currentSpaces, setSpaces } = useSpaceStore.getState();
|
|
setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s));
|
|
break;
|
|
}
|
|
|
|
// ─── Category events (all origins) ────────────────────────────────────
|
|
|
|
case 'category_created': {
|
|
const { currentSpaceId: catSpaceId, categories: curCategories, setCategories: setCats, categoryOriginMap: catOriginMap } = useSpaceStore.getState();
|
|
catOriginMap.set(event.category.id, origin);
|
|
if (event.spaceId === catSpaceId) {
|
|
if (!curCategories.some(c => c.id === event.category.id)) {
|
|
setCats([...curCategories, event.category].sort((a, b) => a.position - b.position));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'category_updated': {
|
|
const { currentSpaceId: catSpaceId2, categories: curCategories2, setCategories: setCats2 } = useSpaceStore.getState();
|
|
if (event.spaceId === catSpaceId2) {
|
|
setCats2(curCategories2.map(c => c.id === event.category.id ? event.category : c).sort((a, b) => a.position - b.position));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'category_deleted': {
|
|
const { currentSpaceId: catSpaceId3, categories: curCategories3, setCategories: setCats3, channels: curChsForCat, setChannels: setChsForCat, categoryOriginMap: catOriginMap3 } = useSpaceStore.getState();
|
|
catOriginMap3.delete(event.categoryId);
|
|
if (event.spaceId === catSpaceId3) {
|
|
setCats3(curCategories3.filter(c => c.id !== event.categoryId));
|
|
// Null out categoryId on affected channels (server already did this, but sync local state)
|
|
setChsForCat(curChsForCat.map(ch => ch.categoryId === event.categoryId ? { ...ch, categoryId: null } : ch));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'channel_layout_updated': {
|
|
const { currentSpaceId: layoutSpaceId, setChannels: setLayoutChannels, setCategories: setLayoutCategories, channelPermissions: layoutChPerms, channelToSpaceMap: layoutCtsMap, channelOriginMap: layoutCoMap } = useSpaceStore.getState();
|
|
if (event.spaceId === layoutSpaceId) {
|
|
setLayoutChannels(event.channels.sort((a, b) => a.position - b.position));
|
|
setLayoutCategories(event.categories.sort((a, b) => a.position - b.position));
|
|
// Update permission maps from the new layout
|
|
for (const ch of event.channels) {
|
|
layoutCtsMap.set(ch.id, event.spaceId);
|
|
layoutCoMap.set(ch.id, origin);
|
|
if (ch.myPermissions) {
|
|
layoutChPerms.set(ch.id, ch.myPermissions);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'space_layout_updated': {
|
|
// LWW: only accept if incoming timestamp >= current
|
|
const incomingTs = event.updatedAt ?? 0;
|
|
const currentTs = useSpaceStore.getState()._layoutUpdatedAt;
|
|
if (incomingTs >= currentTs) {
|
|
useSpaceStore.getState().setSpaceLayout(event.layout);
|
|
useSpaceStore.setState({ folders: event.folders, _layoutUpdatedAt: incomingTs });
|
|
}
|
|
break;
|
|
}
|
|
|
|
// ─── Join request events (home-only) ────────────────────────────────
|
|
|
|
case 'join_request_received': {
|
|
if (!isHome) break;
|
|
console.log('[WebSocket] Join request received from', event.request.user?.username ?? event.request.userId);
|
|
break;
|
|
}
|
|
|
|
case 'join_request_accepted': {
|
|
if (!isHome) break;
|
|
// Add the space to our space list
|
|
const { addSpaceFromReady } = useSpaceStore.getState();
|
|
addSpaceFromReady(origin, event.space);
|
|
console.log('[WebSocket] Join request accepted for space', event.space.name);
|
|
break;
|
|
}
|
|
|
|
case 'join_request_declined': {
|
|
if (!isHome) break;
|
|
console.log('[WebSocket] Join request declined for space', event.request.spaceId);
|
|
break;
|
|
}
|
|
|
|
case 'pong':
|
|
break;
|
|
|
|
case 'error':
|
|
console.error(`WebSocket error (${origin || 'home'}):`, event.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ─── Connection management ────────────────────────────────────────────────────
|
|
|
|
function getOrCreateConnection(origin: string, token: string): ConnectionState {
|
|
let conn = connections.get(origin);
|
|
if (!conn) {
|
|
conn = {
|
|
ws: null,
|
|
heartbeatWorker: null,
|
|
reconnectAttempts: 0,
|
|
reconnectTimer: undefined,
|
|
token,
|
|
};
|
|
connections.set(origin, conn);
|
|
} else {
|
|
conn.token = token;
|
|
}
|
|
return conn;
|
|
}
|
|
|
|
function connectToOrigin(origin: string, token: string): void {
|
|
const conn = getOrCreateConnection(origin, token);
|
|
|
|
if (conn.ws && (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
const wsUrl = buildWsUrl(origin);
|
|
const ws = new WebSocket(wsUrl);
|
|
conn.ws = ws;
|
|
|
|
ws.onopen = () => {
|
|
conn.reconnectAttempts = 0;
|
|
ws.send(JSON.stringify({ type: 'auth', token: conn.token }));
|
|
startHeartbeat(conn);
|
|
};
|
|
|
|
ws.onmessage = (e) => {
|
|
let event: ServerEvent;
|
|
try {
|
|
event = JSON.parse(e.data as string) as ServerEvent;
|
|
} catch {
|
|
console.error(`Failed to parse WebSocket message (${origin || 'home'})`);
|
|
return;
|
|
}
|
|
try {
|
|
handleEvent(origin, event);
|
|
} catch (err) {
|
|
console.error(`Error handling WS event "${event.type}" (${origin || 'home'}):`, err);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
conn.ws = null;
|
|
stopHeartbeat(conn);
|
|
// Mark remote instance as disconnected in instanceStore
|
|
if (origin !== HOME_ORIGIN) {
|
|
import('../stores/instanceStore').then(({ useInstanceStore }) => {
|
|
useInstanceStore.getState().setInstanceStatus(origin, 'disconnected', 'Connection lost — reconnecting');
|
|
});
|
|
}
|
|
// Only reconnect if the connection is still registered (not explicitly disconnected)
|
|
if (connections.has(origin) && conn.token) {
|
|
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 30000);
|
|
conn.reconnectAttempts++;
|
|
conn.reconnectTimer = setTimeout(() => connectToOrigin(origin, conn.token), delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
ws.close();
|
|
};
|
|
}
|
|
|
|
function disconnectFromOrigin(origin: string): void {
|
|
const conn = connections.get(origin);
|
|
if (!conn) return;
|
|
|
|
// Clear token to prevent reconnect
|
|
conn.token = '';
|
|
|
|
if (conn.reconnectTimer) {
|
|
clearTimeout(conn.reconnectTimer);
|
|
conn.reconnectTimer = undefined;
|
|
}
|
|
stopHeartbeat(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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Read-only home WS connection status — safe to call from any component without managing lifecycle. */
|
|
export function getHomeWsConnected(): boolean {
|
|
const conn = connections.get(HOME_ORIGIN);
|
|
return !!conn?.ws && conn.ws.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
/** Send an event over the WebSocket. Can be used outside of React components. */
|
|
export function wsSend(event: ClientEvent, origin: string = HOME_ORIGIN): void {
|
|
const conn = connections.get(origin);
|
|
if (conn?.ws && conn.ws.readyState === WebSocket.OPEN) {
|
|
conn.ws.send(JSON.stringify(event));
|
|
}
|
|
}
|
|
|
|
/** Send an event to ALL connected WebSocket instances (home + remotes). */
|
|
export function wsSendAll(event: ClientEvent): void {
|
|
for (const [_origin, conn] of connections) {
|
|
if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
|
|
conn.ws.send(JSON.stringify(event));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook to initialize the home WebSocket connection. Should only be called ONCE
|
|
* from the top-level layout component (AppLayout). Other components should
|
|
* use the exported `wsSend` function directly.
|
|
*
|
|
* Remote instance connections are managed by instanceStore via
|
|
* connectInstance/disconnectInstance — not by this hook.
|
|
*/
|
|
export function useWebSocket() {
|
|
const token = useAuthStore((s) => s.token);
|
|
const prevToken = useRef(token);
|
|
const [isConnected, setIsConnected] = React.useState(false);
|
|
|
|
useEffect(() => {
|
|
if (token && (!homeInitialized || token !== prevToken.current)) {
|
|
homeInitialized = true;
|
|
connectToOrigin(HOME_ORIGIN, token);
|
|
} else if (!token && homeInitialized) {
|
|
homeInitialized = false;
|
|
disconnectFromOrigin(HOME_ORIGIN);
|
|
}
|
|
prevToken.current = token;
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
const checkStatus = setInterval(() => {
|
|
const conn = connections.get(HOME_ORIGIN);
|
|
setIsConnected(!!conn?.ws && conn.ws.readyState === WebSocket.OPEN);
|
|
}, 500);
|
|
return () => {
|
|
clearInterval(checkStatus);
|
|
homeInitialized = false;
|
|
disconnectFromOrigin(HOME_ORIGIN);
|
|
};
|
|
}, []);
|
|
|
|
return { send: wsSend, isConnected };
|
|
}
|