UMBRA/frontend/src/components/notifications/NotificationToaster.tsx
Kyle Pope 2139ea8077 Fix connection accept: stale cache, hidden button, and false 409 error
- incomingQuery: staleTime:0 + refetchOnMount:'always' so pending
  requests are always fresh when components mount (was inheriting
  5-min global staleTime, causing empty pendingRequestIds on nav)
- NotificationsPage: show Accept button while incoming data loads
  (was hidden during async gap); disable with spinner until ready
- Both toast and page: treat 409 as success ("already accepted")
  instead of showing error (fixes race when both fire respond)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:37:21 +08:00

146 lines
5.8 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: any) {
toast.dismiss(loadingId);
// 409 means the request was already resolved (e.g. accepted via notification center)
const status = err?.response?.status;
if (status === 409) {
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
} else {
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;
}