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 [loading, setLoading] = useState(true);
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 unreadCount = notifications.filter((n) => !n.read).length;
@ -78,9 +80,24 @@ export function NotificationsBell() {
}, [fetchNotifications]);
async function handleItemClick(item: NotificationItem) {
if (marking) return;
if (!item.read) {
await markNotificationsRead([item.id]);
setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
setMarking(true);
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) {
router.push(item.link);
@ -89,9 +106,21 @@ export function NotificationsBell() {
}
async function handleMarkAll() {
if (unreadCount === 0) return;
await markAllNotificationsRead();
setNotifications((prev) => prev.map((n) => (n.read ? n : { ...n, read: true })));
if (unreadCount === 0 || marking) return;
setMarking(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 (
@ -104,6 +133,11 @@ export function NotificationsBell() {
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
<span aria-live="polite" className="sr-only">
{unreadCount > 0
? `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`
: "No unread notifications"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 sm:w-96">
@ -112,9 +146,11 @@ export function NotificationsBell() {
{unreadCount > 0 && (
<button
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>
)}
</div>
@ -131,34 +167,44 @@ export function NotificationsBell() {
No notifications yet.
</p>
) : (
notifications.map((item) => (
<DropdownMenuItem
key={item.id}
className={cn(
"flex flex-col items-start gap-1 py-2.5",
!item.read && "bg-accent/50",
)}
onClick={() => void handleItemClick(item)}
>
<span className="flex w-full items-center justify-between gap-2">
<span className="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}
<>
{actionError && (
<p role="alert" className="px-3 pb-2 pt-1 text-xs text-red-400">
{actionError}
</p>
)}
{notifications.map((item) => (
<DropdownMenuItem
key={item.id}
className={cn(
"flex flex-col items-start gap-1 py-2.5",
!item.read && "bg-accent/50",
)}
disabled={marking}
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 className="shrink-0 text-[10px] text-muted-foreground">
{timeAgo(item.createdAt)}
{item.message && (
<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>
{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>
))
</DropdownMenuItem>
))}
</>
)}
</div>
<DropdownMenuSeparator />

View file

@ -185,7 +185,9 @@ export function NotificationsInbox({
{timeAgo(item.createdAt)}
</span>
</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>}
</ItemContent>
{item.link && (