1
0
Fork 0

fix(notifications): harden bell dropdown against failed mark-read

This commit is contained in:
Jason Fraley 2026-08-12 12:38:48 -04:00
parent bc1456db88
commit 2984c72d77
2 changed files with 82 additions and 34 deletions

View file

@ -49,6 +49,8 @@ export function NotificationsBell() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [marking, setMarking] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unreadCount = notifications.filter((n) => !n.read).length; const unreadCount = notifications.filter((n) => !n.read).length;
@ -78,9 +80,24 @@ export function NotificationsBell() {
}, [fetchNotifications]); }, [fetchNotifications]);
async function handleItemClick(item: NotificationItem) { async function handleItemClick(item: NotificationItem) {
if (marking) return;
if (!item.read) { if (!item.read) {
await markNotificationsRead([item.id]); setMarking(true);
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n))); setActionError(null);
try {
const result = await markNotificationsRead([item.id]);
if (result.success) {
setNotifications((prev) =>
prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)),
);
} else {
setActionError(result.error ?? "Failed to update notifications.");
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
} finally {
setMarking(false);
}
} }
if (item.link) { if (item.link) {
router.push(item.link); router.push(item.link);
@ -89,9 +106,21 @@ export function NotificationsBell() {
} }
async function handleMarkAll() { async function handleMarkAll() {
if (unreadCount === 0) return; if (unreadCount === 0 || marking) return;
await markAllNotificationsRead(); setMarking(true);
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true }))); setActionError(null);
try {
const result = await markAllNotificationsRead();
if (result.success) {
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
} else {
setActionError(result.error ?? "Failed to update notifications.");
}
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to update notifications.");
} finally {
setMarking(false);
}
} }
return ( return (
@ -104,6 +133,11 @@ export function NotificationsBell() {
{unreadCount > 9 ? "9+" : unreadCount} {unreadCount > 9 ? "9+" : unreadCount}
</span> </span>
)} )}
<span aria-live="polite" className="sr-only">
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
: "No unread notifications"}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 sm:w-96"> <DropdownMenuContent align="end" className="w-80 sm:w-96">
@ -112,9 +146,11 @@ export function NotificationsBell() {
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
onClick={() => void handleMarkAll()} onClick={() => void handleMarkAll()}
className="text-xs text-muted-foreground hover:text-foreground" disabled={marking}
aria-label="Mark all notifications as read"
className="text-xs text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
> >
Mark all read {marking ? "Marking..." : "Mark all read"}
</button> </button>
)} )}
</div> </div>
@ -131,34 +167,44 @@ export function NotificationsBell() {
No notifications yet. No notifications yet.
</p> </p>
) : ( ) : (
notifications.map((item) => ( <>
<DropdownMenuItem {actionError && (
key={item.id} <p role="alert" className="px-3 pb-2 pt-1 text-xs text-red-400">
className={cn( {actionError}
"flex flex-col items-start gap-1 py-2.5", </p>
!item.read && "bg-accent/50", )}
)} {notifications.map((item) => (
onClick={() => void handleItemClick(item)} <DropdownMenuItem
> key={item.id}
<span className="flex w-full items-center justify-between gap-2"> className={cn(
<span className="text-sm font-medium"> "flex flex-col items-start gap-1 py-2.5",
{!item.read && ( !item.read && "bg-accent/50",
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" /> )}
)} disabled={marking}
{item.title} onClick={() => void handleItemClick(item)}
>
<span className="flex w-full items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-medium">
{!item.read && (
<span className="mr-1.5 inline-block size-1.5 rounded-full bg-blue-500 align-middle" />
)}
{item.title}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{timeAgo(item.createdAt)}
</span>
</span> </span>
<span className="shrink-0 text-[10px] text-muted-foreground"> {item.message && (
{timeAgo(item.createdAt)} <span className="line-clamp-3 w-full text-xs text-muted-foreground">
{item.message}
</span>
)}
<span className="text-[10px] text-muted-foreground/70">
{notificationLabel(item.type)}
</span> </span>
</span> </DropdownMenuItem>
{item.message && ( ))}
<span className="w-full text-xs text-muted-foreground">{item.message}</span> </>
)}
<span className="text-[10px] text-muted-foreground/70">
{notificationLabel(item.type)}
</span>
</DropdownMenuItem>
))
)} )}
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View file

@ -185,7 +185,9 @@ export function NotificationsInbox({
{timeAgo(item.createdAt)} {timeAgo(item.createdAt)}
</span> </span>
</div> </div>
<ItemTitle className="max-w-full text-foreground">{item.title}</ItemTitle> <ItemTitle className="line-clamp-2 max-w-full text-foreground">
{item.title}
</ItemTitle>
{item.message && <ItemDescription>{item.message}</ItemDescription>} {item.message && <ItemDescription>{item.message}</ItemDescription>}
</ItemContent> </ItemContent>
{item.link && ( {item.link && (