Files
Shadowbroker/frontend/src/hooks/useAlertToasts.ts
T
Shadowbroker 421682c447 Pause AlertToast auto-dismiss while hovered (#235)
Each alert toast had a 5-second auto-dismiss timer that fired even
while the user was reading the card. This adds pause-on-hover: the
dismiss timer stops while the mouse is over a toast and restarts (full
lifetime) on mouse leave. The progress bar animation pauses with it,
so the visual matches the actual remaining time.

All other behavior is preserved: same cyber/mono styling, same spring
slide-in, same risk-color border + glow, same warning icon, same
LVL X/10 readout, same title/source layout, same click-to-fly + dismiss
on body click, same × dismiss button.

Implementation notes:
- Extract a ToastCard sub-component so each card can own its own
  paused state (useState can't be array-indexed in the parent).
- Move the auto-dismiss timer out of useAlertToasts.ts and into
  ToastCard. The hook previously scheduled the dismiss itself, which
  meant the UI couldn't pause it — only the component knows whether
  the user is interacting.
- Add tests covering: title/source/severity render, auto-dismiss
  fires at 5s, hover pauses indefinitely, mouse-leave restarts the
  full lifetime, × dismisses without flying, body-click flies +
  dismisses.

This implements the genuine UX improvement that PR #234 was reaching
for, without #234's broken syntax, missing-field bug, duplicate
timer logic, or design regression.

Refs: #234

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 00:49:36 -06:00

74 lines
2.2 KiB
TypeScript

/**
* useAlertToasts — watches for new high-severity news items and surfaces toast notifications.
*
* Monitors the `news` data key for articles with risk_score >= 8.
* Maintains a seen-set to avoid duplicate toasts.
*
* NOTE: auto-dismissal is owned by the `AlertToast` component (per-card
* timer with pause-on-hover) — this hook used to schedule its own
* dismiss timer, but that prevented the UI from pausing it. The hook
* now only manages the toast queue + dedup; the component decides when
* a toast goes away.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useDataKey } from './useDataStore';
import type { NewsArticle } from '@/types/dashboard';
export interface ToastItem {
id: string;
title: string;
source: string;
risk_score: number;
lat: number;
lng: number;
timestamp: number; // when the toast was created
}
const TOAST_THRESHOLD = 8; // minimum risk_score to trigger a toast
const MAX_VISIBLE = 3;
export function useAlertToasts() {
const news = useDataKey('news') as NewsArticle[] | undefined;
const seenKeys = useRef(new Set<string>());
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
// Watch for new high-severity articles
useEffect(() => {
if (!news || !Array.isArray(news)) return;
const newToasts: ToastItem[] = [];
for (const article of news) {
if ((article.risk_score || 0) < TOAST_THRESHOLD) continue;
const key = `${article.title}|${article.source}`;
if (seenKeys.current.has(key)) continue;
seenKeys.current.add(key);
newToasts.push({
id: key,
title: article.title,
source: article.source,
risk_score: article.risk_score,
lat: article.lat || article.coords?.[0] || 0,
lng: article.lng || article.coords?.[1] || 0,
timestamp: Date.now(),
});
}
if (newToasts.length > 0) {
setToasts((prev) => {
// Merge new toasts, keep only MAX_VISIBLE most recent
const merged = [...newToasts, ...prev].slice(0, MAX_VISIBLE);
return merged;
});
}
}, [news]);
return { toasts, dismiss };
}