Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -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