new chat layout and stuff

This commit is contained in:
legop3
2026-01-15 14:43:55 -05:00
parent 5b9167ab2a
commit d184590bf2
12 changed files with 258 additions and 180 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
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-5t6wOuB4.js"></script> <script type="module" crossorigin src="/assets/index-BNq14-R7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BY_dmM98.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cmh8Rw7S.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -102,7 +102,7 @@ function MobileFeatureTabs({
</TabPanel> </TabPanel>
<TabPanel id="roomcontrols"> <TabPanel id="roomcontrols">
<div className="space-y-0.5"> <div className="space-y-0.5">
{showTelemetry ? <TelemetryPanel /> : null} {/* {showTelemetry ? <TelemetryPanel /> : null} */}
<HomeAssistantControls /> <HomeAssistantControls />
<RoomCameraPanel panelId={roomPanelId} /> <RoomCameraPanel panelId={roomPanelId} />
</div> </div>
+4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import ChatMessageRow from './ChatMessageRow.jsx';
const LIFETIME_MS = 5000; const LIFETIME_MS = 5000;
const DEFAULT_COLOR = '#2196f3'; const DEFAULT_COLOR = '#2196f3';
@@ -52,6 +53,9 @@ function hexToRgb(hex) {
} }
function AlertToast({ alert }) { function AlertToast({ alert }) {
if (alert.kind === 'chat' && alert.payload) {
return <ChatMessageRow message={alert.payload} />;
}
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR); const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.25)` : 'rgba(33, 150, 243, 0.25)'; const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.25)` : 'rgba(33, 150, 243, 0.25)';
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7)` : 'rgba(33, 150, 243, 0.7)'; const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7)` : 'rgba(33, 150, 243, 0.7)';
+99
View File
@@ -0,0 +1,99 @@
import { FaDiscord } from 'react-icons/fa';
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';
}
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<span
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
title={label}
>
<span
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-cover' : ''}`}
style={
guildIconUrl
? {
backgroundImage: `url(${guildIconUrl})`,
backgroundPosition: 'left center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
<span
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-cover' : ''}`}
style={
userAvatarUrl
? {
backgroundImage: `url(${userAvatarUrl})`,
backgroundPosition: 'right center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
</span>
);
}
export default function ChatMessageRow({ message }) {
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
return (
<div
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
isAdmin
? 'border border-amber-400/30'
: message.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
{message.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : null}
<span className={`font-semibold text-[0.85rem] ${roleColors(message.role)}`}>
{displayName(message)}
</span>
{message.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {message.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
{formatTime(message.ts)}
</span>
</div>
);
}
+9 -99
View File
@@ -1,65 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { FaDiscord } from 'react-icons/fa';
import { useChat } from '../context/ChatContext.jsx'; import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import ChatMessageRow from './ChatMessageRow.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';
}
function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
if (!guildIconUrl && !userAvatarUrl) return null;
return (
<span
className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80"
title={label}
>
<span
className={`h-full w-1/2 bg-slate-700/70 ${guildIconUrl ? 'bg-cover' : ''}`}
style={
guildIconUrl
? {
backgroundImage: `url(${guildIconUrl})`,
backgroundPosition: 'left center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
<span
className={`h-full w-1/2 bg-slate-700/70 ${userAvatarUrl ? 'bg-cover' : ''}`}
style={
userAvatarUrl
? {
backgroundImage: `url(${userAvatarUrl})`,
backgroundPosition: 'right center',
backgroundSize: '200% 100%',
}
: undefined
}
/>
</span>
);
}
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl']; const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10); const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
@@ -146,46 +89,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
{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>
) : ( ) : (
sorted.map((msg) => { sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
const isAdmin =
msg.role === 'admin' || msg.role === 'lockdown' || msg.role === 'lockdown-admin';
const discordLabel = msg.fromDiscord
? `${msg.discordGuildName || 'Discord'} · ${displayName(msg)}`
: null;
return (
<div
key={msg.id}
className={`surface-muted relative flex flex-wrap items-start gap-1 text-sm ${
isAdmin
? 'border border-amber-400/30'
: msg.fromDiscord
? 'border border-indigo-400/30 bg-indigo-900/20'
: ''
}`}
>
{msg.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={msg.discordGuildIconUrl}
userAvatarUrl={msg.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : null}
<span className={`font-semibold text-[0.85rem] ${roleColors(msg.role)}`}>
{displayName(msg)}
</span>
{msg.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{msg.roverId}</span>
)}
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{msg.text}</span>
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
{formatTime(msg.ts)}
</span>
</div>
);
})
)} )}
</div> </div>
{!hideInput && ( {!hideInput && (
@@ -196,7 +100,13 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onFocus={onInputFocus} onFocus={onInputFocus}
onBlur={onInputBlur} onBlur={onInputBlur}
ref={(el) => registerInputRef(el)} onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
}
}}
ref={(el) => registerInputRef(el, { target: 'panel' })}
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'} placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat} disabled={!canChat}
/> />
+110 -56
View File
@@ -2,20 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { WhepPlayer } from '../lib/whepPlayer.js'; import { WhepPlayer } from '../lib/whepPlayer.js';
import TopDownMap from './TopDownMap.jsx'; import TopDownMap from './TopDownMap.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js'; import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
const RESTART_DELAY_MS = 2000; const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000; const UNMUTE_RETRY_MS = 3000;
const AUDIO_RETRY_MS = 3000; const AUDIO_RETRY_MS = 3000;
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function formatNoteLabel(note) {
if (typeof note !== 'number' || !Number.isFinite(note)) return '--';
const name = NOTE_NAMES[note % 12] || '?';
const octave = Math.floor(note / 12) - 1;
return `${name}${octave}`;
}
function buildBatteryVisual(charge, config) { function buildBatteryVisual(charge, config) {
const full = config?.Full; const full = config?.Full;
const warn = config?.Warn; const warn = config?.Warn;
@@ -55,7 +48,6 @@ export default function VideoTile({
layoutFormat = 'desktop', layoutFormat = 'desktop',
hudVariant = 'default', hudVariant = 'default',
driverLabel = null, driverLabel = null,
songNote = null,
hudForceMap = false, hudForceMap = false,
hudMapPosition = 'top-right', hudMapPosition = 'top-right',
fitParent = false, fitParent = false,
@@ -405,18 +397,22 @@ export default function VideoTile({
variant={hudVariant} variant={hudVariant}
driverLabel={driverLabel} driverLabel={driverLabel}
battery={batteryVisual} battery={batteryVisual}
songNote={songNote}
showTopDown={showHudMap} showTopDown={showHudMap}
mobileHud={mobileHud} mobileHud={mobileHud}
mapPosition={hudMapPosition} mapPosition={hudMapPosition}
/> />
<OvercurrentOverlay motors={overcurrentMotors} /> <HudChatInput compact={mobileHud} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} /> <OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
{showVerticalBattery && batteryVisual.available ? ( {showVerticalBattery && batteryVisual.available ? (
<BatteryBarVertical visual={batteryVisual} /> <BatteryBarVertical visual={batteryVisual} />
) : null} ) : null}
{qualityNotice ? ( {qualityNotice ? (
<div className="pointer-events-none absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-xs font-semibold text-amber-200"> <div
className={`pointer-events-none absolute rounded bg-black/70 font-semibold text-amber-200 ${
mobileHud ? 'left-0.5 top-0.5 px-0.5 py-0.25 text-[0.55rem]' : 'left-1 top-1 px-1 py-0.5 text-xs'
}`}
>
{qualityNotice} {qualityNotice}
</div> </div>
) : null} ) : null}
@@ -527,7 +523,6 @@ function HudOverlay({
variant = 'default', variant = 'default',
driverLabel = null, driverLabel = null,
battery, battery,
songNote = null,
showTopDown = false, showTopDown = false,
mobileHud = false, mobileHud = false,
mapPosition = 'top-right', mapPosition = 'top-right',
@@ -543,10 +538,13 @@ function HudOverlay({
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false; const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
const isMobile = mobileHud; const isMobile = mobileHud;
const portraitMobile = layoutFormat === 'mobile-portrait'; const portraitMobile = layoutFormat === 'mobile-portrait';
const statusTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.65rem]'; const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
const statusPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5'; const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-0.5 py-0.5'; const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.7rem]' : 'text-[0.8rem]'; const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const mapSize = '240px'; const mapSize = '240px';
const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7; const mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
const mapOpacity = isMobile ? 0.85 : 0.7; const mapOpacity = isMobile ? 0.85 : 0.7;
@@ -570,25 +568,17 @@ function HudOverlay({
]; ];
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 <div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`} <div className="flex flex-col gap-[1px] leading-none">
> <span>Status: {status}</span>
<span>Status: {status}</span> {audioStatus ? <span>Audio: {audioStatus}</span> : null}
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</div>
{songNote != null ? (
<div
className={`absolute right-1 top-1 rounded bg-black/70 font-semibold text-emerald-200 ${statusTextClass} ${statusPadClass}`}
>
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
</div> </div>
) : null} </div>
<div <div
className={`absolute left-1 top-1/2 flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`} className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.35 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
> >
<div className="space-y-0.1 leading-tight"> <div className="space-y-0.1 leading-tight">
<span className={`${isMobile ? 'text-[0.55rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}> <span className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}>
Telemetry Telemetry
</span> </span>
{telemetryEntries.map(([labelText, value]) => ( {telemetryEntries.map(([labelText, value]) => (
@@ -617,7 +607,7 @@ function HudOverlay({
</div> </div>
<div <div
className={`absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`} className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
> >
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span> <span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null} {driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
@@ -628,22 +618,14 @@ function HudOverlay({
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 <div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`} <div className="flex flex-col gap-[1px] leading-none">
> <span>Status: {status}</span>
<span>Status: {status}</span> {audioStatus ? <span>Audio: {audioStatus}</span> : null}
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</div>
{songNote != null ? (
<div
className={`absolute bottom-1 right-1 rounded bg-black/70 font-semibold text-emerald-200 ${statusTextClass} ${statusPadClass}`}
>
Song {formatNoteLabel(songNote)} <span className="text-slate-400">({songNote})</span>
</div> </div>
) : null} </div>
<div <div
className={`absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`} className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
> >
<span>Rover: "{label || 'Unnamed Rover'}"</span> <span>Rover: "{label || 'Unnamed Rover'}"</span>
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */} {/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
@@ -672,21 +654,26 @@ const OVERCURRENT_LABELS = {
sideBrush: 'Side brush', sideBrush: 'Side brush',
}; };
function OvercurrentOverlay({ motors }) { function OvercurrentOverlay({ motors, compact = false }) {
if (!motors?.length) return null; if (!motors?.length) return null;
const labels = motors.map((name) => OVERCURRENT_LABELS[name] || name); const labels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'p-2' : 'p-4';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
return ( return (
<div className="pointer-events-none absolute flex items-center justify-center bg-red-900/60 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-4"> <div
<div className="text-center text-4xl font-semibold text-white animate-pulse"> className={`pointer-events-none absolute flex items-center justify-center bg-red-900/60 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>OVERCURRENT</div> <div>OVERCURRENT</div>
<div className="mt-0.5 text-xl font-medium text-white">{labels.join(', ')}</div> <div className={`mt-0.5 font-medium text-white ${subTextClass}`}>{labels.join(', ')}</div>
</div> </div>
</div> </div>
); );
} }
// low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent. // low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent.
function LowBatteryOverlay({ charge, config }) { function LowBatteryOverlay({ charge, config, compact = false }) {
if (charge == null || config == null) return null; if (charge == null || config == null) return null;
const full = config.Full; const full = config.Full;
const warn = config.Warn; const warn = config.Warn;
@@ -701,11 +688,78 @@ function LowBatteryOverlay({ charge, config }) {
const message = depleted ? 'Battery low! please dock and charge the rover soon.' : 'BATTERY VERY LOW, PLEASE DOCK THE ROVER AND CHARGE IMMEDIATELY!!'; const message = depleted ? 'Battery low! please dock and charge the rover soon.' : 'BATTERY VERY LOW, PLEASE DOCK THE ROVER AND CHARGE IMMEDIATELY!!';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return ( return (
<div className="pointer-events-none absolute flex items-center justify-center bg-amber-900/60 top-10 left-1/2 -translate-x-1/2 p-4"> <div
<div className="text-center text-2xl font-semibold text-white animate-pulse"> className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div> <div>{message}</div>
</div> </div>
</div> </div>
); );
} }
function HudChatInput({ compact = false }) {
const { session } = useSession();
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef } = useChat();
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const canChat = session?.role !== 'spectator';
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.25 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const inputClass = compact
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
const buttonClass = compact
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
if (!canChat) return;
const clean = draft.trim();
if (!clean) return;
setSending(true);
try {
await sendMessage(clean, null);
setDraft('');
blurChat();
} catch (err) {
alert(err.message);
} finally {
setSending(false);
}
}
return (
<form onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={onInputFocus}
onBlur={onInputBlur}
onKeyDown={(event) => {
if (event.key === 'Enter' && !draft.trim()) {
event.preventDefault();
blurChat();
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat…' : 'Spectator'}
disabled={!canChat}
/>
<button
type="submit"
disabled={!canChat || sending}
className={buttonClass}
>
Send
</button>
</form>
);
}
+19 -6
View File
@@ -18,10 +18,11 @@ const ChatContext = createContext({
export function ChatProvider({ children }) { export function ChatProvider({ children }) {
const socket = useSocket(); const socket = useSocket();
const { session } = useSession(); const { session, pushAlert } = useSession();
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false); const [isChatFocused, setIsChatFocused] = useState(false);
const inputRef = useRef(null); const panelInputRef = useRef(null);
const hudInputRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -43,6 +44,12 @@ export function ChatProvider({ children }) {
return; return;
} }
playSound(); playSound();
pushAlert?.({
kind: 'chat',
payload,
id: `chat-${payload.id || Math.random().toString(36).slice(2)}`,
receivedAt: Date.now(),
});
} }
socket.on('chat:message', handleMessage); socket.on('chat:message', handleMessage);
return () => { return () => {
@@ -82,18 +89,24 @@ export function ChatProvider({ children }) {
[socket], [socket],
); );
const registerInputRef = useCallback((el) => { const registerInputRef = useCallback((el, options = {}) => {
inputRef.current = el; const target = options?.target === 'hud' ? 'hud' : 'panel';
if (target === 'hud') {
hudInputRef.current = el;
} else {
panelInputRef.current = el;
}
}, []); }, []);
const focusChat = useCallback(() => { const focusChat = useCallback(() => {
setIsChatFocused(true); setIsChatFocused(true);
inputRef.current?.focus(); (hudInputRef.current || panelInputRef.current)?.focus();
}, []); }, []);
const blurChat = useCallback(() => { const blurChat = useCallback(() => {
setIsChatFocused(false); setIsChatFocused(false);
inputRef.current?.blur(); hudInputRef.current?.blur();
panelInputRef.current?.blur();
}, []); }, []);
const onInputFocus = useCallback(() => setIsChatFocused(true), []); const onInputFocus = useCallback(() => setIsChatFocused(true), []);
@@ -351,9 +351,7 @@ export default function KeyboardInputManager() {
if (bindingActive(keymap.chatFocus, tokenSet)) { if (bindingActive(keymap.chatFocus, tokenSet)) {
event.preventDefault(); event.preventDefault();
resetAll(); resetAll();
if (isChatFocused) { if (!isChatFocused) {
blurChat();
} else {
focusChat(); focusChat();
} }
return; return;