redoing dock your rover popup thingy
Container image / image (push) Failing after 56s

This commit is contained in:
legop3
2026-10-09 17:27:55 -04:00
parent ed26b90384
commit 1ffe680895
6 changed files with 124 additions and 121 deletions
+21 -7
View File
@@ -187,6 +187,7 @@ function buildSession(socket) {
socketId: socket?.id || null,
role: getRole(socket),
operatingMode: getOperatingMode(socket),
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
mode: getMode(),
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
@@ -263,10 +264,14 @@ function buildSession(socket) {
};
}
// Cache only the published decision so sensor frames send updates on transitions.
const leavePermissionBySocket = new WeakMap();
function syncSocket(socket) {
if (!socket) return;
const payload = buildSession(socket);
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
leavePermissionBySocket.set(socket, payload.canLeaveRover);
socket.emit('session:sync', payload);
}
@@ -287,6 +292,15 @@ io.on('connection', (socket) => {
syncSocket(socket);
});
managerEvents.on('sensor', ({ roverId }) => {
io.sockets.sockets.forEach((socket) => {
const decision = roverManager.canLeaveCurrentRover(socket);
if (decision.currentId !== roverId) return;
if (leavePermissionBySocket.get(socket) === decision.ok) return;
syncSocket(socket);
});
});
operatingModeEvents.on('change', () => syncAll());
roleEvents.on('change', ({ socket }) => {
@@ -365,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
syncAll();
});
managerEvents.on('driver', ({ socketId }) => {
if (!socketId) return;
const socket = io.sockets.sockets.get(socketId);
if (socket) {
logger.info('Driver assignment change; syncing session', socketId);
syncSocket(socket);
}
// Update the changed driver and anyone whose departure permission depends on them.
managerEvents.on('driver', ({ socketId, roverId }) => {
io.sockets.sockets.forEach((socket) => {
if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
syncSocket(socket);
}
});
});
turnEvents.on('activeDriver', () => {
@@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) {
);
}
function UndockedExitWarningToast() {
return (
<div
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
export default function AlertContent({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { useBlocker } from 'react-router-dom';
import { useSessionSelector } from '../../context/SessionContext.jsx';
export default function PageExitGuard() {
const shouldBlock = useSessionSelector((state) => state.session?.canLeaveRover === false);
const [warning, setWarning] = useState({ blocked: shouldBlock, visible: false });
// Clear an old exit attempt when server permission changes, so undocking
// again does not resurrect a warning from the previous trip.
if (warning.blocked !== shouldBlock) {
setWarning({ blocked: shouldBlock, visible: false });
}
const blocker = useBlocker(({ currentLocation, nextLocation }) => shouldBlock && (
currentLocation.pathname !== nextLocation.pathname || currentLocation.search !== nextLocation.search
));
useEffect(() => {
if (!shouldBlock) return undefined;
function handleBeforeUnload(event) {
// Commit before the native dialog pauses JavaScript. The browser still
// decides whether it repaints the document before displaying its dialog.
flushSync(() => setWarning({ blocked: true, visible: true }));
event.preventDefault();
event.returnValue = true;
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [shouldBlock]);
useEffect(() => {
// Docking cancels the attempted navigation; the next attempt can proceed.
if (!shouldBlock && blocker.state === 'blocked') blocker.reset();
}, [shouldBlock, blocker]);
const visible = shouldBlock && (warning.visible || blocker.state === 'blocked');
if (!visible) return null;
function dismiss() {
setWarning({ blocked: shouldBlock, visible: false });
if (blocker.state === 'blocked') blocker.reset();
}
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="page-exit-warning"
className="fixed inset-0 z-[2147483647] flex flex-col items-center justify-center gap-10 bg-neutral-950 p-6 text-center text-white"
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Escape') dismiss();
}}
>
<p id="page-exit-warning" className="text-[clamp(3rem,10vw,10rem)] font-bold leading-tight">
Please dock your rover
</p>
<button type="button" autoFocus className="button-dark px-8 py-4 text-3xl" onClick={dismiss}>
Okay
</button>
</div>,
document.body,
);
}
@@ -1,66 +0,0 @@
// Undocked Page Exit Guard
// Purpose: Asks the browser to confirm document-level navigation while the current driver's rover is undocked.
// Scope: Owns the native beforeunload lifecycle and queues the matching AlertFeed explanation when leaving is attempted.
import { useEffect } from 'react';
import { useControlSelector } from '../../controls/index.js';
import { useSessionActions } from '../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
const UNDOCKED_EXIT_ALERT_LIFETIME_MS = 15 * 1000;
function selectHomeBaseState(frame) {
const homeBase = frame?.sensors?.chargingSources?.homeBase;
/*
Missing telemetry must remain distinct from a real "not on the home base"
reading. Treating the initial empty frame as undocked would enable the exit
warning before the browser has received enough information to justify it.
*/
return typeof homeBase === 'boolean' ? homeBase : null;
}
export default function UndockedPageExitGuard() {
const roverId = useControlSelector((control) => control.state.roverId);
const homeBaseState = useTelemetrySelector(roverId, selectHomeBaseState);
const { pushAlert } = useSessionActions();
const shouldConfirmExit = Boolean(roverId) && homeBaseState === false;
useEffect(() => {
if (!shouldConfirmExit) return undefined;
function handleBeforeUnload(event) {
/*
Browsers intentionally own the wording and presentation of this dialog.
preventDefault is the modern signal, while assigning returnValue keeps
the confirmation working in browsers that still require the legacy part
of the beforeunload contract.
*/
event.preventDefault();
event.returnValue = true;
/*
If the driver cancels the browser-owned confirmation, React remains
mounted and AlertFeed presents the actionable reason for the warning.
A stable id refreshes this one warning lane on another exit attempt
instead of stacking duplicate cards over the driving interface.
*/
pushAlert({
id: 'undocked-page-exit-warning',
kind: 'undocked-exit-warning',
lifetimeMs: UNDOCKED_EXIT_ALERT_LIFETIME_MS,
});
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
/*
Removing the listener as soon as the rover docks or the assignment ends
prevents ordinary spectators and completed drivers from seeing a stale
leave-page confirmation.
*/
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [pushAlert, shouldConfirmExit]);
return null;
}
-2
View File
@@ -28,7 +28,6 @@ import useLayoutMode from '../hooks/useLayoutMode.js';
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
/* Driver-page compositions live under layouts/driver. App retains only global providers, overlays, and route-level state. */
function DriverPageRoot({ oldDesktop = false }) {
@@ -92,7 +91,6 @@ function DriverPageContent({ layout, oldDesktop }) {
return (
<ControlSystemProvider>
<RoomAudioPlayer />
<UndockedPageExitGuard />
<KeyboardInputManager />
<GamepadInputManager />
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
+39 -29
View File
@@ -2,7 +2,7 @@
// Purpose: Boots the React application and mounts global providers/router roots. Scope: Defines top-level route wiring and root render lifecycle for the browser app.
import { lazy, StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { createBrowserRouter, Route, RouterProvider, Routes } from 'react-router-dom'
import './index.css'
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
@@ -22,6 +22,7 @@ import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import MicApp from './mic/MicApp.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import PageExitGuard from './components/PageExitGuard/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
@@ -41,6 +42,42 @@ function lazyRoute(element, label) {
)
}
// A data router provides one blocker for links, programmatic navigation, and history.
const router = createBrowserRouter([{
path: '*',
element: (
<>
<AnalyticsReporter />
<OperatingModeRouteSync />
<PageExitGuard />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
without duplicating the driver shell or changing canonical mobile layouts. */}
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/mic" element={<MicApp />} />
<Route path="/scanner" element={<ScannerApp />} />
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
<Route
path="/reports"
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
/>
</Routes>
</>
),
}])
createRoot(document.getElementById('root')).render(
<AppErrorBoundary>
<StrictMode>
@@ -55,34 +92,7 @@ createRoot(document.getElementById('root')).render(
<SettingsProvider>
<ChatProvider>
<DeterrenceChaos />
<BrowserRouter>
<AnalyticsReporter />
<OperatingModeRouteSync />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
without duplicating the driver shell or changing canonical mobile layouts. */}
<Route path="/old" element={<DriverPageRoot oldDesktop />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/mic" element={<MicApp />} />
<Route path="/scanner" element={<ScannerApp />} />
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
{/*
PTZ is a separate route so the driver layout and its replay
panel are not mounted behind the camera controller. This
also makes orientation changes a PTZ layout concern instead
of a local overlay-open state owned by the driver page.
*/}
<Route path="/ptz" element={<PtzAppRoot />} />
<Route
path="/reports"
element={lazyRoute(<FleetReportsApp />, 'fleet reports')}
/>
</Routes>
</BrowserRouter>
<RouterProvider router={router} />
</ChatProvider>
</SettingsProvider>
</TelemetryProvider>