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>
This commit is contained in:
2026-10-08 07:25:31 -04:00
co-authored by Claude Opus 5.5
parent 956058d4a0
commit 87f8ed6147
7 changed files with 346 additions and 5 deletions
+53
View File
@@ -71,3 +71,56 @@ export function badgeLabel(count: number): string {
if (count <= 0) return ''; if (count <= 0) return '';
return count > 9 ? '9+' : String(count); 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'
};
@@ -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
View File
@@ -26,10 +26,6 @@ vi.mock('$lib/auth/store.svelte', () => ({
// The bell's queries need a QueryClient the Shell tests do not provide; its own // The bell's queries need a QueryClient the Shell tests do not provide; its own
// behaviour is covered in NotificationBell.test.ts. // 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 () => { vi.mock('$lib/api/notifications', async () => {
const { mockQuery } = await import('../../test-utils/query'); const { mockQuery } = await import('../../test-utils/query');
return { return {
+6 -1
View File
@@ -31,6 +31,7 @@
import ServerVersion from '$lib/components/ServerVersion.svelte'; import ServerVersion from '$lib/components/ServerVersion.svelte';
import ActiveSessions from '$lib/components/ActiveSessions.svelte'; import ActiveSessions from '$lib/components/ActiveSessions.svelte';
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte'; import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
import NotificationSettings from '$lib/components/NotificationSettings.svelte';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -131,6 +132,8 @@
email: profileForm.email, email: profileForm.email,
}); });
pushToast('Profile saved.'); pushToast('Profile saved.');
// Whether email notifications are possible depends on the address.
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
} catch (e: unknown) { } catch (e: unknown) {
const code = errCode(e); const code = errCode(e);
if (code === 'email_taken') pushToast('That email is already in use.', 'error'); if (code === 'email_taken') pushToast('That email is already in use.', 'error');
@@ -346,6 +349,8 @@
<NormalizationSetting /> <NormalizationSetting />
</section> </section>
<NotificationSettings />
<section class="space-y-4 rounded border border-border bg-surface p-4"> <section class="space-y-4 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">ListenBrainz</h2> <h2 class="text-lg font-semibold">ListenBrainz</h2>
@@ -550,7 +555,7 @@
</section> </section>
<!-- Profile card --> <!-- 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> <h2 class="text-lg font-semibold">Profile</h2>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Your display name and recovery email. 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. // The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => { vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>(); const mod = await importOriginal<typeof import('$lib/api/normalization')>();
+13
View File
@@ -11,6 +11,19 @@ vi.mock('$lib/api/listenbrainz', () => ({
setListenBrainzEnabled: vi.fn() 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. // The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => { vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>(); const mod = await importOriginal<typeof import('$lib/api/normalization')>();