mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better alertfeed!
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Alert Feed
|
||||
// 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.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||
@@ -10,6 +10,8 @@ import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
|
||||
const LIFETIME_MS = 3000;
|
||||
const BUTTONBOX_LIFETIME_MS = 5000;
|
||||
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
|
||||
const MAX_VISIBLE_TOASTS = 6;
|
||||
const RECENT_ALERT_LOOKBACK = 12;
|
||||
const DEFAULT_COLOR = '#2196f3';
|
||||
|
||||
function buildKey(alert) {
|
||||
@@ -18,12 +20,105 @@ function buildKey(alert) {
|
||||
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 }) {
|
||||
const alerts = useSessionSelector((state) => state.alerts);
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const { pushAlert } = useSessionActions();
|
||||
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(() => {
|
||||
function onButtonIncrement(payload = {}) {
|
||||
@@ -79,48 +174,115 @@ export default function AlertFeed({ scale = 1 }) {
|
||||
};
|
||||
}, [pushAlert, socket]);
|
||||
|
||||
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||
|
||||
const { visible, nextExpiryAt } = useMemo(() => {
|
||||
const visibleByKey = new Map();
|
||||
let soonestExpiryAt = null;
|
||||
|
||||
latest.forEach((item) => {
|
||||
const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0;
|
||||
const age = now - receivedAt;
|
||||
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
||||
const expiryAt = receivedAt + lifetimeMs;
|
||||
|
||||
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]);
|
||||
const latest = useMemo(
|
||||
() =>
|
||||
alerts.slice(-RECENT_ALERT_LOOKBACK).map((alert) => {
|
||||
const key = buildKey(alert);
|
||||
return {
|
||||
alert,
|
||||
key,
|
||||
token: buildAlertToken(alert, key),
|
||||
};
|
||||
}),
|
||||
[alerts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (nextExpiryAt == null) return undefined;
|
||||
/*
|
||||
The small padding avoids waking a few milliseconds before the browser's
|
||||
current Date.now value crosses the expiry boundary. Without it, React can
|
||||
render the same alert once more and schedule a second near-immediate timer.
|
||||
The processed-token set is captured before scheduling the state update
|
||||
because React may run the functional updater after this effect continues.
|
||||
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 timer = setTimeout(() => setNow(Date.now()), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [nextExpiryAt]);
|
||||
const previousTokens = latestTokensRef.current;
|
||||
const newItems = latest.filter((item) => !previousTokens.has(item.token));
|
||||
|
||||
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 =
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className={containerClass} style={containerStyle}>
|
||||
{visible.map((toast) => (
|
||||
<AlertToast key={toast.key} alert={toast.alert} />
|
||||
<div className={containerClass} style={containerStyle} aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<AlertToastShell
|
||||
key={toast.key}
|
||||
toast={toast}
|
||||
onDismiss={dismissToast}
|
||||
onPause={pauseToast}
|
||||
onResume={resumeToast}
|
||||
/>
|
||||
))}
|
||||
</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 }) {
|
||||
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
|
||||
const label = payload?.label || payload?.code || 'unknown barcode';
|
||||
@@ -238,16 +472,19 @@ function AlertToast({ alert }) {
|
||||
if (alert.kind === 'barcode-scan' && 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 (
|
||||
<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">
|
||||
<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-white">{alert.message}</span>
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user