Compare commits

..

No commits in common. "email-notifications" and "dev" have entirely different histories.

8 changed files with 11 additions and 772 deletions

View file

@ -5,11 +5,6 @@
#
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
# A .env in the build context is picked up by scripts/build.sh for branding config.
#
# Self-hosted deployments can override branding/feature settings by passing
# VITE_* build args below (e.g. VITE_EMAIL_NOTIFICATION_SERVER). Unset args keep
# the value from the tracked .env (scripts/build.sh sources it and restores the
# exported environment), so local overrides never clobber upstream defaults.
# https://pnpm.io/docker#example-3-build-on-cicd
FROM node:24-slim AS builder
@ -22,20 +17,9 @@ ENV NODE_OPTIONS=--max_old_space_size=16384
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm i --frozen-lockfile
COPY . .
ARG VITE_BUILD_HASH
ARG VITE_PLATFORM_URL
ARG VITE_PLATFORM_NAME
ARG VITE_PLATFORM_DESCRIPTION
ARG VITE_PLATFORM_ACCENT
ARG VITE_PLATFORM_SECONDARY
ARG VITE_DEFAULT_PUBKEYS
ARG VITE_PLATFORM_LOGO
ARG VITE_PLATFORM_RELAYS
ARG VITE_EMAIL_NOTIFICATION_SERVER
RUN bash -c 'for v in VITE_BUILD_HASH VITE_PLATFORM_URL VITE_PLATFORM_NAME VITE_PLATFORM_DESCRIPTION VITE_PLATFORM_ACCENT VITE_PLATFORM_SECONDARY VITE_DEFAULT_PUBKEYS VITE_PLATFORM_LOGO VITE_PLATFORM_RELAYS VITE_EMAIL_NOTIFICATION_SERVER; do if [ -n "${!v}" ]; then export "$v=${!v}"; fi; done; pnpm run build'
RUN bash -c 'for v in VITE_BUILD_HASH VITE_PLATFORM_URL VITE_PLATFORM_NAME VITE_PLATFORM_DESCRIPTION VITE_PLATFORM_ACCENT VITE_PLATFORM_SECONDARY VITE_DEFAULT_PUBKEYS VITE_PLATFORM_LOGO VITE_PLATFORM_RELAYS VITE_EMAIL_NOTIFICATION_SERVER; do if [ -n "${!v}" ]; then export "$v=${!v}"; fi; done; pnpm run build:server'
RUN pnpm run build
RUN pnpm run build:server
FROM node:24-slim AS production
ENV NODE_ENV=production

View file

@ -1,26 +0,0 @@
<script lang="ts">
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {popModal} from "@app/modal"
const gotIt = () => popModal()
</script>
<Modal
><ModalBody>
<ModalHeader>
<ModalTitle>Email notifications</ModalTitle>
</ModalHeader>
<p>
For a community's new messages to appear in your email digest, its relay must support NIP-9a
(email notifications).
</p>
<p>
If a community is missing from your digest, ask the community admin to enable NIP-9a on their
relay.
</p>
<Button class="button button-primary" onclick={gotIt}>Got it</Button>
</ModalBody></Modal>

View file

@ -16,8 +16,6 @@ export const PUSH_SERVER = env("VITE_PUSH_SERVER")
export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE"))
export const EMAIL_NOTIFICATION_SERVER = import.meta.env.VITE_EMAIL_NOTIFICATION_SERVER
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl)

View file

@ -1,420 +0,0 @@
import {call, hash, maybe, spec, throttle} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {Address, DELETE, MESSAGE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
import {merged} from "@welshman/store"
import {Relays, User} from "@welshman/app"
import {buildUrl} from "@lib/util"
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
import {EMAIL_NOTIFICATION_SERVER, PUSH_BRIDGE} from "@app/env"
import {ensureEmailSalt, userSettingsValues} from "@app/settings"
import {emailNotificationState} from "@app/push/adapters/common"
import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content"
// Email digests sync their own kind 30390 subscriptions. Unlike push, the email
// subscription is driven by the email digest setting (SettingsValues.email) plus
// per-space alert preferences, and must NOT be gated on the push notificationSettings
// toggles - otherwise turning off push "new activity" would delete the 30390 and stop
// email digests entirely.
const syncRelaySubscriptionsForEmail = (
signal: AbortSignal,
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
) => {
const $pubkey = User.require(app.get()).pubkey
const unsubscribeSpaces = merged([roomLists.get().urls($pubkey).$, userSettingsValues]).subscribe(
throttle(3000, ([$spaceUrls, {email, alerts}]) => {
const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $spaceUrls) {
const filters: Filter[] = []
const ignore: Filter[] = []
if (!email) {
sync(url, "spaces", filters, [])
continue
}
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
if (notify) {
if (exceptions.length > 0) {
ignore.push({"#h": exceptions})
}
filters.push(...baseFilters)
} else if (exceptions.length > 0) {
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
}
// Mentions are part of the digest regardless of push settings, and
// guarantee there is always at least one filter so the subscription
// stays live while email digests are enabled.
filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]})))
sync(url, "spaces", filters, ignore)
}
}),
)
const unsubscribeMessages = merged([
messagingRelayLists.get().urls($pubkey).$,
userSettingsValues,
]).subscribe(
throttle(3000, ([$messagingUrls, {email}]) => {
for (const url of $messagingUrls) {
sync(url, "messages", email ? [{kinds: DM_KINDS, "#p": [$pubkey]}] : [], [])
}
}),
)
signal.addEventListener("abort", () => {
unsubscribeSpaces()
unsubscribeMessages()
})
}
export class MailNotifications {
_controller = maybe<AbortController>()
// In-flight register promise, so overlapping calls (settings page submit plus
// Mail.sync) coalesce into a single PUT instead of racing two subscriptions.
_register = maybe<Promise<void>>()
async register(
email: string,
frequency: string,
hour = 17,
minute = 0,
dayOfWeek?: number,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
) {
if (this._register) {
return this._register
}
this._register = call(async () => {
try {
const $user = User.require(app.get())
const $signer = $user.signer
if (!$signer) {
emailNotificationState.set({
error: "No signer available. Check your browser extension.",
})
throw new Error("No signer available")
}
if (!EMAIL_NOTIFICATION_SERVER) {
emailNotificationState.set({error: "Email notification server is not configured."})
throw new Error("Email notification server not configured")
}
const body = {email, frequency, hour, minute, dayOfWeek, timezone}
const bodyJson = JSON.stringify(body)
let authEvent
try {
authEvent = await $signer.sign(
await makeHttpAuth(
buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"),
"PUT",
bodyJson,
),
)
} catch (e) {
emailNotificationState.set({
error: "Failed to create authentication. Check your signer.",
})
throw new Error("Failed to sign NIP-98 auth", {cause: e})
}
const authHeader = makeHttpAuthHeader(authEvent)
let res
try {
res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), {
method: "PUT",
body: bodyJson,
headers: {Authorization: authHeader, "Content-Type": "application/json"},
})
} catch (e) {
emailNotificationState.set({
error: `Could not reach email server. Check your connection.`,
})
throw new Error("Network error", {cause: e})
}
if (!res.ok) {
const text = await res.text().catch(() => "unknown error")
emailNotificationState.set({error: `Registration failed: ${text}`})
throw new Error(`Registration failed: ${text}`)
}
const {key, callback} = await res.json()
emailNotificationState.set({key, callback, email, frequency, error: undefined})
} finally {
this._register = undefined
}
})
return this._register
}
// Fetch the existing subscription for this pubkey (if any) without creating
// or changing anything. Used at boot to restore state instead of re-PUTting,
// which would otherwise re-send a confirmation email for an unconfirmed
// address on every reload.
async lookup(): Promise<
| {
key: string
callback: string
email: string
frequency: string
confirmed: boolean
}
| undefined
> {
const $signer = app.get().user?.signer
if (!$signer || !EMAIL_NOTIFICATION_SERVER) {
return undefined
}
try {
const authEvent = await $signer.sign(
await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), "GET"),
)
const authHeader = makeHttpAuthHeader(authEvent)
const res = await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", "email"), {
method: "GET",
headers: {Authorization: authHeader},
})
if (!res.ok) {
return undefined
}
return await res.json()
} catch {
return undefined
}
}
async unregister() {
const {key} = emailNotificationState.get()
if (!key) {
return
}
const $signer = app.get().user?.signer
if (!$signer) {
return
}
try {
const authEvent = await $signer.sign(
await makeHttpAuth(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), "DELETE"),
)
const authHeader = makeHttpAuthHeader(authEvent)
await fetch(buildUrl(EMAIL_NOTIFICATION_SERVER, "subscription", key), {
method: "DELETE",
headers: {Authorization: authHeader},
})
} catch {
// Best effort
}
emailNotificationState.set({})
}
async enable() {
if (this._controller) {
return
}
const {email, frequency} = emailNotificationState.get()
if (!EMAIL_NOTIFICATION_SERVER) {
console.warn("Email notification server not configured")
return
}
this._controller = new AbortController()
// Register if we don't have a subscription yet
if (!emailNotificationState.get().callback && email && frequency) {
const {emailHour, emailMinute, emailDayOfWeek, emailTimezone} = userSettingsValues.get()
await this.register(email, frequency, emailHour, emailMinute, emailDayOfWeek, emailTimezone)
}
// Sync kind 30390 subscriptions for the email digest. Filters are derived
// from the email setting + per-space alerts, independent of push toggles.
syncRelaySubscriptionsForEmail(
this._controller.signal,
async (url: string, key: string, filters: Filter[], ignore: Filter[]) => {
const {callback} = emailNotificationState.get()
if (!callback) {
return
}
if (filters.length > 0) {
await this._syncRelay(url, key, filters, ignore)
} else {
await this._unsyncRelay(url, key)
}
},
)
}
async disable() {
this._controller?.abort()
this._controller = undefined
await this.unregister()
}
sync() {
let reconciledPubkey: string | undefined
return userSettingsValues.subscribe(
async ({
email,
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
}) => {
const $pubkey = app.get().user?.pubkey
if (!$pubkey || reconciledPubkey === $pubkey) {
return
}
// Wait for the real stored settings. userSettingsValues is projected from
// AppData and reads as the default (email: false, emailAddress: "") until
// that loads — acting on that would race the disk with a no-op. Only a
// truthy email comes from stored settings, so keep waiting until we see
// it (a user who has email off simply never reconciles, which is correct).
if (!email || !emailAddress) {
return
}
reconciledPubkey = $pubkey
// GET-first: if a subscription already exists for this pubkey with the
// same email and cadence, restore its state (key/callback) instead of
// PUTting. A PUT here would make mailship re-send a confirmation email
// for an unconfirmed address on every app reload. Only register when
// there's nothing to restore or the settings changed.
const existing = await this.lookup()
try {
if (!existing) {
await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) {
await this.register(
emailAddress,
emailFrequency,
emailHour,
emailMinute,
emailDayOfWeek,
emailTimezone,
)
} else {
emailNotificationState.set({
key: existing.key,
callback: existing.callback,
email: existing.email,
frequency: existing.frequency,
error: undefined,
})
}
} catch {
return
}
await this.enable()
},
)
}
_getSubscriptionIdentifier = async (relay: string, key: string) => {
const salt = await ensureEmailSalt()
return String(hash(relay + key + salt + "email"))
}
_getPushUrl = async (url: string) => {
for (const candidate of [url, PUSH_BRIDGE]) {
const relay = await app.get().use(Relays).load(candidate)
if (relay?.hasNip("9a")) {
return candidate
}
}
}
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
const {callback} = emailNotificationState.get()
if (!callback) {
return
}
const url = await this._getPushUrl(relay)
if (!url) {
console.warn(`Failed to subscribe ${relay} to email notifications: unsupported`)
return
}
const identifier = await this._getSubscriptionIdentifier(relay, key)
const thunk = thunks.get().publish({
relays: [url],
event: makeEvent(30390, {
tags: [
["d", identifier],
["relay", relay],
["callback", callback],
["include_event", "true"],
...ignore.map(f => ["ignore", JSON.stringify(f)]),
...filters.map(f => ["filter", JSON.stringify(f)]),
],
}),
})
const error = await thunk.waitForError()
if (error) {
console.warn(`Failed to subscribe ${relay} to email notifications:`, error)
}
}
_unsyncRelay = async (relay: string, key: string) => {
const url = await this._getPushUrl(relay)
if (!url) {
return
}
const $pubkey = User.require(app.get()).pubkey
const identifier = await this._getSubscriptionIdentifier(relay, key)
const address = new Address(30390, $pubkey, identifier).toString()
const event = makeEvent(DELETE, {tags: [["a", address]]})
const error = await thunks
.get()
.publish({relays: [url], event})
.waitForError()
if (error) {
console.warn(`Failed to unsubscribe ${relay} from email notifications:`, error)
}
}
}
export const Mail = new MailNotifications()

View file

@ -40,16 +40,6 @@ export const requiresFallback =
export const pushState = withGetter(writable<PushState>({useFallback: requiresFallback}))
export type EmailNotificationState = {
key?: string
callback?: string
email?: string
frequency?: string
error?: string
}
export const emailNotificationState = withGetter(writable<EmailNotificationState>({}))
export interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
disable: () => Promise<void>

View file

@ -1,6 +1,6 @@
import type {Writable} from "svelte/store"
import {derived, writable} from "svelte/store"
import {append, call, equals, randomId, remove, spec} from "@welshman/lib"
import {append, equals, remove, spec} from "@welshman/lib"
import {APP_DATA} from "@welshman/util"
import {withGetter} from "@welshman/store"
import {AppData} from "@welshman/domain"
@ -11,8 +11,6 @@ import {app, fromApp, usePlugin} from "@app/core"
export const SETTINGS = "flotilla/settings"
const emailSaltsByPubkey = new Map<string, Promise<string>>()
export enum RelayAuthMode {
Aggressive = "aggressive",
Conservative = "conservative",
@ -36,17 +34,9 @@ export type SettingsValues = {
font_size: number
alerts: SpaceNotificationSettings[]
zap_amounts: number[]
email: boolean
emailAddress: string
emailFrequency: string
emailSalt: string
openrouter_key: string
min_dm_pow: number
min_dm_wot: number
emailHour: number
emailMinute: number
emailDayOfWeek: number | undefined
emailTimezone: string
}
export const defaultSettings: SettingsValues = {
@ -59,17 +49,9 @@ export const defaultSettings: SettingsValues = {
font_size: 1.1,
alerts: [],
zap_amounts: [21, 210, 2100, 21000],
email: false,
emailAddress: "",
emailFrequency: "daily",
emailSalt: "",
openrouter_key: "",
min_dm_pow: 16,
min_dm_wot: 3,
emailHour: 17,
emailMinute: 0,
emailDayOfWeek: undefined,
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
}
export class Settings extends DerivedPlugin<AppDataReader> {
@ -182,7 +164,6 @@ export const notificationSettings = withGetter(
spaces: true,
mentions: true,
messages: true,
email: false,
}),
)
@ -201,35 +182,6 @@ export const publishSettings = async (params: Partial<SettingsValues>) => {
return command.publish()
}
// A per-account salt for building deterministic identifiers (e.g. kind 30390 email
// subscription ids) that reproduce on any device of the user, stored with the settings.
export const ensureEmailSalt = () => {
const $pubkey = User.require(app.get()).pubkey
const existing = emailSaltsByPubkey.get($pubkey)
if (existing) {
return existing
}
// Force-load from the network so a salt synced by another device wins over a fresh one,
// and memoize the in-flight promise so parallel syncs (one per space relay) share a salt.
const promise = call(async () => {
await settings.get().forceLoad($pubkey)
const salt = userSettingsValues.get().emailSalt
if (salt) {
return salt
}
const emailSalt = randomId()
await publishSettings({emailSalt})
return emailSalt
})
emailSaltsByPubkey.set($pubkey, promise)
return promise
}
export const addTrustedRelay = (url: string) =>
publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))})
@ -277,9 +229,3 @@ export const toggleRoomMuted = (url: string, h: string) => {
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]})
}
export const publishEmailSettings = (
params: Partial<
Pick<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
>,
) => publishSettings(params)

View file

@ -42,7 +42,6 @@
import * as notifications from "@app/notifications"
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
import {Push} from "@app/push"
import {Mail} from "@app/mail"
import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
import {syncKeyboard} from "@app/keyboard"
import {getPageTitle} from "@app/title"
@ -241,9 +240,6 @@
// Initialize background notifications
unsubscribers.push(Push.sync())
// Sync email notification prefs across devices and register this device
unsubscribers.push(Mail.sync())
// Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync
// application data against the new identity's relays. Wait for the new app's storage to
// load the way startup does, so sync reconciles against cached events.
@ -283,6 +279,10 @@
theme: "error",
timeout: 60_000,
message: "Your signer isn't responding.",
action: {
message: "Details",
onclick: () => goto("/settings/profile"),
},
})
}
}),

View file

@ -1,8 +1,6 @@
<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"
@ -14,46 +12,12 @@
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,
emailHour: $userSettingsValues.emailHour,
emailMinute: $userSettingsValues.emailMinute,
emailDayOfWeek: $userSettingsValues.emailDayOfWeek,
emailTimezone: $userSettingsValues.emailTimezone,
}
}
import {notificationSettings} from "@app/settings"
const reset = () => {
settings = buildSettings()
clearError()
settings = {...notificationSettings.get()}
}
const onsubmit = preventDefault(async () => {
@ -81,52 +45,7 @@
}
}
// Handle email notification registration
if (settings.email && settings.emailAddress) {
try {
await Mail.register(
settings.emailAddress,
settings.emailFrequency,
settings.emailHour,
settings.emailMinute,
settings.emailDayOfWeek,
settings.emailTimezone,
)
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,
emailHour: settings.emailHour,
emailMinute: settings.emailMinute,
emailDayOfWeek: settings.emailDayOfWeek,
emailTimezone: settings.emailTimezone,
})
notificationSettings.set({
push: settings.push,
sound: settings.sound,
badge: settings.badge,
spaces: settings.spaces,
mentions: settings.mentions,
messages: settings.messages,
email: settings.email,
})
notificationSettings.set(settings)
pushToast({message: "Your settings have been saved!"})
} finally {
@ -134,51 +53,8 @@
}
})
// 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()
})
let settings = $state({...notificationSettings.get()})
</script>
<form {onsubmit}>
@ -251,115 +127,6 @@
{/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={e => {
if (e.currentTarget.value === "weekly" && !settings.emailDayOfWeek) {
settings.emailDayOfWeek = 1
}
clearError()
}}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Time</p>
{/snippet}
{#snippet input()}
<input
type="time"
class="input input-bordered w-auto"
value={String(settings.emailHour).padStart(2, "0") +
":" +
String(settings.emailMinute).padStart(2, "0")}
oninput={e => {
const [h, m] = e.currentTarget.value.split(":").map(Number)
settings.emailHour = h
settings.emailMinute = m
}} />
{/snippet}
</FieldInline>
{#if settings.emailFrequency === "weekly"}
<FieldInline>
{#snippet label()}
<p>Day of week</p>
{/snippet}
{#snippet input()}
<div class="flex gap-1">
{#each [{label: "M", value: 1}, {label: "T", value: 2}, {label: "W", value: 3}, {label: "T", value: 4}, {label: "F", value: 5}, {label: "S", value: 6}, {label: "S", value: 7}] as day (day.value)}
<button
type="button"
class={cx("button button-sm size-8 rounded-md", {
"button-primary": settings.emailDayOfWeek === day.value,
"button-ghost": settings.emailDayOfWeek !== day.value,
})}
onclick={() => {
settings.emailDayOfWeek = day.value
}}>{day.label}</button>
{/each}
</div>
{/snippet}
</FieldInline>
{/if}
{/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>