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>
140 lines
5.5 KiB
TypeScript
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;
|
|
}
|