flotilla/src/routes/settings/alerts/+page.svelte
mplorentz 63cf801ef1
All checks were successful
CI / lint-check-build (pull_request) Successful in 3m23s
Fix settings hydration
2026-09-21 16:14:02 -04:00

306 lines
9.6 KiB
Svelte

<script lang="ts">
import cx from "classnames"
import {derived} from "svelte/store"
import {sleep} from "@welshman/lib"
import type {Relay} from "@welshman/domain"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import Bell from "@assets/icons/bell.svg?dataurl"
import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte"
import ToggleInput from "@lib/components/ToggleInput.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {clearBadges} from "@app/notifications"
import {Push} from "@app/push"
import {Mail} from "@app/mail"
import {notificationSettings, publishSettings, userSettingsValues} from "@app/settings"
import {emailNotificationState} from "@app/push/adapters/common"
import {EMAIL_NOTIFICATION_SERVER} from "@app/env"
import {relays} from "@app/core"
import {userSpaceUrls} from "@app/rooms"
import EmailNotificationsInfo from "@app/components/EmailNotificationsInfo.svelte"
const clearError = () => {
if ($emailNotificationState.error) {
emailNotificationState.set({...emailNotificationState.get(), error: undefined})
}
}
const buildSettings = () => {
const local = notificationSettings.get()
return {
push: local.push,
sound: local.sound,
badge: local.badge,
spaces: local.spaces,
mentions: local.mentions,
messages: local.messages,
email: $userSettingsValues.email,
emailAddress: $userSettingsValues.emailAddress,
emailFrequency: $userSettingsValues.emailFrequency,
}
}
const reset = () => {
settings = buildSettings()
clearError()
}
const onsubmit = preventDefault(async () => {
loading = true
try {
if (!settings.badge) {
clearBadges()
}
let permission = "granted"
if (settings.push) {
permission = await Push.request()
if (!permission.startsWith("granted")) {
await sleep(300)
settings.push = false
return pushToast({
theme: "error",
message: `Failed to request notification permissions (${permission}).`,
})
}
}
// Handle email notification registration
if (settings.email && settings.emailAddress) {
try {
await Mail.register(settings.emailAddress, settings.emailFrequency)
await Mail.enable()
} catch (e) {
settings.email = false
console.error(e)
await sleep(300)
return pushToast({
theme: "error",
message: `Failed to enable email notifications.`,
action: {message: "Details", onclick: () => {}},
})
}
} else if (!settings.email) {
await Mail.disable()
}
// Sync email prefs across devices via AppData
await publishSettings({
email: settings.email,
emailAddress: settings.emailAddress,
emailFrequency: settings.emailFrequency,
})
notificationSettings.set({
push: settings.push,
sound: settings.sound,
badge: settings.badge,
spaces: settings.spaces,
mentions: settings.mentions,
messages: settings.messages,
})
pushToast({message: "Your settings have been saved!"})
} finally {
loading = false
}
})
// Communities whose relay doesn't advertise NIP-9a won't be included in the email digest.
// Subscribing to each space's NIP-11 info triggers a fetch for any that hasn't loaded yet.
const communitiesWithoutEmail = derived(
userSpaceUrls,
($urls, set: (value: string[]) => void) => {
const unsubscribers = $urls.map(url =>
relays
.get()
.one(url)
.subscribe(() =>
set(
$urls
.map(url => relays.get().get(url))
.filter((relay): relay is Relay => relay !== undefined && !relay.hasNip("9a"))
.map(relay => relay.display()),
),
),
)
return () =>
unsubscribers.forEach(unsubscribe => {
unsubscribe()
})
},
)
const showEmailInfo = () => pushModal(EmailNotificationsInfo)
let loading = $state(false)
let settings = $state(buildSettings())
let settingsHydrated = $state(false)
$effect(() => {
if (settingsHydrated) return
const {email, emailAddress} = $userSettingsValues
if (!email && !emailAddress) return
settingsHydrated = true
settings = buildSettings()
})
</script>
<form {onsubmit}>
<PageContent>
<div class="card flex flex-col gap-4 shadow-md">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={Bell} />
Alert Settings
</strong>
{#await Badge.isSupported()}
<!-- pass -->
{:then { isSupported }}
{#if isSupported}
<FieldInline>
{#snippet label()}
<p>Show badge for unread alerts</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.badge} />
{/snippet}
</FieldInline>
{/if}
{/await}
{#if !Capacitor.isNativePlatform()}
<FieldInline>
{#snippet label()}
<p>Play sound for new activity</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.sound} />
{/snippet}
</FieldInline>
{/if}
<FieldInline>
{#snippet label()}
<p>Enable push notifications</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.push} />
{/snippet}
</FieldInline>
</div>
<div
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
})}>
<strong class="text-lg">Alert Types</strong>
<FieldInline>
{#snippet label()}
<p>Notify me about new activity</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.spaces} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Always notify me when mentioned</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.mentions} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Notify me about new messages</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.messages} />
{/snippet}
</FieldInline>
</div>
{#if EMAIL_NOTIFICATION_SERVER}
<div class="card bg-surface flex flex-col gap-4 shadow-md">
<strong class="text-lg">Email Digest Settings</strong>
<FieldInline>
{#snippet label()}
<p>Receive email digest of missed activity</p>
{/snippet}
{#snippet input()}
<ToggleInput bind:checked={settings.email} />
{/snippet}
</FieldInline>
{#if settings.email}
{#if $communitiesWithoutEmail.length > 0}
<div class="flex flex-col gap-1 rounded-2xl border border-warning bg-warning/10 p-4">
<p class="flex items-center gap-2 text-sm text-warning">
{#if $communitiesWithoutEmail.length === 1}
1 of your communities does not support email notifications.
{:else}
{$communitiesWithoutEmail.length} of your communities do not support email notifications.
{/if}
<Button class="link" onclick={showEmailInfo}>Learn more</Button>
</p>
<ul class="list-inside list-disc text-sm text-warning">
{#each $communitiesWithoutEmail as name (name)}
<li>{name}</li>
{/each}
</ul>
</div>
{/if}
<FieldInline>
{#snippet label()}
<p>Email address</p>
{/snippet}
{#snippet input()}
<input
type="email"
class="input input-bordered w-full"
bind:value={settings.emailAddress}
oninput={clearError}
placeholder="you@example.com" />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Frequency</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered"
bind:value={settings.emailFrequency}
onchange={clearError}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if $emailNotificationState.error}
<div class="text-sm text-error p-3">
{$emailNotificationState.error}
</div>
{/if}
</div>
{/if}
<div class="card sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button>
<Button type="submit" class="button button-primary" disabled={loading}>
<Spinner {loading}>Save Changes</Spinner>
</Button>
</div>
</PageContent>
</form>