mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 01:22:27 +02:00
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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AlertToast from '@/components/AlertToast';
|
||||
import type { ToastItem } from '@/hooks/useAlertToasts';
|
||||
|
||||
function buildToast(partial: Partial<ToastItem> = {}): ToastItem {
|
||||
return {
|
||||
id: 'toast-1',
|
||||
title: 'Embassy evacuation reported',
|
||||
source: 'Reuters',
|
||||
risk_score: 9,
|
||||
lat: 38.9,
|
||||
lng: -77.0,
|
||||
timestamp: Date.now(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AlertToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the toast title, source, and severity label', () => {
|
||||
const toast = buildToast();
|
||||
render(
|
||||
<AlertToast toasts={[toast]} onDismiss={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(toast.title)).toBeTruthy();
|
||||
expect(screen.getByText(toast.source)).toBeTruthy();
|
||||
// 9/10 -> CRITICAL
|
||||
expect(screen.getByText(/CRITICAL/)).toBeTruthy();
|
||||
expect(screen.getByText(/LVL 9\/10/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('auto-dismisses after 5 seconds', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const toast = buildToast();
|
||||
render(
|
||||
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
|
||||
);
|
||||
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledWith(toast.id);
|
||||
});
|
||||
|
||||
it('pauses auto-dismiss while the card is hovered', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const toast = buildToast();
|
||||
render(
|
||||
<AlertToast toasts={[toast]} onDismiss={onDismiss} />,
|
||||
);
|
||||
|
||||
// Hover before the timer fires. mouseEnter must be flushed
|
||||
// (state update + effect cleanup) in its own act() before we
|
||||
// advance timers — otherwise the original mount-time timer is
|
||||
// still active when advanceTimersByTime runs.
|
||||
const card = screen.getByText(toast.title).closest('[class*="cursor-pointer"]')!;
|
||||
expect(card).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(card);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
// Still no dismiss — timer is paused.
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
// Leave: a fresh full-lifetime timer starts.
|
||||
act(() => {
|
||||
fireEvent.mouseLeave(card);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4_999);
|
||||
});
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledWith(toast.id);
|
||||
});
|
||||
|
||||
it('dismisses on × button click without calling onFlyTo', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const onFlyTo = vi.fn();
|
||||
const toast = buildToast();
|
||||
render(
|
||||
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('×'));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledWith(toast.id);
|
||||
expect(onFlyTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flies to the toast location and dismisses on body click', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const onFlyTo = vi.fn();
|
||||
const toast = buildToast();
|
||||
render(
|
||||
<AlertToast toasts={[toast]} onDismiss={onDismiss} onFlyTo={onFlyTo} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(toast.title));
|
||||
|
||||
expect(onFlyTo).toHaveBeenCalledWith(toast.lat, toast.lng);
|
||||
expect(onDismiss).toHaveBeenCalledWith(toast.id);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { ToastItem } from '@/hooks/useAlertToasts';
|
||||
|
||||
const TOAST_LIFETIME_MS = 5_000;
|
||||
|
||||
function getRiskColor(score: number): string {
|
||||
if (score >= 9) return '#ef4444';
|
||||
if (score >= 7) return '#f97316';
|
||||
@@ -16,6 +19,122 @@ function getRiskLabel(score: number): string {
|
||||
return 'ELEVATED';
|
||||
}
|
||||
|
||||
/**
|
||||
* ToastCard — renders a single toast with hover-to-pause auto-dismiss.
|
||||
*
|
||||
* Each card owns its own 5s dismiss timer. Hovering the card pauses the
|
||||
* timer; the timer restarts (full duration) on mouse leave. All visual
|
||||
* styling, the progress bar animation, the click-to-fly behavior, and
|
||||
* the dismiss button match the previous inline implementation — the
|
||||
* only behavioral change is the pause-on-hover.
|
||||
*/
|
||||
function ToastCard({
|
||||
toast,
|
||||
onDismiss,
|
||||
onFlyTo,
|
||||
}: {
|
||||
toast: ToastItem;
|
||||
onDismiss: (id: string) => void;
|
||||
onFlyTo?: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const color = getRiskColor(toast.risk_score);
|
||||
const label = getRiskLabel(toast.risk_score);
|
||||
|
||||
// Per-toast auto-dismiss timer. Restarts whenever the pause flag flips
|
||||
// off — so hovering resets the clock back to a full lifetime when the
|
||||
// user moves the mouse away, giving them time to actually read it.
|
||||
useEffect(() => {
|
||||
if (isPaused) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(toast.id);
|
||||
}, TOAST_LIFETIME_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isPaused, toast.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
onClick={() => {
|
||||
if (onFlyTo && toast.lat && toast.lng) {
|
||||
onFlyTo(toast.lat, toast.lng);
|
||||
}
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
|
||||
style={{
|
||||
borderLeft: `3px solid ${color}`,
|
||||
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
|
||||
}}
|
||||
>
|
||||
{/* Progress bar — animation pauses while the card is hovered. */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-[2px]"
|
||||
style={{ background: color }}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: isPaused ? '100%' : '0%' }}
|
||||
transition={{ duration: TOAST_LIFETIME_MS / 1000, ease: 'linear' }}
|
||||
/>
|
||||
|
||||
<div className="p-3 pr-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
⚠ {label}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
LVL {toast.risk_score}/10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
|
||||
>
|
||||
{toast.title}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
{toast.source}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AlertToast({
|
||||
toasts,
|
||||
onDismiss,
|
||||
@@ -28,87 +147,14 @@ export default function AlertToast({
|
||||
return (
|
||||
<div className="fixed top-16 right-[440px] z-[9500] flex flex-col gap-2 pointer-events-none max-w-[380px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast) => {
|
||||
const color = getRiskColor(toast.risk_score);
|
||||
const label = getRiskLabel(toast.risk_score);
|
||||
return (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.9 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onClick={() => {
|
||||
if (onFlyTo && toast.lat && toast.lng) {
|
||||
onFlyTo(toast.lat, toast.lng);
|
||||
}
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative bg-[rgba(5,5,5,0.96)] backdrop-blur-sm rounded-sm overflow-hidden font-mono"
|
||||
style={{
|
||||
borderLeft: `3px solid ${color}`,
|
||||
boxShadow: `0 0 20px ${color}40, 0 4px 12px rgba(0,0,0,0.5)`,
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-[2px]"
|
||||
style={{ background: color }}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%' }}
|
||||
transition={{ duration: 5, ease: 'linear' }}
|
||||
/>
|
||||
|
||||
<div className="p-3 pr-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className="text-[9px] font-bold tracking-[0.2em] px-1.5 py-0.5 rounded-sm"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`,
|
||||
}}
|
||||
>
|
||||
⚠ {label}
|
||||
</span>
|
||||
<span className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
LVL {toast.risk_score}/10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
className="text-[11px] text-[var(--text-primary)] leading-tight mb-1"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
|
||||
>
|
||||
{toast.title}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="text-[9px] text-[var(--text-muted)] tracking-wider uppercase">
|
||||
{toast.source}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
className="absolute top-2 right-2 text-[var(--text-muted)] hover:text-white transition-colors text-xs font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(toast.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
{toasts.map((toast) => (
|
||||
<ToastCard
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onDismiss={onDismiss}
|
||||
onFlyTo={onFlyTo}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
* 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. Auto-dismisses after 5 seconds.
|
||||
* 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';
|
||||
@@ -20,30 +26,14 @@ export interface ToastItem {
|
||||
|
||||
const TOAST_THRESHOLD = 8; // minimum risk_score to trigger a toast
|
||||
const MAX_VISIBLE = 3;
|
||||
const AUTO_DISMISS_MS = 5_000;
|
||||
|
||||
export function useAlertToasts() {
|
||||
const news = useDataKey('news') as NewsArticle[] | undefined;
|
||||
const seenKeys = useRef(new Set<string>());
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
// Auto-dismiss scheduled toasts
|
||||
const scheduleDismiss = useCallback((id: string) => {
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timersRef.current.delete(id);
|
||||
}, AUTO_DISMISS_MS);
|
||||
timersRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Watch for new high-severity articles
|
||||
@@ -76,22 +66,8 @@ export function useAlertToasts() {
|
||||
const merged = [...newToasts, ...prev].slice(0, MAX_VISIBLE);
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Schedule auto-dismiss for each new toast
|
||||
for (const t of newToasts) {
|
||||
scheduleDismiss(t.id);
|
||||
}
|
||||
}
|
||||
}, [news, scheduleDismiss]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of timersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [news]);
|
||||
|
||||
return { toasts, dismiss };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user