// Replay Sources Panel // Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useEffect, useMemo, useState } from 'react'; import { useSession } from '../../context/SessionContext.jsx'; import { useSettingsNamespace } from '../../settings/index.js'; import { roverNameChromeStyle } from '../../lib/roverColor.js'; function normalizeSources(list = []) { return list .map((entry) => { if (!entry?.type || !entry?.id) return null; return { type: entry.type, id: String(entry.id), label: entry.label || String(entry.id), color: entry.color || null, key: `${entry.type}:${entry.id}`, }; }) .filter(Boolean); } export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { const { session, triggerReplay } = useSession(); const sources = normalizeSources(session?.replaySources || []); const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {}); const [selected, setSelected] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [title, setTitle] = useState(''); const [titleDirty, setTitleDirty] = useState(false); const [includeSidebar, setIncludeSidebar] = useState(true); const replayState = session?.replay || null; const [remainingMs, setRemainingMs] = useState(0); const defaults = useMemo(() => { const roverId = session?.assignment?.roverId; if (roverId) { return [`rover:${roverId}`]; } return []; }, [session?.assignment?.roverId]); const defaultTitle = useMemo(() => { const self = Array.isArray(session?.users) ? session.users.find((entry) => entry?.socketId === session?.socketId) : null; const nickname = (self?.nickname || 'Someone').trim() || 'Someone'; const roverId = session?.assignment?.roverId || null; const roverName = roverId && Array.isArray(session?.roster) ? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId : 'a rover'; return `${nickname} driving ${roverName}`; }, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]); useEffect(() => { const saved = settings?.[panelId]; if (Array.isArray(saved) && saved.length) { setSelected(saved); return; } setSelected(defaults); }, [settings?.[panelId], defaults, panelId]); useEffect(() => { const saved = settings?.[`${panelId}:includeSidebar`]; if (typeof saved === 'boolean') { setIncludeSidebar(saved); return; } setIncludeSidebar(true); }, [settings?.[`${panelId}:includeSidebar`], panelId]); useEffect(() => { if (!titleDirty) { setTitle(defaultTitle); } }, [defaultTitle, titleDirty]); useEffect(() => { const allowed = new Set(sources.map((source) => source.key)); setSelected((prev) => prev.filter((key) => allowed.has(key))); }, [sources]); const grouped = useMemo(() => { return { rovers: sources.filter((source) => source.type === 'rover'), rooms: sources.filter((source) => source.type === 'room'), }; }, [sources]); useEffect(() => { if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) { setRemainingMs(0); return undefined; } const update = () => { const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now(); setRemainingMs(Math.max(0, next)); }; update(); const interval = setInterval(update, 250); return () => clearInterval(interval); }, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]); const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length; const toggleKey = (key) => { setSelected((prev) => { const next = prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; saveSettings((current) => ({ ...(current || {}), [panelId]: next })); return next; }); setSuccess(null); }; const handleReplay = async () => { if (replayDisabled) return; setBusy(true); setError(null); setSuccess(null); try { const payload = selected.map((key) => { const [type, id] = key.split(':'); return { type, id }; }); const resolvedTitle = String(title || '').trim() || defaultTitle; await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar }); setSuccess('Replay sent. Check the Discord replay channel.'); } catch (err) { setError(err.message); } finally { setBusy(false); } }; const containerClass = fillHeight ? 'h-full flex flex-col' : ''; const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : ''; return (
Replay Sources {sources.length}
{ setTitle(event.target.value); setTitleDirty(true); }} placeholder={defaultTitle} maxLength={120} /> {error ?
{error}
: null} {success ?
{success}
: null}
); } function GroupList({ title, items, selected, onToggle }) { if (!items.length) return null; return (
{title}
{items.map((item) => ( ))}
); }