UMBRA/frontend/src/hooks/useAlerts.tsx
Kyle Pope 17643d54ea Suppress reminder toasts while lock screen is active
Swap LockProvider to outer wrapper so AlertsProvider can read isLocked.
When locked, dismiss all visible reminder toasts and skip firing new ones.
Toasts re-fire normally on unlock via the firedRef.clear() reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:23:26 +08:00

228 lines
7.9 KiB
TypeScript

import { createContext, useContext, useRef, useEffect, useCallback, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { Bell, X } from 'lucide-react';
import api from '@/lib/api';
import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils';
import { useLock } from '@/hooks/useLock';
import SnoozeDropdown from '@/components/reminders/SnoozeDropdown';
import type { Reminder } from '@/types';
const MAX_TOASTS = 3;
interface AlertsContextValue {
alerts: Reminder[];
dismiss: (id: number) => void;
snooze: (id: number, minutes: 5 | 10 | 15) => void;
}
const AlertsContext = createContext<AlertsContextValue>({
alerts: [],
dismiss: () => {},
snooze: () => {},
});
export function useAlerts() {
return useContext(AlertsContext);
}
export function AlertsProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const location = useLocation();
const { isLocked } = useLock();
const firedRef = useRef<Set<number>>(new Set());
const prevPathnameRef = useRef(location.pathname);
const isDashboard = location.pathname === '/' || location.pathname === '/dashboard';
const { data: alerts = [] } = useQuery<Reminder[]>({
queryKey: ['reminders', 'due'],
queryFn: async () => {
const { data } = await api.get<Reminder[]>('/reminders/due', {
params: { client_now: toLocalDatetime() },
});
return data;
},
refetchInterval: 30_000,
staleTime: 0,
refetchIntervalInBackground: false,
refetchOnWindowFocus: true,
});
// Prune firedRef — remove IDs no longer in the response
useEffect(() => {
const currentIds = new Set(alerts.map((a) => a.id));
firedRef.current.forEach((id) => {
if (!currentIds.has(id)) {
firedRef.current.delete(id);
toast.dismiss(`reminder-${id}`);
}
});
}, [alerts]);
const dismissMutation = useMutation({
mutationFn: (id: number) => api.patch(`/reminders/${id}/dismiss`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ['reminders', 'due'] });
toast.error('Failed to dismiss reminder');
},
});
const snoozeMutation = useMutation({
mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) =>
api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ['reminders', 'due'] });
toast.error('Failed to snooze reminder');
},
});
const updateSummaryToast = useCallback(() => {
// Count alerts that are still fired but don't have individual toasts (overflow)
const totalFired = firedRef.current.size;
const overflow = totalFired - MAX_TOASTS;
if (overflow > 0) {
toast.custom(
(t) => renderSummaryToast(t, overflow),
{ id: 'reminder-summary', duration: Infinity }
);
} else {
toast.dismiss('reminder-summary');
}
}, []);
const handleDismiss = useCallback((id: number) => {
toast.dismiss(`reminder-${id}`);
firedRef.current.delete(id);
updateSummaryToast();
dismissMutation.mutate(id);
}, [dismissMutation, updateSummaryToast]);
const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => {
toast.dismiss(`reminder-${id}`);
firedRef.current.delete(id);
updateSummaryToast();
snoozeMutation.mutate({ id, minutes });
}, [snoozeMutation, updateSummaryToast]);
// Store latest callbacks in refs so toast closures always call current versions
const dismissRef = useRef(handleDismiss);
const snoozeRef = useRef(handleSnooze);
useEffect(() => { dismissRef.current = handleDismiss; }, [handleDismiss]);
useEffect(() => { snoozeRef.current = handleSnooze; }, [handleSnooze]);
function renderToast(_t: string | number, reminder: Reminder) {
const timeAgo = reminder.remind_at ? getRelativeTime(reminder.remind_at) : '';
return (
<div className="flex items-center gap-3 bg-card border border-border rounded-lg p-3 shadow-lg w-[356px]">
<div className="p-1.5 rounded-md bg-orange-500/10 shrink-0">
<Bell className="h-4 w-4 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{reminder.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{timeAgo}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<SnoozeDropdown
onSnooze={(m) => snoozeRef.current(reminder.id, m)}
label={reminder.title}
direction="down"
/>
<button
onClick={() => dismissRef.current(reminder.id)}
aria-label={`Dismiss "${reminder.title}"`}
className="flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/10 hover:text-accent text-muted-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
<span className="text-[11px] font-medium">Dismiss</span>
</button>
</div>
</div>
);
}
function renderSummaryToast(_t: string | number, count: number) {
return (
<div className="flex items-center gap-3 bg-card border border-border rounded-lg p-3 shadow-lg w-[356px]">
<div className="p-1.5 rounded-md bg-orange-500/10 shrink-0">
<Bell className="h-4 w-4 text-orange-400" />
</div>
<p className="text-sm text-muted-foreground">
+{count} more reminder{count > 1 ? 's' : ''} due
</p>
</div>
);
}
function fireToasts(reminders: Reminder[]) {
const newAlerts = reminders.filter((a) => !firedRef.current.has(a.id));
if (newAlerts.length === 0) return;
const toShow = newAlerts.slice(0, MAX_TOASTS);
const overflow = newAlerts.length - MAX_TOASTS;
toShow.forEach((reminder) => {
firedRef.current.add(reminder.id);
toast.custom(
(t) => renderToast(t, reminder),
{ id: `reminder-${reminder.id}`, duration: Infinity }
);
});
// Mark remaining as fired to prevent re-firing
newAlerts.slice(MAX_TOASTS).forEach((a) => firedRef.current.add(a.id));
if (overflow > 0) {
toast.custom(
(t) => renderSummaryToast(t, overflow),
{ id: 'reminder-summary', duration: Infinity }
);
}
}
// Unified toast management — single effect handles route changes, lock state, and new alerts
useEffect(() => {
const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard';
const nowOnDashboard = isDashboard;
prevPathnameRef.current = location.pathname;
// Suppress toasts while locked — dismiss any visible ones
if (isLocked) {
alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`));
toast.dismiss('reminder-summary');
firedRef.current.clear();
return;
}
if (nowOnDashboard) {
// On dashboard — dismiss all toasts, banner takes over
alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`));
toast.dismiss('reminder-summary');
firedRef.current.clear();
return;
}
// Transitioning away from dashboard — reset fired set so all current alerts get toasts
if (wasOnDashboard && !nowOnDashboard) {
firedRef.current.clear();
}
// On non-dashboard page — fire toasts for any unfired alerts
fireToasts(alerts);
}, [location.pathname, isDashboard, alerts, isLocked]);
return (
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>
{children}
</AlertsContext.Provider>
);
}