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:
@@ -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>
|
||||
Reference in New Issue
Block a user