Files
backspace/packages/web/src/hooks/useWebSocket.js
T
Jannis Braun 4f65ea9c86 fix: resolve LiveKit voice/video stability issues
- Guard Disconnected/ConnectionStateChanged event handlers against stale rooms:
  old room events no longer nuke the new room's state (root cause of buttons
  failing, mute getting stuck, DUPLICATE_IDENTITY cascades)
- Reset media state (isMuted/isCameraOn/isScreenSharing) on connect to prevent
  desync after reconnects
- Add voiceStates to WS ready payload so users see who's in voice on page load
- Wire VoiceControls buttons to check getActiveRoom() before SDK calls
- Guard ChannelSidebar against re-joining the same voice channel
- Switch LIVEKIT_URL to wss://nova.ddns.net/livekit for HTTPS secure context
  (required for getUserMedia in Safari)
2026-02-18 07:52:25 +01:00

165 lines
5.6 KiB
JavaScript

import { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
let globalWs = null;
let reconnectAttempts = 0;
let reconnectTimer;
let currentToken = null;
let isInitialized = false;
function handleEvent(event) {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
switch (event.type) {
case 'ready':
setUser(event.user);
populateFromReady(event.servers, event.folders, event.dmChannels);
if (currentServerId) {
loadServerDetail(currentServerId);
}
// Populate voice channel state so users see who's in voice on load
if (event.voiceStates) {
const vs = event.voiceStates;
const { setVoiceUsers } = useVoiceStore.getState();
for (const [channelId, userIds] of Object.entries(vs)) {
setVoiceUsers(channelId, userIds);
}
}
break;
case 'message_created':
addMessage(event.message.channelId, event.message);
break;
case 'message_updated':
updateMessage(event.message);
break;
case 'message_deleted':
removeMessage(event.messageId, event.channelId);
break;
case 'typing':
setTyping(event.channelId, event.userId, event.username);
break;
case 'presence_update':
updateMemberPresence(event.userId, event.status);
break;
case 'voice_state_update':
if (event.action === 'join') {
addVoiceUser(event.channelId, event.userId);
}
else {
removeVoiceUser(event.channelId, event.userId);
}
break;
case 'member_joined':
addMember(event.member);
break;
case 'member_left':
removeMember(event.userId);
break;
case 'dm_message_created':
addMessage(event.message.dmChannelId, event.message);
break;
case 'reaction_added':
onReactionAdded(event.messageId, event.reaction);
break;
case 'reaction_removed':
onReactionRemoved(event.messageId, event.userId, event.emoji);
break;
case 'friend_request_received': {
const { addIncomingRequest } = useSocialStore.getState();
addIncomingRequest(event.request);
break;
}
case 'friend_request_accepted': {
const { addFriendFromAccepted } = useSocialStore.getState();
addFriendFromAccepted(event.friend, event.requestId);
break;
}
case 'error':
console.error('WebSocket error:', event.message);
break;
}
}
function connect() {
if (!currentToken)
return;
if (globalWs && (globalWs.readyState === WebSocket.OPEN || globalWs.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const ws = new WebSocket(wsUrl);
globalWs = ws;
ws.onopen = () => {
reconnectAttempts = 0;
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
};
ws.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
handleEvent(event);
}
catch {
console.error('Failed to parse WebSocket message');
}
};
ws.onclose = () => {
globalWs = null;
if (currentToken) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
reconnectTimer = setTimeout(connect, delay);
}
};
ws.onerror = () => {
ws.close();
};
}
function disconnect() {
currentToken = null;
isInitialized = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
if (globalWs) {
globalWs.close();
globalWs = null;
}
}
/** Send an event over the WebSocket. Can be used outside of React components. */
export function wsSend(event) {
if (globalWs && globalWs.readyState === WebSocket.OPEN) {
globalWs.send(JSON.stringify(event));
}
}
/**
* Hook to initialize the WebSocket connection. Should only be called ONCE
* from the top-level layout component (AppLayout). Other components should
* use the exported `wsSend` function directly.
*/
export function useWebSocket() {
const token = useAuthStore((s) => s.token);
const prevToken = useRef(token);
useEffect(() => {
if (token && (!isInitialized || token !== prevToken.current)) {
currentToken = token;
isInitialized = true;
connect();
}
else if (!token && isInitialized) {
disconnect();
}
prevToken.current = token;
}, [token]);
useEffect(() => {
return () => {
disconnect();
};
}, []);
return { send: wsSend };
}