chore: Initial commit of Opencord base state
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
export function useAuth() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!user && !isLoading) {
|
||||
loadUser();
|
||||
}
|
||||
}, [token, user, isLoading, loadUser, navigate]);
|
||||
return { user, isLoading, isAuthenticated: !!token };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
export function useAuth() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user && !isLoading) {
|
||||
loadUser();
|
||||
}
|
||||
}, [token, user, isLoading, loadUser, navigate]);
|
||||
|
||||
return { user, isLoading, isAuthenticated: !!token };
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Room, RoomEvent, Track, ConnectionState, } from 'livekit-client';
|
||||
import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
function parseIdentity(identity) {
|
||||
const parts = identity.split(':');
|
||||
return {
|
||||
userId: parts[0] ?? identity,
|
||||
username: parts[1] ?? identity,
|
||||
};
|
||||
}
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState(null);
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const roomRef = useRef(null);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const updateParticipants = useCallback(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r)
|
||||
return;
|
||||
const allParticipants = [];
|
||||
const processParticipant = (p) => {
|
||||
const { userId, username } = parseIdentity(p.identity);
|
||||
let audioTrack = null;
|
||||
let videoTrack = null;
|
||||
let screenTrack = null;
|
||||
p.trackPublications.forEach((pub) => {
|
||||
const track = pub.track;
|
||||
if (!track)
|
||||
return;
|
||||
if (pub.source === Track.Source.Microphone) {
|
||||
audioTrack = track.mediaStreamTrack;
|
||||
}
|
||||
else if (pub.source === Track.Source.Camera) {
|
||||
videoTrack = track.mediaStreamTrack;
|
||||
}
|
||||
else if (pub.source === Track.Source.ScreenShare) {
|
||||
screenTrack = track.mediaStreamTrack;
|
||||
}
|
||||
});
|
||||
allParticipants.push({
|
||||
identity: p.identity,
|
||||
userId,
|
||||
username,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: !p.isMicrophoneEnabled,
|
||||
isCameraOn: p.isCameraEnabled,
|
||||
isScreenSharing: p.isScreenShareEnabled,
|
||||
audioTrack,
|
||||
videoTrack,
|
||||
screenTrack,
|
||||
});
|
||||
};
|
||||
processParticipant(r.localParticipant);
|
||||
r.remoteParticipants.forEach(processParticipant);
|
||||
setParticipants(allParticipants);
|
||||
}, []);
|
||||
const connect = useCallback(async (channelId) => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const { token } = await api.livekit.token(channelId);
|
||||
const livekitUrl = 'wss://nova.ddns.net/livekit';
|
||||
const newRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
newRoom.on(RoomEvent.ParticipantConnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackMuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, updateParticipants);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
});
|
||||
newRoom.on(RoomEvent.Disconnected, () => {
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
});
|
||||
await newRoom.connect(livekitUrl, token);
|
||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||
roomRef.current = newRoom;
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
updateParticipants();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to connect to LiveKit:', err);
|
||||
}
|
||||
finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants]);
|
||||
const disconnect = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
setRoom(null);
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
}
|
||||
}, []);
|
||||
const toggleMic = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isMuted, updateParticipants]);
|
||||
const toggleCamera = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isCameraOn, updateParticipants]);
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isScreenSharing, updateParticipants]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
room,
|
||||
participants,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCamera,
|
||||
toggleScreenShare,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
LocalTrackPublication,
|
||||
RemoteTrackPublication,
|
||||
Participant,
|
||||
RemoteParticipant,
|
||||
LocalParticipant,
|
||||
ConnectionState,
|
||||
} from 'livekit-client';
|
||||
import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
|
||||
export interface ParticipantInfo {
|
||||
identity: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
isSpeaking: boolean;
|
||||
isMuted: boolean;
|
||||
isCameraOn: boolean;
|
||||
isScreenSharing: boolean;
|
||||
audioTrack: MediaStreamTrack | null;
|
||||
videoTrack: MediaStreamTrack | null;
|
||||
screenTrack: MediaStreamTrack | null;
|
||||
}
|
||||
|
||||
function parseIdentity(identity: string): { userId: string; username: string } {
|
||||
const parts = identity.split(':');
|
||||
return {
|
||||
userId: parts[0] ?? identity,
|
||||
username: parts[1] ?? identity,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
|
||||
const updateParticipants = useCallback(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
|
||||
const allParticipants: ParticipantInfo[] = [];
|
||||
|
||||
const processParticipant = (p: Participant) => {
|
||||
const { userId, username } = parseIdentity(p.identity);
|
||||
let audioTrack: MediaStreamTrack | null = null;
|
||||
let videoTrack: MediaStreamTrack | null = null;
|
||||
let screenTrack: MediaStreamTrack | null = null;
|
||||
|
||||
p.trackPublications.forEach((pub) => {
|
||||
const track = pub.track;
|
||||
if (!track) return;
|
||||
if (pub.source === Track.Source.Microphone) {
|
||||
audioTrack = track.mediaStreamTrack;
|
||||
} else if (pub.source === Track.Source.Camera) {
|
||||
videoTrack = track.mediaStreamTrack;
|
||||
} else if (pub.source === Track.Source.ScreenShare) {
|
||||
screenTrack = track.mediaStreamTrack;
|
||||
}
|
||||
});
|
||||
|
||||
allParticipants.push({
|
||||
identity: p.identity,
|
||||
userId,
|
||||
username,
|
||||
isSpeaking: p.isSpeaking,
|
||||
isMuted: !p.isMicrophoneEnabled,
|
||||
isCameraOn: p.isCameraEnabled,
|
||||
isScreenSharing: p.isScreenShareEnabled,
|
||||
audioTrack,
|
||||
videoTrack,
|
||||
screenTrack,
|
||||
});
|
||||
};
|
||||
|
||||
processParticipant(r.localParticipant);
|
||||
r.remoteParticipants.forEach(processParticipant);
|
||||
|
||||
setParticipants(allParticipants);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (channelId: string) => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const { token } = await api.livekit.token(channelId);
|
||||
const livekitUrl = 'wss://nova.ddns.net/livekit';
|
||||
|
||||
const newRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackMuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, updateParticipants);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
});
|
||||
newRoom.on(RoomEvent.Disconnected, () => {
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
});
|
||||
|
||||
await newRoom.connect(livekitUrl, token);
|
||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||
|
||||
roomRef.current = newRoom;
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
updateParticipants();
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to LiveKit:', err);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
setRoom(null);
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleMic = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isMuted, updateParticipants]);
|
||||
|
||||
const toggleCamera = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isCameraOn, updateParticipants]);
|
||||
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.localParticipant.setScreenShareEnabled(!isScreenSharing);
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isScreenSharing, updateParticipants]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
room,
|
||||
participants,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCamera,
|
||||
toggleScreenShare,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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';
|
||||
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 } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
setUser(event.user);
|
||||
populateFromReady(event.servers);
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
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':
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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 type { ServerEvent, ClientEvent } from '@opencord/shared';
|
||||
|
||||
let globalWs: WebSocket | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let currentToken: string | null = null;
|
||||
let isInitialized = false;
|
||||
|
||||
function handleEvent(event: ServerEvent): void {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||
const { addMessage, updateMessage, removeMessage, setTyping } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
setUser(event.user);
|
||||
populateFromReady(event.servers);
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
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':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
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 as string) as ServerEvent;
|
||||
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(): void {
|
||||
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: ClientEvent): void {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user