From 1ffe680895e4289e7c60fcd1435497d2e738246d Mon Sep 17 00:00:00 2001 From: legop3 Date: Fri, 9 Oct 2026 17:27:55 -0400 Subject: [PATCH] redoing dock your rover popup thingy --- server/src/services/sessionService/index.js | 28 ++++++-- .../src/components/AlertFeed/AlertContent.jsx | 17 ----- webui/src/components/PageExitGuard/index.jsx | 64 +++++++++++++++++ .../UndockedPageExitGuard/index.jsx | 66 ------------------ webui/src/driver/DriverPageRoot.jsx | 2 - webui/src/main.jsx | 68 +++++++++++-------- 6 files changed, 124 insertions(+), 121 deletions(-) create mode 100644 webui/src/components/PageExitGuard/index.jsx delete mode 100644 webui/src/components/UndockedPageExitGuard/index.jsx diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index 3bc4fcbe..1cb1f82c 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -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', () => { diff --git a/webui/src/components/AlertFeed/AlertContent.jsx b/webui/src/components/AlertFeed/AlertContent.jsx index 874f0072..4418198d 100644 --- a/webui/src/components/AlertFeed/AlertContent.jsx +++ b/webui/src/components/AlertFeed/AlertContent.jsx @@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) { ); } -function UndockedExitWarningToast() { - return ( -
-

Please dock your rover

-

- Your rover is still undocked. Please dock it before leaving the page. -

-
- ); -} - 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 ; } - if (alert.kind === 'undocked-exit-warning') { - return ; - } const color = typeof alert.color === 'string' ? alert.color.trim() : ''; const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3'; diff --git a/webui/src/components/PageExitGuard/index.jsx b/webui/src/components/PageExitGuard/index.jsx new file mode 100644 index 00000000..f9450d54 --- /dev/null +++ b/webui/src/components/PageExitGuard/index.jsx @@ -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( +
{ + event.stopPropagation(); + if (event.key === 'Escape') dismiss(); + }} + > +

+ Please dock your rover +

+ +
, + document.body, + ); +} diff --git a/webui/src/components/UndockedPageExitGuard/index.jsx b/webui/src/components/UndockedPageExitGuard/index.jsx deleted file mode 100644 index df83f0c6..00000000 --- a/webui/src/components/UndockedPageExitGuard/index.jsx +++ /dev/null @@ -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; -} diff --git a/webui/src/driver/DriverPageRoot.jsx b/webui/src/driver/DriverPageRoot.jsx index daefc339..4daebbc7 100644 --- a/webui/src/driver/DriverPageRoot.jsx +++ b/webui/src/driver/DriverPageRoot.jsx @@ -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 ( -
diff --git a/webui/src/main.jsx b/webui/src/main.jsx index fa001ab0..629aa090 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -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: ( + <> + + + + + } /> + {/* The retired desktop composition remains available for direct comparison + without duplicating the driver shell or changing canonical mobile layouts. */} + } /> + } /> + } /> + } /> + } /> + } /> + , 'setup')} /> + , '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. + */} + } /> + , 'fleet reports')} + /> + + + ), +}]) + createRoot(document.getElementById('root')).render( @@ -55,34 +92,7 @@ createRoot(document.getElementById('root')).render( - - - - - } /> - {/* The retired desktop composition remains available for direct comparison - without duplicating the driver shell or changing canonical mobile layouts. */} - } /> - } /> - } /> - } /> - } /> - } /> - , 'setup')} /> - , '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. - */} - } /> - , 'fleet reports')} - /> - - +