diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 86945861..9599cab5 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -71,3 +71,56 @@ 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>; + +export async function getNotificationSettings(): Promise { + return api.get('/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 { + return api.put('/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 = { + 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' +}; diff --git a/web/src/lib/components/NotificationSettings.svelte b/web/src/lib/components/NotificationSettings.svelte new file mode 100644 index 00000000..1ae43d14 --- /dev/null +++ b/web/src/lib/components/NotificationSettings.svelte @@ -0,0 +1,132 @@ + + +{#snippet rows(list: NotificationKindSetting[])} +
+ + {#each channels as c (c.key)} + {c.label} + {/each} + {#each list as row (row.kind)} + {label(row.kind)} + {#each channels as c (c.key)} + + toggle(row, c.key)} + /> + + {/each} + {/each} +
+{/snippet} + +
+

Notifications

+ + {#if !settings} +

Loading…

+ {:else} + {@render rows(forEveryone)} + + {#if forAdmins.length > 0} +

Library health

+ {@render rows(forAdmins)} + {/if} + + {#if !settings.email_available} +

+ {#if settings.email_unavailable_reason === 'no_address'} + Email is off: add an email address in your profile. + {:else if user.value?.is_admin} + Email is off: SMTP isn't set up. + {:else} + Email is off: this server doesn't send email. + {/if} +

+ {/if} + {/if} +
diff --git a/web/src/lib/components/NotificationSettings.test.ts b/web/src/lib/components/NotificationSettings.test.ts new file mode 100644 index 00000000..d3078a2b --- /dev/null +++ b/web/src/lib/components/NotificationSettings.test.ts @@ -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; + return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/notifications', async (orig) => { + const actual = (await orig()) as Record; + 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) { + const data: Settings = { kinds: requesterKinds, email_available: true, ...s }; + (createNotificationSettingsQuery as ReturnType).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).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).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' + ); + }); +}); diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index 14738863..5d46dc92 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -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 // behaviour is covered in NotificationBell.test.ts. -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; -}); vi.mock('$lib/api/notifications', async () => { const { mockQuery } = await import('../../test-utils/query'); return { diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 64bd2b79..ce4b9226 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -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 @@ + +

ListenBrainz

@@ -550,7 +555,7 @@
-
+

Profile

Your display name and recovery email. diff --git a/web/src/routes/settings/Appearance.test.ts b/web/src/routes/settings/Appearance.test.ts index d982fb2f..a72b5492 100644 --- a/web/src/routes/settings/Appearance.test.ts +++ b/web/src/routes/settings/Appearance.test.ts @@ -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(); + 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(); diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts index 746c78bd..315d17df 100644 --- a/web/src/routes/settings/settings.test.ts +++ b/web/src/routes/settings/settings.test.ts @@ -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(); + 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();