From 7dde0d0599405d75d1dc3ca459ce0d99abd03404 Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 21 Nov 2025 04:32:49 -0500 Subject: [PATCH] chat, nicknames, user list. --- server/index.js | 2 + server/src/services/chatService.js | 119 ++++++++++++++++ server/src/services/nicknameService.js | 50 +++++++ server/src/services/sessionService.js | 28 ++++ webui/src/App.jsx | 6 + webui/src/components/ChatPanel.jsx | 101 +++++++++++++ webui/src/components/KeymapSettings.jsx | 1 + webui/src/components/TelemetryPanel.jsx | 14 +- webui/src/components/UserListPanel.jsx | 134 ++++++++++++++++++ webui/src/components/VideoTile.jsx | 12 +- webui/src/context/ChatContext.jsx | 90 ++++++++++++ webui/src/context/SessionContext.jsx | 2 + webui/src/controls/ControlContext.jsx | 5 +- webui/src/controls/constants.js | 1 + .../controls/inputs/KeyboardInputManager.jsx | 29 +++- webui/src/main.jsx | 15 +- 16 files changed, 587 insertions(+), 22 deletions(-) create mode 100644 server/src/services/chatService.js create mode 100644 webui/src/components/ChatPanel.jsx create mode 100644 webui/src/components/UserListPanel.jsx create mode 100644 webui/src/context/ChatContext.jsx diff --git a/server/index.js b/server/index.js index 559b298b..83a22a02 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,8 @@ require('./src/services/roverManager'); require('./src/services/commandService'); require('./src/services/roverConnectionService'); require('./src/services/assignmentService'); +require('./src/services/nicknameService'); +require('./src/services/chatService'); require('./src/services/videoSessions'); require('./src/services/videoAuthService'); require('./src/services/videoSocketService'); diff --git a/server/src/services/chatService.js b/server/src/services/chatService.js new file mode 100644 index 00000000..993b2652 --- /dev/null +++ b/server/src/services/chatService.js @@ -0,0 +1,119 @@ +const { v4: uuidv4 } = require('uuid'); +const io = require('../globals/io'); +const logger = require('../globals/logger').child('chatService'); +const { publishEvent, subscribe } = require('./eventBus'); +const { getRole } = require('./roleService'); +const { describeAssignment } = require('./assignmentService'); +const roverManager = require('./roverManager'); +const { getNickname } = require('./nicknameService'); + +const RATE_LIMIT_WINDOW_MS = 8000; +const RATE_LIMIT_MAX = 5; +const rateBuckets = new Map(); // socketId -> [timestamps] + +const PROFANITY_LIST = ['fuck', 'shit', 'bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick']; +const DUPLICATE_WINDOW_MS = 15000; +const lastMessageBySocket = new Map(); // socketId -> { text, ts } + +function withinRateLimit(socketId) { + const now = Date.now(); + const entries = rateBuckets.get(socketId) || []; + const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS); + next.push(now); + rateBuckets.set(socketId, next); + return next.length <= RATE_LIMIT_MAX; +} + +function hasProfanity(text) { + const lower = text.toLowerCase(); + return PROFANITY_LIST.some((word) => lower.includes(word)); +} + +function isDuplicate(socketId, text) { + const prev = lastMessageBySocket.get(socketId); + const now = Date.now(); + if (!prev) { + lastMessageBySocket.set(socketId, { text, ts: now }); + return false; + } + lastMessageBySocket.set(socketId, { text, ts: now }); + return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS; +} + +function isKeymash(text) { + if (!text) return false; + if (/(.)\1{6,}/.test(text)) return true; // same char 7+ + if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true; + if (/^[qwertyuiop]{6,}$/i.test(text)) return true; + return false; +} + +function resolveRoverId(socketId) { + const primary = roverManager.getPrimaryRoverForSocket(socketId); + if (primary) return primary; + const assignment = describeAssignment(socketId); + return assignment?.roverId || null; +} + +function buildMessage(socket, text) { + const roverId = resolveRoverId(socket.id); + return { + id: uuidv4(), + ts: Date.now(), + socketId: socket.id, + nickname: getNickname(socket) || null, + role: getRole(socket), + roverId, + text, + }; +} + +function broadcastMessage(message) { + publishEvent({ source: 'chat', type: 'chat:message', payload: message }); +} + +function handleIncoming({ text } = {}, socket, cb = () => {}) { + const role = getRole(socket); + if (role === 'spectator') { + cb({ error: 'Spectators cannot chat' }); + return; + } + const clean = typeof text === 'string' ? text.trim() : ''; + if (!clean) { + cb({ error: 'Message required' }); + return; + } + if (!withinRateLimit(socket.id)) { + cb({ error: 'Slow down' }); + return; + } + if (clean.length > 256) { + cb({ error: 'Message too long' }); + return; + } + if (hasProfanity(clean)) { + cb({ error: 'Message blocked' }); + return; + } + if (isDuplicate(socket.id, clean)) { + cb({ error: 'Duplicate message' }); + return; + } + if (isKeymash(clean)) { + cb({ error: 'Message looks like spam' }); + return; + } + const message = buildMessage(socket, clean); + logger.info('Chat message', { socket: socket.id, roverId: message.roverId }); + broadcastMessage(message); + cb({ success: true }); +} + +io.on('connection', (socket) => { + socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb)); +}); + +subscribe('chat:message', ({ payload }) => { + if (!payload) return; + io.emit('chat:message', payload); +}); diff --git a/server/src/services/nicknameService.js b/server/src/services/nicknameService.js index b11abfed..c44d7f6b 100644 --- a/server/src/services/nicknameService.js +++ b/server/src/services/nicknameService.js @@ -1 +1,51 @@ +const EventEmitter = require('events'); const io = require('../globals/io'); +const logger = require('../globals/logger').child('nicknameService'); +const { getRole } = require('./roleService'); + +const nicknameEvents = new EventEmitter(); + +function sanitizeNickname(raw) { + if (typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + return trimmed.slice(0, 32); +} + +function getNickname(socket) { + return socket?.data?.nickname || ''; +} + +function setNickname(socket, nickname) { + if (!socket) return null; + const role = getRole(socket); + if (role === 'spectator') { + throw new Error('Spectators cannot set nicknames'); + } + const value = sanitizeNickname(nickname); + if (!value) { + throw new Error('Nickname required'); + } + socket.data = socket.data || {}; + socket.data.nickname = value; + nicknameEvents.emit('change', { socketId: socket.id, nickname: value }); + logger.info('Nickname set', { socketId: socket.id, nickname: value }); + return value; +} + +io.on('connection', (socket) => { + socket.on('nickname:set', ({ nickname } = {}, cb = () => {}) => { + try { + const value = setNickname(socket, nickname); + cb({ success: true, nickname: value }); + } catch (err) { + cb({ error: err.message }); + } + }); +}); + +module.exports = { + getNickname, + setNickname, + nicknameEvents, +}; diff --git a/server/src/services/sessionService.js b/server/src/services/sessionService.js index 0e46b60c..0e18a49f 100644 --- a/server/src/services/sessionService.js +++ b/server/src/services/sessionService.js @@ -8,8 +8,25 @@ const assignmentService = require('./assignmentService'); const { getActiveDrivers, turnEvents } = require('./turnService'); const { getRoomCameras, roomCameraEvents } = require('./roomCameraService'); const { getState: getHomeAssistantState, homeAssistantEvents } = require('./homeAssistantService'); +const { getNickname, nicknameEvents } = require('./nicknameService'); + +function buildUserEntry(socket) { + if (!socket) return null; + const role = getRole(socket); + const assignment = assignmentService.describeAssignment(socket.id); + const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id); + return { + socketId: socket.id, + nickname: getNickname(socket) || null, + role, + roverId: primaryRover || assignment?.roverId || null, + }; +} function buildSession(socket) { + const users = Array.from(io.sockets.sockets.values()) + .map((sock) => buildUserEntry(sock)) + .filter(Boolean); return { socketId: socket?.id || null, role: getRole(socket), @@ -19,6 +36,7 @@ function buildSession(socket) { activeDrivers: getActiveDrivers(), roomCameras: getRoomCameras(), homeAssistant: getHomeAssistantState(), + users, }; } @@ -97,6 +115,16 @@ homeAssistantEvents.on('status', () => { syncAll(); }); +nicknameEvents.on('change', ({ socketId }) => { + const socket = socketId ? io.sockets.sockets.get(socketId) : null; + if (socket) { + logger.info('Nickname change; syncing session', socketId); + syncSocket(socket); + } else { + syncAll(); + } +}); + // sync all sockets 20 seconds setInterval(() => { logger.info('Periodic session sync for all clients'); diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 845485b0..a3a83066 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -17,6 +17,8 @@ import RightPaneTabs from './components/RightPaneTabs.jsx'; import ModeGateOverlay from './components/ModeGateOverlay.jsx'; import SessionSnapshot from './components/SessionSnapshot.jsx'; import HomeAssistantControls from './components/HomeAssistantControls.jsx'; +import UserListPanel from './components/UserListPanel.jsx'; +import ChatPanel from './components/ChatPanel.jsx'; function useLayoutMode() { const [mode, setMode] = useState(() => { @@ -53,6 +55,10 @@ function DesktopLayout({ layout }) {
+
+ + +
diff --git a/webui/src/components/ChatPanel.jsx b/webui/src/components/ChatPanel.jsx new file mode 100644 index 00000000..4cc5f3da --- /dev/null +++ b/webui/src/components/ChatPanel.jsx @@ -0,0 +1,101 @@ +import { useMemo, useState } from 'react'; +import { useChat } from '../context/ChatContext.jsx'; +import { useSession } from '../context/SessionContext.jsx'; + +function roleColors(role) { + switch (role) { + case 'admin': + case 'lockdown': + case 'lockdown-admin': + return 'text-amber-300'; + case 'spectator': + return 'text-slate-400'; + default: + return 'text-sky-300'; + } +} + +function formatTime(ts) { + const date = new Date(ts); + return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; +} + +function displayName(message) { + return message.nickname || message.socketId?.slice(0, 6) || 'unknown'; +} + +export default function ChatPanel() { + const { session } = useSession(); + const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat(); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const canChat = session?.role !== 'spectator'; + + const sorted = useMemo(() => messages.slice(-200), [messages]); + + async function handleSend(event) { + event.preventDefault(); + if (!canChat) return; + const clean = draft.trim(); + if (!clean) return; + setSending(true); + try { + await sendMessage(clean); + setDraft(''); + blurChat(); + } catch (err) { + alert(err.message); + } finally { + setSending(false); + } + } + + return ( +
+ {/*
+ Chat + {sorted.length} +
*/} +
+ {sorted.length === 0 ? ( +

No messages yet.

+ ) : ( + sorted.map((msg) => { + const isAdmin = + msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin'; + return ( +
+
+ {formatTime(msg.ts)} + {displayName(msg)} + {msg.roverId && ( + rover {msg.roverId} + )} +
+

{msg.text}

+
+ ); + }) + )} +
+
+ setDraft(e.target.value)} + onFocus={onInputFocus} + onBlur={onInputBlur} + ref={(el) => registerInputRef(el)} + placeholder={canChat ? 'Type a message…' : 'Spectators cannot chat'} + disabled={!canChat} + /> + +
+
+ ); +} diff --git a/webui/src/components/KeymapSettings.jsx b/webui/src/components/KeymapSettings.jsx index a5d19c85..c546035e 100644 --- a/webui/src/components/KeymapSettings.jsx +++ b/webui/src/components/KeymapSettings.jsx @@ -21,6 +21,7 @@ const KEY_ACTIONS = [ { id: 'cameraDown', label: 'Camera Down', group: 'Camera' }, { id: 'driveMacro', label: 'Drive Macro', group: 'Macros' }, { id: 'dockMacro', label: 'Dock Macro', group: 'Macros' }, + { id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' }, ]; function groupActions(actions) { diff --git a/webui/src/components/TelemetryPanel.jsx b/webui/src/components/TelemetryPanel.jsx index 384db24d..49a443cf 100644 --- a/webui/src/components/TelemetryPanel.jsx +++ b/webui/src/components/TelemetryPanel.jsx @@ -22,13 +22,13 @@ export default function TelemetryPanel() { const rawSnippet = frame?.raw ? frame.raw : null; const roster = session?.roster ?? []; const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null; - const driverLabel = !roverId - ? 'n/a' - : activeDriverId - ? activeDriverId === session?.socketId - ? 'You' - : activeDriverId.slice(0, 6) - : 'Available'; + const driverLabel = useMemo(() => { + if (!roverId) return 'n/a'; + if (!activeDriverId) return 'Available'; + if (activeDriverId === session?.socketId) return 'You'; + const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId); + return user?.nickname || activeDriverId.slice(0, 6); + }, [activeDriverId, roverId, session?.socketId, session?.users]); const isAdmin = useMemo( () => session?.role === 'admin' || session?.role === 'lockdown' || session?.role === 'lockdown-admin', [session?.role], diff --git a/webui/src/components/UserListPanel.jsx b/webui/src/components/UserListPanel.jsx new file mode 100644 index 00000000..3fc94b3e --- /dev/null +++ b/webui/src/components/UserListPanel.jsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useSession } from '../context/SessionContext.jsx'; +import { useSettingsNamespace } from '../settings/index.js'; + +function roleColors(role) { + switch (role) { + case 'admin': + case 'lockdown': + case 'lockdown-admin': + return 'text-amber-300'; + case 'spectator': + return 'text-slate-400'; + default: + return 'text-sky-300'; + } +} + +function formatLabel(user, selfId) { + if (!user) return ''; + const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown'; + if (user.socketId && user.socketId === selfId) { + return `${base} (you)`; + } + return base; +} + +export default function UserListPanel() { + const { session, setNickname } = useSession(); + const { value, save } = useSettingsNamespace('profile', { nickname: '' }); + const [nicknameInput, setNicknameInput] = useState(value.nickname || ''); + const [saving, setSaving] = useState(false); + const hasSyncedRef = useRef(false); + const canSetNickname = session?.role !== 'spectator'; + const users = session?.users ?? []; + const selfId = session?.socketId || null; + + useEffect(() => { + setNicknameInput(value.nickname || ''); + }, [value.nickname]); + + useEffect(() => { + if (hasSyncedRef.current) return; + if (!canSetNickname) return; + if (!session?.socketId) return; + if (!nicknameInput) return; + hasSyncedRef.current = true; + setNickname(nicknameInput).catch(() => { + hasSyncedRef.current = false; + }); + }, [canSetNickname, nicknameInput, session?.socketId, setNickname]); + + const sorted = useMemo( + () => + [...users].sort((a, b) => { + if (a.socketId === selfId) return -1; + if (b.socketId === selfId) return 1; + return (a.nickname || '').localeCompare(b.nickname || ''); + }), + [selfId, users], + ); + + async function handleSave(event) { + event.preventDefault(); + if (!canSetNickname) return; + const trimmed = (nicknameInput || '').trim().slice(0, 32); + if (!trimmed) return; + setSaving(true); + try { + await setNickname(trimmed); + save({ nickname: trimmed }); + } catch (err) { + alert(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+
+ {/*

Nickname

*/} +
+ setNicknameInput(e.target.value)} + maxLength={32} + placeholder="Enter a nickname" + disabled={!canSetNickname} + /> + +
+ {!canSetNickname &&

Spectators cannot set nicknames.

} +
+ +
+
+ Users + {sorted.length} +
+
+ {sorted.length === 0 ? ( +

Waiting for users…

+ ) : ( + sorted.map((user) => { + const isAdmin = + user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin'; + return ( +
+
+

{formatLabel(user, selfId)}

+

+ {user.roverId ? `Driving ${user.roverId}` : 'No rover'} +

+
+ {isAdmin && ( + + Admin + + )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/webui/src/components/VideoTile.jsx b/webui/src/components/VideoTile.jsx index fd2ab038..1067ebf5 100644 --- a/webui/src/components/VideoTile.jsx +++ b/webui/src/components/VideoTile.jsx @@ -195,11 +195,13 @@ function BatteryBar({ charge, config, status }) {
{status}
-
-
- - Battery {percentText} - +
+
+ + Battery {percentText} + +
+
); diff --git a/webui/src/context/ChatContext.jsx b/webui/src/context/ChatContext.jsx new file mode 100644 index 00000000..69482f38 --- /dev/null +++ b/webui/src/context/ChatContext.jsx @@ -0,0 +1,90 @@ +/* eslint-disable react-refresh/only-export-components */ + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useSocket } from './SocketContext.jsx'; +import { useSession } from './SessionContext.jsx'; + +const ChatContext = createContext({ + messages: [], + sendMessage: async () => {}, + focusChat: () => {}, + blurChat: () => {}, + registerInputRef: () => {}, + onInputFocus: () => {}, + onInputBlur: () => {}, + isChatFocused: false, +}); + +export function ChatProvider({ children }) { + const socket = useSocket(); + const { session } = useSession(); + const [messages, setMessages] = useState([]); + const [isChatFocused, setIsChatFocused] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + function handleMessage(payload = {}) { + setMessages((prev) => [...prev.slice(-99), payload]); + } + socket.on('chat:message', handleMessage); + return () => { + socket.off('chat:message', handleMessage); + }; + }, [socket]); + + const sendMessage = useCallback( + (text) => + new Promise((resolve, reject) => { + socket.emit('chat:send', { text }, (resp = {}) => { + if (resp.error) { + reject(new Error(resp.error)); + } else { + resolve(resp); + } + }); + }), + [socket], + ); + + const registerInputRef = useCallback((el) => { + inputRef.current = el; + }, []); + + const focusChat = useCallback(() => { + setIsChatFocused(true); + inputRef.current?.focus(); + }, []); + + const blurChat = useCallback(() => { + setIsChatFocused(false); + inputRef.current?.blur(); + }, []); + + const onInputFocus = useCallback(() => setIsChatFocused(true), []); + const onInputBlur = useCallback(() => setIsChatFocused(false), []); + + const value = useMemo( + () => ({ + messages, + sendMessage, + focusChat, + blurChat, + registerInputRef, + onInputFocus, + onInputBlur, + isChatFocused, + selfSocketId: session?.socketId || null, + }), + [blurChat, focusChat, isChatFocused, messages, onInputBlur, onInputFocus, registerInputRef, sendMessage, session?.socketId], + ); + + return {children}; +} + +export function useChat() { + const ctx = useContext(ChatContext); + if (!ctx) { + throw new Error('useChat must be used inside ChatProvider'); + } + return ctx; +} diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index d5c4981b..05cfe497 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -14,6 +14,7 @@ const SessionContext = createContext({ subscribeAll: async () => {}, homeAssistantToggle: async () => {}, homeAssistantSetState: async () => {}, + setNickname: async () => {}, }); function useAckEmitter(socket) { @@ -94,6 +95,7 @@ export function SessionProvider({ children }) { homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }), homeAssistantSetState: (entityId, state) => emitWithAck('homeAssistant:setState', { entityId, state }), + setNickname: (nickname) => emitWithAck('nickname:set', { nickname }), }), [emitWithAck], ); diff --git a/webui/src/controls/ControlContext.jsx b/webui/src/controls/ControlContext.jsx index 14bcaf91..120059b2 100644 --- a/webui/src/controls/ControlContext.jsx +++ b/webui/src/controls/ControlContext.jsx @@ -35,9 +35,8 @@ export function ControlSystemProvider({ children }) { }, [pipeline.roverId]); useEffect(() => { - if (controlSettings?.keymap) { - dispatch({ type: 'control/set-keymap', payload: controlSettings.keymap }); - } + const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) }; + dispatch({ type: 'control/set-keymap', payload: mergedKeymap }); if (controlSettings?.macros) { dispatch({ type: 'control/set-macros', payload: controlSettings.macros }); } diff --git a/webui/src/controls/constants.js b/webui/src/controls/constants.js index 61517501..fd3740c0 100644 --- a/webui/src/controls/constants.js +++ b/webui/src/controls/constants.js @@ -38,6 +38,7 @@ export const DEFAULT_KEYMAP = { cameraDown: ['j'], driveMacro: ['f'], dockMacro: ['g'], + chatFocus: ['enter'], }; export const DEFAULT_MACROS = [ diff --git a/webui/src/controls/inputs/KeyboardInputManager.jsx b/webui/src/controls/inputs/KeyboardInputManager.jsx index dd9cbe5f..d844bca6 100644 --- a/webui/src/controls/inputs/KeyboardInputManager.jsx +++ b/webui/src/controls/inputs/KeyboardInputManager.jsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useControlSystem } from '../ControlContext.jsx'; +import { useChat } from '../../context/ChatContext.jsx'; import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js'; const SOURCE = 'keyboard'; @@ -87,6 +88,7 @@ export default function KeyboardInputManager() { registerInputState, }, } = useControlSystem(); + const { focusChat, blurChat, isChatFocused } = useChat(); const keymap = useMemo(() => normalizeKeymapEntries(state.keymap), [state.keymap]); const actionTokens = useMemo(() => { const tokens = new Set(); @@ -182,6 +184,17 @@ export default function KeyboardInputManager() { if (shouldIgnoreEvent(event)) return; const tokens = tokensForEvent(event); if (tokens.length === 0) return; + const tokenSet = new Set(tokens); + if (bindingActive(keymap.chatFocus, tokenSet)) { + event.preventDefault(); + resetAll(); + if (isChatFocused) { + blurChat(); + } else { + focusChat(); + } + return; + } if (tokens.some((token) => actionTokens.has(token))) { event.preventDefault(); } @@ -221,7 +234,21 @@ export default function KeyboardInputManager() { window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [actionTokens, driveFromKeys, ensureServoLoop, keymap.dockMacro, keymap.driveMacro, resetAll, runMacro, setMode]); + }, [ + actionTokens, + blurChat, + driveFromKeys, + ensureServoLoop, + focusChat, + isChatFocused, + keymap.chatFocus, + keymap.dockMacro, + keymap.driveMacro, + resetAll, + runMacro, + setMode, + stopAllMotion, + ]); const latestResetAllRef = useRef(resetAll); useEffect(() => { diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 16163c0f..2bd180f4 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -6,6 +6,7 @@ import App from './App.jsx' import { SocketProvider } from './context/SocketContext.jsx' import { SessionProvider } from './context/SessionContext.jsx' import { TelemetryProvider } from './context/TelemetryContext.jsx' +import { ChatProvider } from './context/ChatContext.jsx' import SpectatorApp from './spectate/SpectatorApp.jsx' createRoot(document.getElementById('root')).render( @@ -13,12 +14,14 @@ createRoot(document.getElementById('root')).render( - - - } /> - } /> - - + + + + } /> + } /> + + +