mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
new chat layout and stuff
This commit is contained in:
+1
-1
@@ -102,7 +102,7 @@ function MobileFeatureTabs({
|
||||
</TabPanel>
|
||||
<TabPanel id="roomcontrols">
|
||||
<div className="space-y-0.5">
|
||||
{showTelemetry ? <TelemetryPanel /> : null}
|
||||
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
|
||||
<HomeAssistantControls />
|
||||
<RoomCameraPanel panelId={roomPanelId} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||
|
||||
const LIFETIME_MS = 5000;
|
||||
const DEFAULT_COLOR = '#2196f3';
|
||||
@@ -52,6 +53,9 @@ function hexToRgb(hex) {
|
||||
}
|
||||
|
||||
function AlertToast({ alert }) {
|
||||
if (alert.kind === 'chat' && alert.payload) {
|
||||
return <ChatMessageRow message={alert.payload} />;
|
||||
}
|
||||
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 borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7)` : 'rgba(33, 150, 243, 0.7)';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaDiscord } from 'react-icons/fa';
|
||||
import { useChat } from '../context/ChatContext.jsx';
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
import ChatMessageRow from './ChatMessageRow.jsx';
|
||||
|
||||
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
|
||||
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 ? (
|
||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||
) : (
|
||||
sorted.map((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>
|
||||
);
|
||||
})
|
||||
sorted.map((msg) => <ChatMessageRow key={msg.id} message={msg} />)
|
||||
)}
|
||||
</div>
|
||||
{!hideInput && (
|
||||
@@ -196,7 +100,13 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
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'}
|
||||
disabled={!canChat}
|
||||
/>
|
||||
|
||||
@@ -2,20 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||
import TopDownMap from './TopDownMap.jsx';
|
||||
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||
import { useChat } from '../context/ChatContext.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
|
||||
const RESTART_DELAY_MS = 2000;
|
||||
const UNMUTE_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) {
|
||||
const full = config?.Full;
|
||||
const warn = config?.Warn;
|
||||
@@ -55,7 +48,6 @@ export default function VideoTile({
|
||||
layoutFormat = 'desktop',
|
||||
hudVariant = 'default',
|
||||
driverLabel = null,
|
||||
songNote = null,
|
||||
hudForceMap = false,
|
||||
hudMapPosition = 'top-right',
|
||||
fitParent = false,
|
||||
@@ -405,18 +397,22 @@ export default function VideoTile({
|
||||
variant={hudVariant}
|
||||
driverLabel={driverLabel}
|
||||
battery={batteryVisual}
|
||||
songNote={songNote}
|
||||
showTopDown={showHudMap}
|
||||
mobileHud={mobileHud}
|
||||
mapPosition={hudMapPosition}
|
||||
/>
|
||||
<OvercurrentOverlay motors={overcurrentMotors} />
|
||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
|
||||
<HudChatInput compact={mobileHud} />
|
||||
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
|
||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
|
||||
{showVerticalBattery && batteryVisual.available ? (
|
||||
<BatteryBarVertical visual={batteryVisual} />
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -527,7 +523,6 @@ function HudOverlay({
|
||||
variant = 'default',
|
||||
driverLabel = null,
|
||||
battery,
|
||||
songNote = null,
|
||||
showTopDown = false,
|
||||
mobileHud = false,
|
||||
mapPosition = 'top-right',
|
||||
@@ -543,10 +538,13 @@ function HudOverlay({
|
||||
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
|
||||
const isMobile = mobileHud;
|
||||
const portraitMobile = layoutFormat === 'mobile-portrait';
|
||||
const statusTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.65rem]';
|
||||
const statusPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
||||
const labelPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-0.5 py-0.5';
|
||||
const labelTextClass = isMobile ? 'text-[0.7rem]' : 'text-[0.8rem]';
|
||||
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
||||
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
||||
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
||||
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 mapScale = portraitMobile ? 0.36 : isMobile ? 0.45 : 0.7;
|
||||
const mapOpacity = isMobile ? 0.85 : 0.7;
|
||||
@@ -570,25 +568,17 @@ function HudOverlay({
|
||||
];
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||
>
|
||||
<span>Status: {status}</span>
|
||||
{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 className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||
<div className="flex flex-col gap-[1px] leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</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">
|
||||
<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
|
||||
</span>
|
||||
{telemetryEntries.map(([labelText, value]) => (
|
||||
@@ -617,7 +607,7 @@ function HudOverlay({
|
||||
</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>
|
||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||
@@ -628,22 +618,14 @@ function HudOverlay({
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={`absolute left-1 top-1 bg-black/70 font-medium text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||
>
|
||||
<span>Status: {status}</span>
|
||||
{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 className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||
<div className="flex flex-col gap-[1px] leading-none">
|
||||
<span>Status: {status}</span>
|
||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</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>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||
@@ -672,21 +654,26 @@ const OVERCURRENT_LABELS = {
|
||||
sideBrush: 'Side brush',
|
||||
};
|
||||
|
||||
function OvercurrentOverlay({ motors }) {
|
||||
function OvercurrentOverlay({ motors, compact = false }) {
|
||||
if (!motors?.length) return null;
|
||||
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 (
|
||||
<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 className="text-center text-4xl font-semibold text-white animate-pulse">
|
||||
<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 ${containerClass}`}
|
||||
>
|
||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
const full = config.Full;
|
||||
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 containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
||||
const textClass = compact ? 'text-sm' : 'text-2xl';
|
||||
|
||||
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 className="text-center text-2xl font-semibold text-white animate-pulse">
|
||||
<div
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ const ChatContext = createContext({
|
||||
|
||||
export function ChatProvider({ children }) {
|
||||
const socket = useSocket();
|
||||
const { session } = useSession();
|
||||
const { session, pushAlert } = useSession();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const panelInputRef = useRef(null);
|
||||
const hudInputRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,6 +44,12 @@ export function ChatProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
playSound();
|
||||
pushAlert?.({
|
||||
kind: 'chat',
|
||||
payload,
|
||||
id: `chat-${payload.id || Math.random().toString(36).slice(2)}`,
|
||||
receivedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
socket.on('chat:message', handleMessage);
|
||||
return () => {
|
||||
@@ -82,18 +89,24 @@ export function ChatProvider({ children }) {
|
||||
[socket],
|
||||
);
|
||||
|
||||
const registerInputRef = useCallback((el) => {
|
||||
inputRef.current = el;
|
||||
const registerInputRef = useCallback((el, options = {}) => {
|
||||
const target = options?.target === 'hud' ? 'hud' : 'panel';
|
||||
if (target === 'hud') {
|
||||
hudInputRef.current = el;
|
||||
} else {
|
||||
panelInputRef.current = el;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const focusChat = useCallback(() => {
|
||||
setIsChatFocused(true);
|
||||
inputRef.current?.focus();
|
||||
(hudInputRef.current || panelInputRef.current)?.focus();
|
||||
}, []);
|
||||
|
||||
const blurChat = useCallback(() => {
|
||||
setIsChatFocused(false);
|
||||
inputRef.current?.blur();
|
||||
hudInputRef.current?.blur();
|
||||
panelInputRef.current?.blur();
|
||||
}, []);
|
||||
|
||||
const onInputFocus = useCallback(() => setIsChatFocused(true), []);
|
||||
|
||||
@@ -351,9 +351,7 @@ export default function KeyboardInputManager() {
|
||||
if (bindingActive(keymap.chatFocus, tokenSet)) {
|
||||
event.preventDefault();
|
||||
resetAll();
|
||||
if (isChatFocused) {
|
||||
blurChat();
|
||||
} else {
|
||||
if (!isChatFocused) {
|
||||
focusChat();
|
||||
}
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user