- 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>
146 lines
5.8 KiB
TypeScript
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;
|
|
}
|