UMBRA/frontend/src/components/notifications/NotificationToaster.tsx
Kyle Pope 053c2ae85e Mark notification as read when accepting via toast
Toast accept/reject now calls markRead on the corresponding notification
so it clears from unread in the notifications tab. Uses markReadRef to
avoid stale closure in Sonner toast callbacks. Covers both success and
409 (already resolved) paths.

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

150 lines
6.0 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 axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
const { notifications, unreadCount, markRead } = 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 markReadRef = useRef(markRead);
markReadRef.current = markRead;
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: 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');
markReadRef.current([notificationId]).catch(() => {});
} catch (err) {
toast.dismiss(loadingId);
// 409 means the request was already resolved (e.g. accepted via notification center)
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
markReadRef.current([notificationId]).catch(() => {});
} 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, notification.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, notification.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;
}