feat(web): notifications bell, unread badge and inbox panel in the header (#5342)
- The bell sits between search and the user menu. Its badge is parchment on obsidian, not the accent, which the house style keeps off general chrome. It counts up to 9, then shows 9+. - The panel lists the server-rendered title, body and relative time, newest first, with unread rows marked. Clicking a row marks it read and opens its link. "Mark all read" appears while anything is unread, and an empty inbox says "Nothing waiting for you." - createNotificationsQuery and createUnreadCountQuery poll every 60s while the tab is visible. The `notification.created` live event invalidates ['notifications'] so the badge and list refresh promptly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
import { qk } from './queries';
|
||||
|
||||
// The notifications inbox (M489). The server renders title, body and link per
|
||||
// kind, so every client says the same thing and this module never has to know
|
||||
// what a kind means.
|
||||
|
||||
export type NotificationItem = {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link: string;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
};
|
||||
|
||||
export type NotificationPage = {
|
||||
items: NotificationItem[];
|
||||
unread_count: number;
|
||||
next_before?: string;
|
||||
};
|
||||
|
||||
// The bell polls as a fallback for a dropped live-event stream; the
|
||||
// `notification.created` event is what makes it prompt. A minute is slow
|
||||
// enough to cost nothing and quick enough that a missed nudge is not missed
|
||||
// for long. Polling pauses while the tab is hidden (TanStack's default).
|
||||
export const NOTIFICATIONS_POLL_MS = 60_000;
|
||||
export const NOTIFICATIONS_PAGE_SIZE = 30;
|
||||
|
||||
export async function listNotifications(before?: string): Promise<NotificationPage> {
|
||||
const params = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) });
|
||||
if (before) params.set('before', before);
|
||||
return api.get<NotificationPage>(`/api/me/notifications?${params}`);
|
||||
}
|
||||
|
||||
export async function unreadNotificationCount(): Promise<number> {
|
||||
const res = await api.get<{ unread_count: number }>('/api/me/notifications/unread-count');
|
||||
return res.unread_count;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await api.post(`/api/me/notifications/${encodeURIComponent(id)}/read`, {});
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await api.post('/api/me/notifications/read-all', {});
|
||||
}
|
||||
|
||||
export function createNotificationsQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.notifications(),
|
||||
queryFn: () => listNotifications(),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: NOTIFICATIONS_POLL_MS
|
||||
});
|
||||
}
|
||||
|
||||
export function createUnreadCountQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.notificationsUnread(),
|
||||
queryFn: unreadNotificationCount,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: NOTIFICATIONS_POLL_MS
|
||||
});
|
||||
}
|
||||
|
||||
/** The badge's text: nothing at zero, the count up to nine, then "9+". */
|
||||
export function badgeLabel(count: number): string {
|
||||
if (count <= 0) return '';
|
||||
return count > 9 ? '9+' : String(count);
|
||||
}
|
||||
@@ -61,6 +61,11 @@ export const qk = {
|
||||
adminDiagnosticDevices: (userId?: string) =>
|
||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||
smtpConfig: () => ['smtpConfig'] as const,
|
||||
// Notifications inbox (M489). Both keys sit under ['notifications'] so one
|
||||
// prefix invalidation refreshes the list and the badge together.
|
||||
notifications: () => ['notifications', 'list'] as const,
|
||||
notificationsUnread: () => ['notifications', 'unread'] as const,
|
||||
notificationSettings: () => ['notificationSettings'] as const,
|
||||
suggestions: (limit?: number) =>
|
||||
['suggestions', { limit: limit ?? 12 }] as const,
|
||||
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
|
||||
|
||||
Reference in New Issue
Block a user