Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 f11dba2336 feat(api): read-all takes an optional up_to cutoff (M489)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:27:29 -04:00
bvandeusenandClaude Opus 5.5 87f8ed6147 feat(web): notification settings per kind and channel (#5345, web half)
- A Notifications section on Settings has a row per kind and a toggle
  each for Inbox, Phone and Email. Labels are short, menu-style.
- Admin kinds sit under "Library health", for admins only.
- Toggles are optimistic and send only the kind and channel touched. A
  failed save reverts unless something newer has happened (snippet
  #5106's generation counter).
- With the inbox off, phone and email are disabled: they ride on it.
- When email isn't usable, one line says why. With no address it links
  to the profile. With no SMTP an admin gets a link to Integrations and a
  listener is simply told. Saving the profile refreshes the settings so
  the line clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:25:31 -04:00
bvandeusenandClaude Opus 5.5 956058d4a0 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>
2026-10-08 07:23:32 -04:00
17 changed files with 819 additions and 8 deletions
+15 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
@@ -170,7 +171,20 @@ func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *ht
if !ok {
return
}
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), user.ID); err != nil {
// Optional {"up_to": RFC3339}: only what existed then. A client replaying
// an offline "mark all read" sends the moment the user asked.
var body struct {
UpTo *time.Time `json:"up_to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
params := dbq.MarkAllNotificationsReadParams{UserID: user.ID}
if body.UpTo != nil {
params.UpTo = pgtype.Timestamptz{Time: *body.UpTo, Valid: true}
}
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), params); err != nil {
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
return
}
+28
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -127,6 +128,33 @@ func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
}
}
// A "mark all read" replayed from an offline queue carries the moment the
// user asked; what arrived after it stays unread.
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
h, pool := testHandlers(t)
alice := seedUser(t, pool, "notif-upto", "pw", false)
r := notificationsRouter(h)
notifyN(t, pool, alice, 1)
var page notificationsPageResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
seen := page.Items[0].CreatedAt
notifyN(t, pool, alice, 1)
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
t.Fatalf("read-all up_to = %d", code)
}
var count unreadCountResp
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
if count.UnreadCount != 1 {
t.Errorf("unread = %d, want 1 (the one that arrived later)", count.UnreadCount)
}
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
t.Errorf("malformed up_to = %d, want 400", code)
}
}
func strPtr(s string) *string { return &s }
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
+14 -3
View File
@@ -214,11 +214,22 @@ func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsPa
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
UPDATE user_notifications
SET read_at = now()
WHERE user_id = $1 AND read_at IS NULL
WHERE user_id = $1
AND read_at IS NULL
AND ($2::timestamptz IS NULL OR created_at <= $2)
`
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, userID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, userID)
type MarkAllNotificationsReadParams struct {
UserID pgtype.UUID
UpTo pgtype.Timestamptz
}
// up_to, when set, limits it to what existed when the user asked: a "mark
// all read" queued offline and replayed later must not mark notices that
// arrived in between, which the user never saw. A coalesced row updated
// since then carries a newer created_at, so it stays unread too.
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
if err != nil {
return 0, err
}
+7 -1
View File
@@ -57,9 +57,15 @@ UPDATE user_notifications
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
-- name: MarkAllNotificationsRead :execrows
-- up_to, when set, limits it to what existed when the user asked: a "mark
-- all read" queued offline and replayed later must not mark notices that
-- arrived in between, which the user never saw. A coalesced row updated
-- since then carries a newer created_at, so it stays unread too.
UPDATE user_notifications
SET read_at = now()
WHERE user_id = $1 AND read_at IS NULL;
WHERE user_id = sqlc.arg(user_id)
AND read_at IS NULL
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
-- name: TrimNotifications :execrows
-- Retention: read rows go after read_cutoff, and anything at all after
+1 -1
View File
@@ -151,7 +151,7 @@ func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
}
markAllRead := func() {
t.Helper()
if _, err := q.MarkAllNotificationsRead(ctx, admin.ID); err != nil {
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
t.Fatalf("mark read: %v", err)
}
}
+126
View File
@@ -0,0 +1,126 @@
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);
}
// Per-user settings: which channels each kind reaches. Kinds the user cannot
// receive (admin kinds, for a non-admin) are not listed at all.
export type NotificationKindSetting = {
kind: string;
admin_only: boolean;
inbox: boolean;
phone: boolean;
email: boolean;
};
export type NotificationChannel = 'inbox' | 'phone' | 'email';
export type NotificationSettings = {
kinds: NotificationKindSetting[];
email_available: boolean;
email_unavailable_reason?: 'no_address' | 'smtp_not_configured';
};
export type NotificationSettingChange = { kind: string } & Partial<Record<NotificationChannel, boolean>>;
export async function getNotificationSettings(): Promise<NotificationSettings> {
return api.get<NotificationSettings>('/api/me/notification-settings');
}
// Partial: only what the user touched, so two quick toggles on different rows
// cannot overwrite each other on the server.
export async function putNotificationSettings(
changes: NotificationSettingChange[]
): Promise<NotificationSettings> {
return api.put<NotificationSettings>('/api/me/notification-settings', { kinds: changes });
}
export function createNotificationSettingsQuery() {
return createQuery({
queryKey: qk.notificationSettings(),
queryFn: getNotificationSettings,
staleTime: 60_000
});
}
/** Short row labels, menu-style (rule 188). Unknown kinds show their key. */
export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
request_approved: 'Request approved',
request_rejected: 'Request declined',
request_completed: 'New music arrived',
request_pending: 'Requests to review',
quarantine_flagged: 'Tracks flagged',
scan_failed: 'Library scan failed',
tracks_missing: 'Tracks gone missing',
duplicates_found: 'Duplicates to review',
playback_errors: 'Playback errors'
};
+5
View File
@@ -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();
});
});
@@ -0,0 +1,132 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import {
NOTIFICATION_KIND_LABELS,
createNotificationSettingsQuery,
putNotificationSettings,
type NotificationChannel,
type NotificationKindSetting,
type NotificationSettingChange,
type NotificationSettings
} from '$lib/api/notifications';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
// Per-user notification settings (M489): one row per kind, a toggle per
// channel. Toggles are optimistic; a failed save puts the switch back.
const client = useQueryClient();
const queryStore = createNotificationSettingsQuery();
const settings = $derived($queryStore.data as NotificationSettings | undefined);
const forEveryone = $derived((settings?.kinds ?? []).filter((k) => !k.admin_only));
const forAdmins = $derived((settings?.kinds ?? []).filter((k) => k.admin_only));
const channels: { key: NotificationChannel; label: string }[] = [
{ key: 'inbox', label: 'Inbox' },
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' }
];
// Bumped by every local change, so a save resolving after a newer toggle
// does not overwrite it, and a failed save only reverts when nothing newer
// happened (snippet #5106's generation counter).
let generation = 0;
function commit(next: NotificationSettings) {
client.setQueryData(qk.notificationSettings(), next);
}
function withChannel(
k: NotificationKindSetting,
channel: NotificationChannel,
value: boolean
): NotificationKindSetting {
const next = { ...k };
next[channel] = value;
return next;
}
async function toggle(row: NotificationKindSetting, channel: NotificationChannel) {
if (!settings) return;
const prev = settings;
const value = !row[channel];
const gen = ++generation;
commit({
...prev,
kinds: prev.kinds.map((k) => (k.kind === row.kind ? withChannel(k, channel, value) : k))
});
const change: NotificationSettingChange = { kind: row.kind };
change[channel] = value;
try {
const saved = await putNotificationSettings([change]);
if (gen === generation) commit(saved);
} catch {
if (gen === generation) commit(prev);
pushToast("Couldn't save that setting. Try again in a moment.", 'error');
}
}
// Phone and email ride on the inbox: with the inbox off, nothing is stored
// to send. Their own choices are kept and come back when the inbox does.
function disabled(row: NotificationKindSetting, channel: NotificationChannel): boolean {
if (channel === 'inbox') return false;
if (!row.inbox) return true;
return channel === 'email' && !settings?.email_available;
}
function label(kind: string): string {
return NOTIFICATION_KIND_LABELS[kind] ?? kind;
}
</script>
{#snippet rows(list: NotificationKindSetting[])}
<div class="grid grid-cols-[1fr_repeat(3,3.5rem)] items-center gap-y-1 text-sm">
<span></span>
{#each channels as c (c.key)}
<span class="text-center text-xs text-text-secondary">{c.label}</span>
{/each}
{#each list as row (row.kind)}
<span class="py-1 text-text-primary">{label(row.kind)}</span>
{#each channels as c (c.key)}
<span class="flex justify-center">
<input
type="checkbox"
class="h-4 w-4 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
aria-label="{label(row.kind)}: {c.label}"
checked={row[c.key] && !disabled(row, c.key)}
disabled={disabled(row, c.key)}
onchange={() => toggle(row, c.key)}
/>
</span>
{/each}
{/each}
</div>
{/snippet}
<section id="notifications" class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Notifications</h2>
{#if !settings}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
{@render rows(forEveryone)}
{#if forAdmins.length > 0}
<h3 class="pt-2 text-sm font-medium text-text-secondary">Library health</h3>
{@render rows(forAdmins)}
{/if}
{#if !settings.email_available}
<p class="text-sm text-text-secondary" data-testid="email-unavailable">
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: <a href="#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
{:else if user.value?.is_admin}
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
{:else}
Email is off: this server doesn't send email.
{/if}
</p>
{/if}
{/if}
</section>
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
const setQueryData = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
createNotificationSettingsQuery: vi.fn(),
putNotificationSettings: vi.fn()
};
});
const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } }));
vi.mock('$lib/auth/store.svelte', () => ({
user: { get value() { return userState.current; } }
}));
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import NotificationSettings from './NotificationSettings.svelte';
import {
createNotificationSettingsQuery,
putNotificationSettings,
type NotificationKindSetting,
type NotificationSettings as Settings
} from '$lib/api/notifications';
const requesterKinds: NotificationKindSetting[] = [
{ kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true },
{ kind: 'request_completed', admin_only: false, inbox: true, phone: true, email: true }
];
const adminKinds: NotificationKindSetting[] = [
{ kind: 'tracks_missing', admin_only: true, inbox: true, phone: true, email: false }
];
function setSettings(s: Partial<Settings>) {
const data: Settings = { kinds: requesterKinds, email_available: true, ...s };
(createNotificationSettingsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
return data;
}
beforeEach(() => {
vi.clearAllMocks();
userState.current = { id: '1', username: 'alice', is_admin: false };
});
describe('NotificationSettings', () => {
test('a listener sees their kinds with a toggle per channel and no admin heading', () => {
setSettings({});
render(NotificationSettings);
expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'New music arrived: Email' })).toBeChecked();
expect(screen.queryByText('Library health')).toBeNull();
});
test('an admin also sees library health under its own heading', () => {
userState.current = { id: '1', username: 'root', is_admin: true };
setSettings({ kinds: [...requesterKinds, ...adminKinds] });
render(NotificationSettings);
expect(screen.getByText('Library health')).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Tracks gone missing: Email' })).not.toBeChecked();
});
test('toggling sends only that kind and channel, and shows the change at once', async () => {
const data = setSettings({});
(putNotificationSettings as ReturnType<typeof vi.fn>).mockResolvedValue(data);
render(NotificationSettings);
await fireEvent.click(screen.getByRole('checkbox', { name: 'New music arrived: Email' }));
expect(putNotificationSettings).toHaveBeenCalledWith([{ kind: 'request_completed', email: false }]);
const optimistic = setQueryData.mock.calls[0][1] as Settings;
expect(optimistic.kinds.find((k) => k.kind === 'request_completed')?.email).toBe(false);
});
test('a failed save puts the switch back and says so', async () => {
const data = setSettings({});
(putNotificationSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
render(NotificationSettings);
await fireEvent.click(screen.getByRole('checkbox', { name: 'Request approved: Phone' }));
await waitFor(() => expect(pushToast).toHaveBeenCalled());
expect(setQueryData).toHaveBeenLastCalledWith(['notificationSettings'], data);
});
test('inbox off disables phone and email for that kind', () => {
setSettings({
kinds: [{ kind: 'request_approved', admin_only: false, inbox: false, phone: true, email: true }]
});
render(NotificationSettings);
expect(screen.getByRole('checkbox', { name: 'Request approved: Phone' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
expect(screen.getByRole('checkbox', { name: 'Request approved: Inbox' })).toBeEnabled();
});
test('no email address: the email column is off and points at the profile', () => {
setSettings({ email_available: false, email_unavailable_reason: 'no_address' });
render(NotificationSettings);
expect(screen.getByRole('checkbox', { name: 'Request approved: Email' })).toBeDisabled();
expect(screen.getByRole('link', { name: 'add an email address in your profile' })).toHaveAttribute(
'href',
'#profile'
);
});
test('no SMTP: an admin is sent to the SMTP settings, a listener is just told', () => {
setSettings({ email_available: false, email_unavailable_reason: 'smtp_not_configured' });
const { unmount } = render(NotificationSettings);
expect(screen.getByTestId('email-unavailable')).toHaveTextContent("this server doesn't send email");
expect(screen.queryByRole('link', { name: "SMTP isn't set up" })).toBeNull();
unmount();
userState.current = { id: '1', username: 'root', is_admin: true };
render(NotificationSettings);
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
'href',
'/admin/integrations'
);
});
});
+4 -1
View File
@@ -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}
+13
View File
@@ -24,6 +24,19 @@ 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('$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';
+8
View File
@@ -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();
};
});
+6 -1
View File
@@ -31,6 +31,7 @@
import ServerVersion from '$lib/components/ServerVersion.svelte';
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
import NotificationSettings from '$lib/components/NotificationSettings.svelte';
const queryClient = useQueryClient();
@@ -131,6 +132,8 @@
email: profileForm.email,
});
pushToast('Profile saved.');
// Whether email notifications are possible depends on the address.
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
} catch (e: unknown) {
const code = errCode(e);
if (code === 'email_taken') pushToast('That email is already in use.', 'error');
@@ -346,6 +349,8 @@
<NormalizationSetting />
</section>
<NotificationSettings />
<section class="space-y-4 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">ListenBrainz</h2>
@@ -550,7 +555,7 @@
</section>
<!-- Profile card -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<section id="profile" class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Profile</h2>
<p class="text-sm text-text-secondary">
Your display name and recovery email.
@@ -25,6 +25,19 @@ vi.mock('$lib/api/listenbrainz', () => {
};
});
// The Notifications section has its own tests (NotificationSettings.test.ts);
// here it only needs a settled query so the page renders.
vi.mock('$lib/api/notifications', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/notifications')>();
const { mockQuery } = await import('../../test-utils/query');
return {
...mod,
createNotificationSettingsQuery: () =>
mockQuery({ data: { kinds: [], email_available: true } }),
putNotificationSettings: vi.fn()
};
});
// The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
+13
View File
@@ -11,6 +11,19 @@ vi.mock('$lib/api/listenbrainz', () => ({
setListenBrainzEnabled: vi.fn()
}));
// The Notifications section has its own tests (NotificationSettings.test.ts);
// here it only needs a settled query so the page renders.
vi.mock('$lib/api/notifications', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/notifications')>();
const { mockQuery } = await import('../../test-utils/query');
return {
...mod,
createNotificationSettingsQuery: () =>
mockQuery({ data: { kinds: [], email_available: true } }),
putNotificationSettings: vi.fn()
};
});
// The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>();