better alertfeed!

This commit is contained in:
legop3
2026-06-21 22:39:58 -04:00
parent a769974626
commit 9c5468ed09
6 changed files with 300 additions and 61 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
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script> <script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CsI4p8Hs.js"></script> <script type="module" crossorigin src="/assets/index-B0U7t0up.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_VrMPCY.css"> <link rel="stylesheet" crossorigin href="/assets/index-CdgtactY.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4 -2
View File
@@ -1,5 +1,7 @@
1. fix rover request spam queue cheat 1. add camera filter indicator for when youre changing it
2. fix this: 2. make alert feed better with fadeout and stuff and it stays when you hover over one
3. fix rover request spam queue cheat
4. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92 `Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message }); Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^ Jun 18 15:14:18 roombaserver.local node[216731]: ^
+285 -48
View File
@@ -1,7 +1,7 @@
// Alert Feed // Alert Feed
// Purpose: Defines the Alert Feed module and the local helpers/components used in this file. // Purpose: Defines the Alert Feed module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx'; import { useSocket } from '../../context/SocketContext.jsx';
import ChatMessageRow from '../ChatMessageRow/index.jsx'; import ChatMessageRow from '../ChatMessageRow/index.jsx';
@@ -10,6 +10,8 @@ import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
const LIFETIME_MS = 3000; const LIFETIME_MS = 3000;
const BUTTONBOX_LIFETIME_MS = 5000; const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000; const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
const MAX_VISIBLE_TOASTS = 6;
const RECENT_ALERT_LOOKBACK = 12;
const DEFAULT_COLOR = '#2196f3'; const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) { function buildKey(alert) {
@@ -18,12 +20,105 @@ function buildKey(alert) {
return `${alert.title || 'alert'}-${alert.message}`; return `${alert.title || 'alert'}-${alert.message}`;
} }
function getAlertLifetime(alert) {
return Number.isFinite(alert?.lifetimeMs) ? alert.lifetimeMs : LIFETIME_MS;
}
function buildAlertToken(alert, key) {
/*
A keyed alert such as a barcode scan intentionally reuses the same id so the
visual slot can be refreshed in place. The token must therefore include the
arrival timestamp too; otherwise a new scan with the same id would look like
the same toast and would not get a fresh timer.
*/
const receivedAt = alert?.receivedAt ?? '';
const timestamp = alert?.timestamp ?? '';
const title = alert?.title ?? '';
const message = alert?.message ?? '';
return `${key}:${receivedAt}:${timestamp}:${title}:${message}`;
}
function buildToastRecord(item) {
const lifetimeMs = getAlertLifetime(item.alert);
const expiresAt = Date.now() + lifetimeMs;
return {
key: item.key,
alert: item.alert,
expiresAt,
remainingMs: lifetimeMs,
paused: false,
};
}
function getToastRgb(alert) {
return hexToRgb(alert?.color) || hexToRgb(DEFAULT_COLOR);
}
function getToastAccent(alert) {
const rgb = getToastRgb(alert);
return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : DEFAULT_COLOR;
}
function isGenericAlert(alert) {
/*
The shell needs to know which alerts use the fallback title/message renderer
because those alerts historically carried their own color-tinted surface.
Matching the same payload guards used by AlertToast keeps the shell styling
aligned with the renderer that will actually be selected below.
*/
if (alert?.kind === 'buttonbox-active' && alert.payload) return false;
if (alert?.kind === 'chat' && alert.payload) return false;
if (alert?.kind === 'chat-typing' && alert.payload) return false;
if (alert?.kind === 'barcode-scan' && alert.payload) return false;
return true;
}
export default function AlertFeed({ scale = 1 }) { export default function AlertFeed({ scale = 1 }) {
const alerts = useSessionSelector((state) => state.alerts); const alerts = useSessionSelector((state) => state.alerts);
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []); const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions(); const { pushAlert } = useSessionActions();
const socket = useSocket(); const socket = useSocket();
const [now, setNow] = useState(() => Date.now()); const [toasts, setToasts] = useState([]);
const latestTokensRef = useRef(new Set());
const timersRef = useRef(new Map());
const clearToastTimer = useCallback((key) => {
const timer = timersRef.current.get(key);
if (!timer) return;
clearTimeout(timer);
timersRef.current.delete(key);
}, []);
const removeToast = useCallback(
(key) => {
clearToastTimer(key);
setToasts((currentToasts) => currentToasts.filter((toast) => toast.key !== key));
},
[clearToastTimer],
);
const scheduleToastRemoval = useCallback(
(key, delayMs) => {
clearToastTimer(key);
/*
Each toast owns exactly one removal timeout. This is cheaper and simpler
than polling all visible alerts for progress/fade state, and it still
lets hover/focus pause by clearing the one timer for the interacted row.
*/
const timer = setTimeout(() => removeToast(key), Math.max(0, delayMs));
timersRef.current.set(key, timer);
},
[clearToastTimer, removeToast],
);
useEffect(
() => () => {
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
},
[],
);
useEffect(() => { useEffect(() => {
function onButtonIncrement(payload = {}) { function onButtonIncrement(payload = {}) {
@@ -79,48 +174,115 @@ export default function AlertFeed({ scale = 1 }) {
}; };
}, [pushAlert, socket]); }, [pushAlert, socket]);
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); const latest = useMemo(
() =>
const { visible, nextExpiryAt } = useMemo(() => { alerts.slice(-RECENT_ALERT_LOOKBACK).map((alert) => {
const visibleByKey = new Map(); const key = buildKey(alert);
let soonestExpiryAt = null; return {
alert,
latest.forEach((item) => { key,
const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0; token: buildAlertToken(alert, key),
const age = now - receivedAt; };
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS; }),
const expiryAt = receivedAt + lifetimeMs; [alerts],
);
if (age <= lifetimeMs) {
visibleByKey.set(item.key, { ...item, age });
/*
Alerts do not render a live progress value, so polling every 200ms is
unnecessary. Tracking the earliest expiry lets this component sleep
until one visible toast actually needs to disappear.
*/
soonestExpiryAt = soonestExpiryAt == null ? expiryAt : Math.min(soonestExpiryAt, expiryAt);
}
});
return {
visible: Array.from(visibleByKey.values()).slice(-6),
nextExpiryAt: soonestExpiryAt,
};
}, [latest, now]);
useEffect(() => { useEffect(() => {
if (nextExpiryAt == null) return undefined;
/* /*
The small padding avoids waking a few milliseconds before the browser's The processed-token set is captured before scheduling the state update
current Date.now value crosses the expiry boundary. Without it, React can because React may run the functional updater after this effect continues.
render the same alert once more and schedule a second near-immediate timer. Reading latestTokensRef.current inside the updater would race with the ref
assignment below and could make brand-new alerts look already processed,
leaving the local toast list empty.
*/ */
const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 25; const previousTokens = latestTokensRef.current;
const timer = setTimeout(() => setNow(Date.now()), delayMs); const newItems = latest.filter((item) => !previousTokens.has(item.token));
return () => clearTimeout(timer);
}, [nextExpiryAt]);
if (!visible.length) return null; if (newItems.length) {
setToasts((currentToasts) => {
const nextByKey = new Map(currentToasts.map((toast) => [toast.key, toast]));
newItems.forEach((item) => {
/*
Setting an existing Map key updates its value but keeps its original
insertion slot. Deleting first makes refreshed keyed alerts behave
like new notifications visually, which is what operators expect when
the barcode lane or a button-box lane receives a fresh event.
*/
nextByKey.delete(item.key);
nextByKey.set(item.key, buildToastRecord(item));
});
/*
The newest records are the only ones that should consume feed slots.
Older active records are dropped immediately so a burst of alerts
cannot cover the rest of the rover interface.
*/
const nextToasts = Array.from(nextByKey.values());
const activeKeys = nextToasts.slice(-MAX_VISIBLE_TOASTS).map((toast) => toast.key);
const activeKeySet = new Set(activeKeys);
return nextToasts.filter((toast) => activeKeySet.has(toast.key));
});
newItems.forEach((item) => {
scheduleToastRemoval(item.key, getAlertLifetime(item.alert));
});
}
latestTokensRef.current = new Set(latest.map((item) => item.token));
}, [latest, scheduleToastRemoval]);
const pauseToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || toast.paused) return;
const remainingMs = Math.max(0, toast.expiresAt - Date.now());
clearToastTimer(key);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || toast.paused) return toast;
/*
Remaining time is captured at the moment interaction begins, not at the
previous render. That makes hover/focus pauses exact enough that a
toast cannot expire while the user is moving toward a link or dismiss
button inside it.
*/
return {
...toast,
paused: true,
remainingMs,
};
}),
);
}, [clearToastTimer, toasts]);
const resumeToast = useCallback((key) => {
const toast = toasts.find((entry) => entry.key === key);
if (!toast || !toast.paused) return;
const startedAt = Date.now();
const remainingMs = Math.max(0, toast.remainingMs);
setToasts((currentToasts) =>
currentToasts.map((toast) => {
if (toast.key !== key || !toast.paused) return toast;
return {
...toast,
paused: false,
expiresAt: startedAt + remainingMs,
remainingMs,
};
}),
);
scheduleToastRemoval(key, remainingMs);
}, [scheduleToastRemoval, toasts]);
const dismissToast = useCallback((key) => {
removeToast(key);
}, [removeToast]);
if (!toasts.length) return null;
const containerStyle = const containerStyle =
scale === 1 scale === 1
@@ -135,9 +297,15 @@ export default function AlertFeed({ scale = 1 }) {
: 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5'; : 'pointer-events-none fixed top-0.5 left-1/2 z-50 flex flex-col gap-0.5';
return ( return (
<div className={containerClass} style={containerStyle}> <div className={containerClass} style={containerStyle} aria-live="polite">
{visible.map((toast) => ( {toasts.map((toast) => (
<AlertToast key={toast.key} alert={toast.alert} /> <AlertToastShell
key={toast.key}
toast={toast}
onDismiss={dismissToast}
onPause={pauseToast}
onResume={resumeToast}
/>
))} ))}
</div> </div>
); );
@@ -174,6 +342,72 @@ function getSafeWikiUrl(rawUrl) {
} }
} }
function AlertToastShell({ toast, onDismiss, onPause, onResume }) {
const accent = getToastAccent(toast.alert);
const generic = isGenericAlert(toast.alert);
const rgb = getToastRgb(toast.alert);
const genericBackgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
const genericBorderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
const shellClass = [
'pointer-events-auto w-fit max-w-[80vw] overflow-hidden rounded-md shadow-sm shadow-black/40',
generic ? '' : 'border border-white/10 bg-black/75',
].join(' ');
return (
<div
className={shellClass}
onMouseEnter={() => onPause(toast.key)}
onMouseLeave={() => onResume(toast.key)}
onFocusCapture={() => onPause(toast.key)}
onBlurCapture={() => onResume(toast.key)}
style={
generic
? {
/*
Generic alerts keep the same color math they used before the shared
shell was introduced. Putting it on the shell restores the original
brightness while still leaving room for the side dismiss column.
*/
backgroundColor: genericBackgroundColor,
border: `1px solid ${genericBorderColor}`,
}
: undefined
}
>
<div className="flex min-w-0 items-stretch">
<div className="min-w-0 flex-1">
<AlertToast alert={toast.alert} />
</div>
<div
className={[
'flex w-5 shrink-0 flex-col items-center justify-start gap-0.5 border-l py-0.5',
generic ? '' : 'border-white/10 bg-black/20',
].join(' ')}
style={
generic
? {
borderLeftColor: genericBorderColor,
backgroundColor: 'rgba(0, 0, 0, 0.08)',
}
: undefined
}
>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-sm text-xs leading-none text-slate-300 hover:bg-white/10 hover:text-white focus:bg-white/10 focus:text-white focus:outline-none focus:ring-1"
style={{ '--tw-ring-color': accent }}
onClick={() => onDismiss(toast.key)}
title="Dismiss alert"
aria-label="Dismiss alert"
>
×
</button>
</div>
</div>
</div>
);
}
function BarcodeScanToast({ payload }) { function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl); const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode'; const label = payload?.label || payload?.code || 'unknown barcode';
@@ -238,16 +472,19 @@ function AlertToast({ alert }) {
if (alert.kind === 'barcode-scan' && alert.payload) { if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />; return <BarcodeScanToast payload={alert.payload} />;
} }
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';
return ( return (
<div <div
className="pointer-events-auto max-w-[80vw] rounded-full border px-2 py-0.5 text-[0.7rem] leading-tight text-slate-100 shadow-sm shadow-black/30" /*
style={{ backgroundColor, border: `1px solid ${borderColor}` }} Generic alerts are the payloads that directly provide title/message/color
instead of a richer custom renderer. The surrounding shell owns their
color-tinted background so the dismiss column and content share one
continuous surface while this renderer stays responsible for one line of
compact title/message text.
*/
className="pointer-events-auto min-w-0 max-w-[24rem] px-1.5 py-0.5 text-left text-[0.72rem] leading-tight text-slate-100"
> >
<p className="truncate text-slate-100"> <p className="truncate text-slate-100">
<span className="text-slate-300">{alert.title || 'Alert'}</span> <span className="font-semibold text-slate-300">{alert.title || 'Alert'}</span>
<span className="text-slate-500"> · </span> <span className="text-slate-500"> · </span>
<span className="text-white">{alert.message}</span> <span className="text-white">{alert.message}</span>
</p> </p>