Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')>();
|
||||||
|
|||||||
@@ -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')>();
|
||||||
|
|||||||
Reference in New Issue
Block a user