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,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { Bell } from 'lucide-svelte';
|
||||
import {
|
||||
badgeLabel,
|
||||
createNotificationsQuery,
|
||||
createUnreadCountQuery,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem
|
||||
} from '$lib/api/notifications';
|
||||
import { relativeTime } from '$lib/utils/relativeTime';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// The notifications inbox (M489): a bell in the header with an unread badge,
|
||||
// opening a panel of what happened while you were away.
|
||||
|
||||
const client = useQueryClient();
|
||||
const unreadStore = createUnreadCountQuery();
|
||||
const listStore = createNotificationsQuery();
|
||||
const unread = $derived(($unreadStore.data as number | undefined) ?? 0);
|
||||
const items = $derived((($listStore.data?.items ?? []) as NotificationItem[]));
|
||||
const badge = $derived(badgeLabel(unread));
|
||||
|
||||
let open = $state(false);
|
||||
let panelRef: HTMLElement | undefined = $state();
|
||||
let buttonRef: HTMLElement | undefined = $state();
|
||||
|
||||
// Same outside-click shape as the user menu in Shell: no stopPropagation on
|
||||
// the panel, so SvelteKit's link interception keeps working inside it.
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
if (!open) return;
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (panelRef?.contains(target) || buttonRef?.contains(target)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
client.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
async function openItem(item: NotificationItem) {
|
||||
open = false;
|
||||
if (!item.read_at) {
|
||||
try {
|
||||
await markNotificationRead(item.id);
|
||||
} catch {
|
||||
// Opening the subject matters more than the read mark; the next
|
||||
// refresh shows it unread again, which is the honest state.
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
if (item.link) await goto(item.link);
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
refresh();
|
||||
} catch {
|
||||
pushToast("Couldn't mark them read. Try again in a moment.", 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
|
||||
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="relative flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Bell size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
{#if badge}
|
||||
<span
|
||||
data-testid="notification-badge"
|
||||
class="absolute right-1 top-1 min-w-[18px] rounded-full bg-text-primary px-1 text-center text-xs leading-[18px] text-background"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
bind:this={panelRef}
|
||||
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span class="text-sm font-medium text-text-primary">Notifications</span>
|
||||
{#if unread > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={markAll}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="px-3 py-6 text-center text-sm text-text-secondary">Nothing waiting for you.</p>
|
||||
{:else}
|
||||
<ul class="overflow-y-auto">
|
||||
{#each items as item (item.id)}
|
||||
<li class="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="notification-item"
|
||||
data-unread={item.read_at ? undefined : 'true'}
|
||||
class="flex w-full gap-2 px-3 py-2 text-left hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onclick={() => openItem(item)}
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span
|
||||
class="block text-sm {item.read_at ? 'text-text-secondary' : 'font-medium text-text-primary'}"
|
||||
>
|
||||
{item.title}
|
||||
{#if !item.read_at}<span class="sr-only"> (unread)</span>{/if}
|
||||
</span>
|
||||
{#if item.body}
|
||||
<span class="block text-sm text-text-secondary">{item.body}</span>
|
||||
{/if}
|
||||
<span class="block text-xs text-text-muted">{relativeTime(item.created_at)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
const invalidateMock = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/notifications', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
badgeLabel: actual.badgeLabel,
|
||||
createUnreadCountQuery: vi.fn(),
|
||||
createNotificationsQuery: vi.fn(),
|
||||
markNotificationRead: vi.fn().mockResolvedValue(undefined),
|
||||
markAllNotificationsRead: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
badgeLabel,
|
||||
createNotificationsQuery,
|
||||
createUnreadCountQuery,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem
|
||||
} from '$lib/api/notifications';
|
||||
|
||||
function item(over: Partial<NotificationItem> = {}): NotificationItem {
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'request_completed',
|
||||
title: 'Arrived: Boards of Canada – Geogaddi',
|
||||
body: 'Your request is in the library.',
|
||||
link: '/albums/al-1',
|
||||
created_at: new Date().toISOString(),
|
||||
read_at: null,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function setInbox(items: NotificationItem[], unread = items.filter((i) => !i.read_at).length) {
|
||||
(createUnreadCountQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: unread }));
|
||||
(createNotificationsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: { items, unread_count: unread } })
|
||||
);
|
||||
}
|
||||
|
||||
async function openPanel() {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('badgeLabel', () => {
|
||||
test('is empty at zero, the count to nine, then 9+', () => {
|
||||
expect(badgeLabel(0)).toBe('');
|
||||
expect(badgeLabel(1)).toBe('1');
|
||||
expect(badgeLabel(9)).toBe('9');
|
||||
expect(badgeLabel(10)).toBe('9+');
|
||||
expect(badgeLabel(250)).toBe('9+');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationBell', () => {
|
||||
test('shows the unread count on the badge and in the label', () => {
|
||||
setInbox([item(), item({ id: 'n2' }), item({ id: 'n3' })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('3');
|
||||
expect(screen.getByRole('button', { name: 'Notifications, 3 unread' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('caps the badge at 9+', () => {
|
||||
setInbox([item()], 42);
|
||||
render(NotificationBell);
|
||||
expect(screen.getByTestId('notification-badge')).toHaveTextContent('9+');
|
||||
});
|
||||
|
||||
test('no badge when everything is read', () => {
|
||||
setInbox([item({ read_at: new Date().toISOString() })]);
|
||||
render(NotificationBell);
|
||||
expect(screen.queryByTestId('notification-badge')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Notifications' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('lists each kind with the server-rendered title and body, unread marked', async () => {
|
||||
setInbox([
|
||||
item({ id: 'a', kind: 'request_approved', title: 'Request approved', body: 'Geogaddi is on its way.' }),
|
||||
item({
|
||||
id: 'b',
|
||||
kind: 'tracks_missing',
|
||||
title: '4 tracks went missing',
|
||||
body: 'Their files are no longer where the library expects them.',
|
||||
link: '/admin/missing-files',
|
||||
read_at: new Date().toISOString()
|
||||
})
|
||||
]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
|
||||
const rows = screen.getAllByTestId('notification-item');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('Request approved');
|
||||
expect(rows[0]).toHaveTextContent('Geogaddi is on its way.');
|
||||
expect(rows[0]).toHaveAttribute('data-unread', 'true');
|
||||
expect(rows[1]).toHaveTextContent('4 tracks went missing');
|
||||
expect(rows[1]).not.toHaveAttribute('data-unread');
|
||||
});
|
||||
|
||||
test('clicking an unread item marks it read and opens its subject', async () => {
|
||||
setInbox([item({ id: 'n7', link: '/albums/al-7' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/albums/al-7'));
|
||||
expect(markNotificationRead).toHaveBeenCalledWith('n7');
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
expect(screen.queryByRole('dialog', { name: 'Notifications' })).toBeNull();
|
||||
});
|
||||
|
||||
test('clicking a read item only opens it', async () => {
|
||||
setInbox([item({ read_at: new Date().toISOString(), link: '/requests' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByTestId('notification-item'));
|
||||
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith('/requests'));
|
||||
expect(markNotificationRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mark all read clears everything and refreshes', async () => {
|
||||
setInbox([item(), item({ id: 'n2' })]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Mark all read' }));
|
||||
|
||||
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalled());
|
||||
expect(invalidateMock).toHaveBeenCalledWith({ queryKey: ['notifications'] });
|
||||
});
|
||||
|
||||
test('an empty inbox says so and offers no mark all', async () => {
|
||||
setInbox([]);
|
||||
render(NotificationBell);
|
||||
await openPanel();
|
||||
expect(screen.getByText('Nothing waiting for you.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Mark all read' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
import { player } from '$lib/player/store.svelte';
|
||||
import { appName } from '$lib/branding';
|
||||
import MinstrelMark from './MinstrelMark.svelte';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import SearchInput from './SearchInput.svelte';
|
||||
|
||||
@@ -92,11 +93,13 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3 md:gap-6 justify-self-end min-w-0">
|
||||
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
|
||||
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
||||
<SearchInput />
|
||||
</div>
|
||||
|
||||
<NotificationBell />
|
||||
|
||||
<div class="relative flex-shrink-0">
|
||||
<button
|
||||
bind:this={menuButtonRef}
|
||||
|
||||
@@ -24,6 +24,23 @@ vi.mock('$lib/auth/store.svelte', () => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
// The bell's queries need a QueryClient the Shell tests do not provide; its own
|
||||
// behaviour is covered in NotificationBell.test.ts.
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
vi.mock('$lib/api/notifications', async () => {
|
||||
const { mockQuery } = await import('../../test-utils/query');
|
||||
return {
|
||||
createUnreadCountQuery: () => mockQuery({ data: 0 }),
|
||||
createNotificationsQuery: () => mockQuery({ data: { items: [], unread_count: 0 } }),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
badgeLabel: () => ''
|
||||
};
|
||||
});
|
||||
|
||||
import Shell from './Shell.svelte';
|
||||
import { logout } from '$lib/auth/store.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -32,6 +32,12 @@ function onSystemRebuilt(): void {
|
||||
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
|
||||
}
|
||||
|
||||
// A new notification for this user (M489). The frame carries no content — it
|
||||
// is a nudge — so the inbox and badge refetch rather than trusting a payload.
|
||||
function onNotificationCreated(): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
}
|
||||
|
||||
// Wire once from +layout.svelte's mount. Opens the stream only while
|
||||
// authenticated and closes it on logout; EventSource auto-reconnects on
|
||||
// transient network drops.
|
||||
@@ -42,8 +48,10 @@ export function useServerEvents(): void {
|
||||
if (typeof EventSource === 'undefined') return;
|
||||
const es = new EventSource('/api/events/stream');
|
||||
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||
es.addEventListener('notification.created', onNotificationCreated);
|
||||
return () => {
|
||||
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||
es.removeEventListener('notification.created', onNotificationCreated);
|
||||
es.close();
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user