move UI stuff around, add message sound

This commit is contained in:
legop3
2025-11-21 04:55:30 -05:00
parent 2baecc90ff
commit b0ca95807c
12 changed files with 80 additions and 58 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-DuG6d4XG.js"></script> <script type="module" crossorigin src="/assets/index-Bw29mQyW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BH17i8qZ.css"> <link rel="stylesheet" crossorigin href="/assets/index-vVHPhuT9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -5
View File
@@ -11,7 +11,7 @@ const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5; const RATE_LIMIT_MAX = 5;
const rateBuckets = new Map(); // socketId -> [timestamps] const rateBuckets = new Map(); // socketId -> [timestamps]
const PROFANITY_LIST = ['fuck', 'shit', 'bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick']; const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
const DUPLICATE_WINDOW_MS = 15000; const DUPLICATE_WINDOW_MS = 15000;
const lastMessageBySocket = new Map(); // socketId -> { text, ts } const lastMessageBySocket = new Map(); // socketId -> { text, ts }
@@ -95,10 +95,10 @@ function handleIncoming({ text } = {}, socket, cb = () => {}) {
cb({ error: 'Message blocked' }); cb({ error: 'Message blocked' });
return; return;
} }
if (isDuplicate(socket.id, clean)) { // if (isDuplicate(socket.id, clean)) {
cb({ error: 'Duplicate message' }); // cb({ error: 'Duplicate message' });
return; // return;
} // }
if (isKeymash(clean)) { if (isKeymash(clean)) {
cb({ error: 'Message looks like spam' }); cb({ error: 'Message looks like spam' });
return; return;
Binary file not shown.
Binary file not shown.
+19 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatContext.jsx'; import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
@@ -30,9 +30,15 @@ export default function ChatPanel() {
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const canChat = session?.role !== 'spectator'; const canChat = session?.role !== 'spectator';
const listRef = useRef(null);
const sorted = useMemo(() => messages.slice(-200), [messages]); const sorted = useMemo(() => messages.slice(-200), [messages]);
useEffect(() => {
if (!listRef.current) return;
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted]);
async function handleSend(event) { async function handleSend(event) {
event.preventDefault(); event.preventDefault();
if (!canChat) return; if (!canChat) return;
@@ -56,7 +62,7 @@ export default function ChatPanel() {
<span>Chat</span> <span>Chat</span>
<span className="text-xs text-slate-500">{sorted.length}</span> <span className="text-xs text-slate-500">{sorted.length}</span>
</div> */} </div> */}
<div className="surface h-48 overflow-y-auto space-y-0.5"> <div className="surface h-48 overflow-y-auto space-y-0.25" ref={listRef}>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<p className="text-sm text-slate-500">No messages yet.</p> <p className="text-sm text-slate-500">No messages yet.</p>
) : ( ) : (
@@ -66,16 +72,18 @@ export default function ChatPanel() {
return ( return (
<div <div
key={msg.id} key={msg.id}
className={`surface-muted text-sm ${isAdmin ? 'border border-amber-400/30' : ''}`} className={`surface-muted text-sm flex items-start gap-1 ${
isAdmin ? 'border border-amber-400/30' : ''
}`}
> >
<div className="flex items-center gap-1 text-[0.75rem] text-slate-400"> <span className="text-[0.75rem] text-slate-400">{formatTime(msg.ts)}</span>
<span>{formatTime(msg.ts)}</span> <span className={`font-semibold text-[0.85rem] ${roleColors(msg.role)}`}>
<span className={`font-semibold ${roleColors(msg.role)}`}>{displayName(msg)}</span> {displayName(msg)}
{msg.roverId && ( </span>
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span> {msg.roverId && (
)} <span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {msg.roverId}</span>
</div> )}
<p className="text-slate-100 break-words">{msg.text}</p> <span className="text-slate-100 break-words leading-tight">{msg.text}</span>
</div> </div>
); );
}) })
+8 -8
View File
@@ -100,7 +100,7 @@ export default function UserListPanel() {
<span>Users</span> <span>Users</span>
<span className="text-xs text-slate-500">{sorted.length}</span> <span className="text-xs text-slate-500">{sorted.length}</span>
</div> </div>
<div className="surface h-48 overflow-y-auto space-y-0.5"> <div className="surface h-48 overflow-y-auto space-y-0.25">
{sorted.length === 0 ? ( {sorted.length === 0 ? (
<p className="text-sm text-slate-500">Waiting for users</p> <p className="text-sm text-slate-500">Waiting for users</p>
) : ( ) : (
@@ -110,14 +110,14 @@ export default function UserListPanel() {
return ( return (
<div <div
key={user.socketId} key={user.socketId}
className="surface-muted flex items-center justify-between gap-0.5 text-sm" className="surface-muted flex items-center gap-1 text-sm"
> >
<div> <p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p> {user.roverId ? (
<p className="text-[0.7rem] text-slate-400"> <span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {user.roverId}</span>
{user.roverId ? `Driving ${user.roverId}` : 'No rover'} ) : (
</p> <span className="text-[0.7rem] text-slate-500">no rover</span>
</div> )}
{isAdmin && ( {isAdmin && (
<span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200"> <span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
Admin Admin
+15 -19
View File
@@ -157,25 +157,22 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
controls={false} controls={false}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
<HudOverlay frame={telemetryFrame} label={label}/> <HudOverlay frame={telemetryFrame} label={label} status={renderedStatus} />
<OvercurrentOverlay motors={overcurrentMotors} /> <OvercurrentOverlay motors={overcurrentMotors} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} /> <LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
</div> </div>
<BatteryBar charge={batteryCharge} config={batteryConfig} status={renderedStatus} /> <BatteryBar charge={batteryCharge} config={batteryConfig} />
</div> </div>
); );
} }
function BatteryBar({ charge, config, status }) { function BatteryBar({ charge, config }) {
const full = config?.Full; const full = config?.Full;
const warn = config?.Warn; const warn = config?.Warn;
const urgent = config?.Urgent ?? null; const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) { if (charge == null || full == null || warn == null) {
return ( return (
<div className="panel-section space-y-0.5 text-sm"> <div className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{status}</span>
</div>
<p className="text-xs text-slate-500">Battery telemetry unavailable</p> <p className="text-xs text-slate-500">Battery telemetry unavailable</p>
</div> </div>
); );
@@ -192,22 +189,18 @@ function BatteryBar({ charge, config, status }) {
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500'; const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
return ( return (
<div className="panel-section space-y-0.5 text-sm"> <div className="panel-section space-y-0.5 text-sm">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{status}</span>
</div>
<div className="relative h-4 w-full bg-zinc-900 flex"> <div className="relative h-4 w-full bg-zinc-900 flex">
<div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }}> <div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }}>
<span className="inset-0 flex items-center justify-center text-xs font-semibold text-black/80"> <span className="inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
Battery {percentText} Battery {percentText}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function HudOverlay({ frame, label }) { function HudOverlay({ frame, label, status }) {
const sensors = frame?.sensors; const sensors = frame?.sensors;
const bumps = sensors?.bumpsAndWheelDrops || {}; const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -221,18 +214,21 @@ function HudOverlay({ frame, label }) {
return ( return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-xs text-slate-100"> <div className="absolute left-1 top-1 bg-black/70 px-1 py-0.5 text-[0.65rem] font-medium text-slate-100">
<span>You are driving: {label || 'Unnamed Rover'}</span> <span>Status: {status}</span>
</div>
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
<span>Rover: "{label || 'Unnamed Rover'}"</span>
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */} {/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
</div> </div>
{/* bump and wheel drops bar */} {/* bump and wheel drops bar */}
<div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/70 px-0.5 py-0.5 text-[0.6rem] font-medium text-slate-200"> <div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-1 bg-black/70 px-0.5 py-0.5 text-[0.6rem] font-medium text-slate-200">
<div className={`px-0.5 py-0.5 ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div> <div className={`px-1 py-0.5 ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div>
<div className={`px-0.5 py-0.5 ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div> <div className={`px-1 py-0.5 ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div>
<div className={`px-0.5 py-0.5 ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div> <div className={`px-1 py-0.5 ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div>
<div className={`px-0.5 py-0.5 ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div> <div className={`px-1 py-0.5 ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div>
</div> </div>
</div> </div>
); );
@@ -281,4 +277,4 @@ function LowBatteryOverlay({ charge, config }) {
</div> </div>
</div> </div>
); );
} }
+19 -1
View File
@@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from './SocketContext.jsx'; import { useSocket } from './SocketContext.jsx';
import { useSession } from './SessionContext.jsx'; import { useSession } from './SessionContext.jsx';
import messageSound from '../assets/message.mp3';
const ChatContext = createContext({ const ChatContext = createContext({
messages: [], messages: [],
@@ -21,16 +22,33 @@ export function ChatProvider({ children }) {
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false); const [isChatFocused, setIsChatFocused] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
const audioRef = useRef(null);
useEffect(() => {
audioRef.current = new Audio(messageSound);
audioRef.current.load();
}, []);
const playSound = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
function handleMessage(payload = {}) { function handleMessage(payload = {}) {
setMessages((prev) => [...prev.slice(-99), payload]); setMessages((prev) => [...prev.slice(-99), payload]);
if (payload?.socketId && session?.socketId && payload.socketId === session.socketId) {
return;
}
playSound();
} }
socket.on('chat:message', handleMessage); socket.on('chat:message', handleMessage);
return () => { return () => {
socket.off('chat:message', handleMessage); socket.off('chat:message', handleMessage);
}; };
}, [socket]); }, [playSound, session?.socketId, socket]);
const sendMessage = useCallback( const sendMessage = useCallback(
(text) => (text) =>