chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
+20
View File
@@ -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 };
}
+24
View File
@@ -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 };
}
+147
View File
@@ -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,
};
}
+185
View File
@@ -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,
};
}
+138
View File
@@ -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 };
}
+160
View File
@@ -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 };
}