UMBRA/frontend/src/components/notifications/NotificationToaster.tsx
Kyle Pope 2fb41e0cf4 Fix toast accept stale closure + harden backend error responses
Toast accept button captured a stale `respond` reference from the
Sonner closure. Use respondRef pattern so clicks always dispatch
through the current mutation. Backend respond endpoint now catches
unhandled exceptions and returns proper JSON with detail field
instead of plain-text 500s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:54:28 +08:00

140 lines
5.5 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { Check, X, Bell, UserPlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
const { notifications, unreadCount } = useNotifications();
const { respond } = useConnections();
const queryClient = useQueryClient();
const maxSeenIdRef = useRef(0);
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondRef = useRef(respond);
respondRef.current = respond;
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
if (respondingRef.current.has(requestId)) return;
respondingRef.current.add(requestId);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accept' ? 'Accepting connection…' : 'Declining request…',
);
try {
await respondRef.current({ requestId, action });
toast.dismiss(loadingId);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) {
toast.dismiss(loadingId);
toast.error(getErrorMessage(err, 'Failed to respond to request'));
} finally {
respondingRef.current.delete(requestId);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
}
prevUnreadRef.current = unreadCount;
}, [unreadCount, queryClient]);
// Show toasts for new notifications (ID > max seen)
useEffect(() => {
if (!notifications.length) return;
// On first load, record the max ID without toasting
if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true;
return;
}
// Find unread notifications with IDs higher than our watermark
const newNotifications = notifications.filter(
(n) => !n.is_read && n.id > maxSeenIdRef.current,
);
// Advance watermark
const maxCurrent = Math.max(...notifications.map((n) => n.id));
if (maxCurrent > maxSeenIdRef.current) {
maxSeenIdRef.current = maxCurrent;
}
// Eagerly refresh incoming requests when connection_request notifications arrive
// so accept buttons work immediately on NotificationsPage / PeoplePage
if (newNotifications.some((n) => n.type === 'connection_request')) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
}
// Show toasts
newNotifications.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification);
} else {
toast(notification.title || 'New Notification', {
description: notification.message || undefined,
icon: <Bell className="h-4 w-4" />,
duration: 8000,
});
}
});
}, [notifications, handleConnectionRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<UserPlus className="h-4 w-4 text-violet-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Connection Request</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'Someone wants to connect with you'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleConnectionRespond(requestId, 'accept', id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleConnectionRespond(requestId, 'reject', id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Reject
</button>
</div>
</div>
</div>
</div>
),
{ id: `connection-request-${requestId}`, duration: 30000 },
);
};
return null;
}