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
@@ -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>