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?: string) =>
|
||||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||||
smtpConfig: () => ['smtpConfig'] 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?: number) =>
|
||||||
['suggestions', { limit: limit ?? 12 }] as const,
|
['suggestions', { limit: limit ?? 12 }] as const,
|
||||||
suggestionSnoozes: () => ['suggestionSnoozes'] 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 { player } from '$lib/player/store.svelte';
|
||||||
import { appName } from '$lib/branding';
|
import { appName } from '$lib/branding';
|
||||||
import MinstrelMark from './MinstrelMark.svelte';
|
import MinstrelMark from './MinstrelMark.svelte';
|
||||||
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import SearchInput from './SearchInput.svelte';
|
import SearchInput from './SearchInput.svelte';
|
||||||
|
|
||||||
@@ -92,11 +93,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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">
|
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
<div class="relative flex-shrink-0">
|
<div class="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
bind:this={menuButtonRef}
|
bind:this={menuButtonRef}
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ vi.mock('$lib/auth/store.svelte', () => ({
|
|||||||
logout: vi.fn().mockResolvedValue(undefined)
|
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 Shell from './Shell.svelte';
|
||||||
import { logout } from '$lib/auth/store.svelte';
|
import { logout } from '$lib/auth/store.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ function onSystemRebuilt(): void {
|
|||||||
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
|
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
|
// Wire once from +layout.svelte's mount. Opens the stream only while
|
||||||
// authenticated and closes it on logout; EventSource auto-reconnects on
|
// authenticated and closes it on logout; EventSource auto-reconnects on
|
||||||
// transient network drops.
|
// transient network drops.
|
||||||
@@ -42,8 +48,10 @@ export function useServerEvents(): void {
|
|||||||
if (typeof EventSource === 'undefined') return;
|
if (typeof EventSource === 'undefined') return;
|
||||||
const es = new EventSource('/api/events/stream');
|
const es = new EventSource('/api/events/stream');
|
||||||
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
es.addEventListener('notification.created', onNotificationCreated);
|
||||||
return () => {
|
return () => {
|
||||||
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
es.removeEventListener('notification.created', onNotificationCreated);
|
||||||
es.close();
|
es.close();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user