diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts new file mode 100644 index 00000000..86945861 --- /dev/null +++ b/web/src/lib/api/notifications.ts @@ -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 { + const params = new URLSearchParams({ limit: String(NOTIFICATIONS_PAGE_SIZE) }); + if (before) params.set('before', before); + return api.get(`/api/me/notifications?${params}`); +} + +export async function unreadNotificationCount(): Promise { + const res = await api.get<{ unread_count: number }>('/api/me/notifications/unread-count'); + return res.unread_count; +} + +export async function markNotificationRead(id: string): Promise { + await api.post(`/api/me/notifications/${encodeURIComponent(id)}/read`, {}); +} + +export async function markAllNotificationsRead(): Promise { + 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); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 105a308e..6f2d1ea9 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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, diff --git a/web/src/lib/components/NotificationBell.svelte b/web/src/lib/components/NotificationBell.svelte new file mode 100644 index 00000000..98b2b2e1 --- /dev/null +++ b/web/src/lib/components/NotificationBell.svelte @@ -0,0 +1,148 @@ + + + e.key === 'Escape' && (open = false)} /> + +
+ + + {#if open} + + {/if} +
diff --git a/web/src/lib/components/NotificationBell.test.ts b/web/src/lib/components/NotificationBell.test.ts new file mode 100644 index 00000000..19fafdd7 --- /dev/null +++ b/web/src/lib/components/NotificationBell.test.ts @@ -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; + return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; +}); + +vi.mock('$lib/api/notifications', async (orig) => { + const actual = (await orig()) as Record; + 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 { + 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).mockReturnValue(mockQuery({ data: unread })); + (createNotificationsQuery as ReturnType).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(); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index b2ac1e39..abc1e392 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -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} -
+
+ +