feat(settings): add email digest schedule controls
All checks were successful
CI / lint-check-build (pull_request) Successful in 3m26s
All checks were successful
CI / lint-check-build (pull_request) Successful in 3m26s
Adds digest schedule controls to the Email Digest card in Alert Settings: - emailHour, emailMinute, emailDayOfWeek, emailTimezone in SettingsValues persisted via AppData (publishSettings/publishEmailSettings) - Mail adapter (src/app/mail.ts) with Mail.register/unregister that sends PUT/DELETE /subscription/email to mailship with NIP-98 auth - UI: frequency toggle (daily/weekly), HH:MM time input, day-of-week picker (shown only for weekly), surface mailship errors via toast - VITE_MAILSHIP_URL env var for the mailship backend
This commit is contained in:
parent
e3e2e2f57c
commit
5c4e07bb8d
5 changed files with 291 additions and 3 deletions
1
.env
1
.env
|
|
@ -24,5 +24,6 @@ VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
|
||||||
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
VITE_THUMBNAIL_URL=https://vthumbs.coracle.social
|
||||||
VITE_GLITCHTIP_API_KEY=
|
VITE_GLITCHTIP_API_KEY=
|
||||||
GLITCHTIP_AUTH_TOKEN=
|
GLITCHTIP_AUTH_TOKEN=
|
||||||
|
VITE_MAILSHIP_URL=https://mailship.flotilla.social
|
||||||
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social
|
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.social
|
||||||
VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social
|
VITE_HOSTING_RELAY_DOMAIN=spaces.flotilla.social
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ export const HOSTING_BACKEND_URL = env("VITE_HOSTING_BACKEND_URL")
|
||||||
|
|
||||||
export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN")
|
export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN")
|
||||||
|
|
||||||
|
export const MAILSHIP_URL = env("VITE_MAILSHIP_URL")
|
||||||
|
|
||||||
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
|
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
|
||||||
|
|
||||||
export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL")
|
export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL")
|
||||||
|
|
|
||||||
111
src/app/mail.ts
Normal file
111
src/app/mail.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import {writable} from "svelte/store"
|
||||||
|
import {withGetter} from "@welshman/store"
|
||||||
|
import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
||||||
|
import {user} from "@app/core"
|
||||||
|
import {MAILSHIP_URL} from "@app/env"
|
||||||
|
|
||||||
|
export type MailSubscription = {
|
||||||
|
email: string
|
||||||
|
frequency: "daily" | "weekly"
|
||||||
|
hour: number
|
||||||
|
minute: number
|
||||||
|
dayOfWeek?: number
|
||||||
|
timezone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailState = {
|
||||||
|
email?: string
|
||||||
|
subscription?: MailSubscription
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailState = withGetter(writable<MailState>({}))
|
||||||
|
|
||||||
|
let cachedAuth: {pubkey: string; header: Promise<string>} | undefined
|
||||||
|
|
||||||
|
const getAuthHeader = (): Promise<string> => {
|
||||||
|
const {pubkey, signer} = user.get()
|
||||||
|
|
||||||
|
if (cachedAuth?.pubkey !== pubkey) {
|
||||||
|
const header = makeHttpAuth(MAILSHIP_URL, "GET")
|
||||||
|
.then(template => signer.sign(template))
|
||||||
|
.then(makeHttpAuthHeader)
|
||||||
|
|
||||||
|
header.catch(() => {
|
||||||
|
if (cachedAuth?.header === header) cachedAuth = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
cachedAuth = {pubkey, header}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedAuth.header
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Mail {
|
||||||
|
static async register(
|
||||||
|
email: string,
|
||||||
|
frequency: "daily" | "weekly",
|
||||||
|
hour: number,
|
||||||
|
minute: number,
|
||||||
|
dayOfWeek?: number,
|
||||||
|
timezone?: string,
|
||||||
|
) {
|
||||||
|
const authorization = await getAuthHeader()
|
||||||
|
|
||||||
|
const body: MailSubscription = {
|
||||||
|
email,
|
||||||
|
frequency,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
dayOfWeek,
|
||||||
|
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MAILSHIP_URL}/subscription/email`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {"Content-Type": "application/json", authorization},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const {error} = await res.json().catch(() => ({error: `Request failed (${res.status})`}))
|
||||||
|
|
||||||
|
throw new Error(error || `Request failed (${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription: MailSubscription = await res.json()
|
||||||
|
|
||||||
|
mailState.set({email, subscription})
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
} catch (e) {
|
||||||
|
mailState.set({email, error: (e as Error).message})
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async unregister() {
|
||||||
|
const authorization = await getAuthHeader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MAILSHIP_URL}/subscription/email`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {authorization},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const {error} = await res.json().catch(() => ({error: `Request failed (${res.status})`}))
|
||||||
|
|
||||||
|
throw new Error(error || `Request failed (${res.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailState.set({})
|
||||||
|
} catch (e) {
|
||||||
|
mailState.set({error: (e as Error).message})
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,10 @@ export type SettingsValues = {
|
||||||
font_size: number
|
font_size: number
|
||||||
alerts: SpaceNotificationSettings[]
|
alerts: SpaceNotificationSettings[]
|
||||||
zap_amounts: number[]
|
zap_amounts: number[]
|
||||||
|
emailHour: number
|
||||||
|
emailMinute: number
|
||||||
|
emailDayOfWeek: number | undefined
|
||||||
|
emailTimezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: SettingsValues = {
|
export const defaultSettings: SettingsValues = {
|
||||||
|
|
@ -43,6 +47,10 @@ export const defaultSettings: SettingsValues = {
|
||||||
font_size: 1.1,
|
font_size: 1.1,
|
||||||
alerts: [],
|
alerts: [],
|
||||||
zap_amounts: [21, 210, 2100, 21000],
|
zap_amounts: [21, 210, 2100, 21000],
|
||||||
|
emailHour: 17,
|
||||||
|
emailMinute: 0,
|
||||||
|
emailDayOfWeek: undefined,
|
||||||
|
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Settings extends DerivedPlugin<AppDataReader> {
|
export class Settings extends DerivedPlugin<AppDataReader> {
|
||||||
|
|
@ -97,6 +105,7 @@ export const notificationSettings = withGetter(
|
||||||
spaces: true,
|
spaces: true,
|
||||||
mentions: true,
|
mentions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
|
email: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,3 +157,9 @@ export const toggleRoomNotifications = (url: string, h: string) => {
|
||||||
|
|
||||||
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]})
|
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const publishEmailSettings = (
|
||||||
|
params: Partial<
|
||||||
|
Pick<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
|
||||||
|
>,
|
||||||
|
) => publishSettings(params)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import {Badge} from "@capawesome/capacitor-badge"
|
import {Badge} from "@capawesome/capacitor-badge"
|
||||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||||
|
import MailIcon from "@assets/icons/letter.svg?dataurl"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||||
|
|
@ -14,10 +15,21 @@
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {clearBadges} from "@app/notifications"
|
import {clearBadges} from "@app/notifications"
|
||||||
import {Push} from "@app/push"
|
import {Push} from "@app/push"
|
||||||
import {notificationSettings} from "@app/settings"
|
import {notificationSettings, userSettingsValues, publishEmailSettings} from "@app/settings"
|
||||||
|
import {Mail} from "@app/mail"
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
settings = {...notificationSettings.get()}
|
const userSettings = userSettingsValues.get()
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
...notificationSettings.get(),
|
||||||
|
emailFrequency: userSettings.emailDayOfWeek ? "weekly" : "daily",
|
||||||
|
emailHour: userSettings.emailHour,
|
||||||
|
emailMinute: userSettings.emailMinute,
|
||||||
|
emailDayOfWeek: userSettings.emailDayOfWeek,
|
||||||
|
emailTimezone: userSettings.emailTimezone,
|
||||||
|
emailAddress: "",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onsubmit = preventDefault(async () => {
|
const onsubmit = preventDefault(async () => {
|
||||||
|
|
@ -45,6 +57,45 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.email) {
|
||||||
|
const timezone =
|
||||||
|
settings.emailTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||||
|
|
||||||
|
if (!settings.emailAddress) {
|
||||||
|
pushToast({theme: "error", message: "Please enter an email address."})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Mail.register(
|
||||||
|
settings.emailAddress,
|
||||||
|
settings.emailFrequency,
|
||||||
|
settings.emailHour,
|
||||||
|
settings.emailMinute,
|
||||||
|
settings.emailDayOfWeek,
|
||||||
|
timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
await publishEmailSettings({
|
||||||
|
emailHour: settings.emailHour,
|
||||||
|
emailMinute: settings.emailMinute,
|
||||||
|
emailDayOfWeek: settings.emailDayOfWeek,
|
||||||
|
emailTimezone: timezone,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: (e as Error).message})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await Mail.unregister()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notificationSettings.set(settings)
|
notificationSettings.set(settings)
|
||||||
|
|
||||||
pushToast({message: "Your settings have been saved!"})
|
pushToast({message: "Your settings have been saved!"})
|
||||||
|
|
@ -54,7 +105,18 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let settings = $state({...notificationSettings.get()})
|
|
||||||
|
let settings = $state({
|
||||||
|
...notificationSettings.get(),
|
||||||
|
emailFrequency: (userSettingsValues.get().emailDayOfWeek ? "weekly" : "daily") as
|
||||||
|
| "daily"
|
||||||
|
| "weekly",
|
||||||
|
emailHour: userSettingsValues.get().emailHour,
|
||||||
|
emailMinute: userSettingsValues.get().emailMinute,
|
||||||
|
emailDayOfWeek: userSettingsValues.get().emailDayOfWeek,
|
||||||
|
emailTimezone: userSettingsValues.get().emailTimezone,
|
||||||
|
emailAddress: "",
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form {onsubmit}>
|
<form {onsubmit}>
|
||||||
|
|
@ -97,6 +159,103 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card flex flex-col gap-4 shadow-md">
|
||||||
|
<strong class="flex items-center gap-3 text-lg">
|
||||||
|
<Icon icon={MailIcon} />
|
||||||
|
Email Digest
|
||||||
|
</strong>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Enable email digest</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<ToggleInput bind:checked={settings.email} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{#if settings.email}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Email address</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
bind:value={settings.emailAddress} />
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Frequency</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex gap-1 rounded-lg bg-surface-dim p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cx("button button-sm rounded-md px-3 py-1", {
|
||||||
|
"button-primary": settings.emailFrequency === "daily",
|
||||||
|
"button-ghost": settings.emailFrequency !== "daily",
|
||||||
|
})}
|
||||||
|
onclick={() => {
|
||||||
|
settings.emailFrequency = "daily"
|
||||||
|
settings.emailDayOfWeek = undefined
|
||||||
|
}}>Daily</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cx("button button-sm rounded-md px-3 py-1", {
|
||||||
|
"button-primary": settings.emailFrequency === "weekly",
|
||||||
|
"button-ghost": settings.emailFrequency !== "weekly",
|
||||||
|
})}
|
||||||
|
onclick={() => {
|
||||||
|
settings.emailFrequency = "weekly"
|
||||||
|
settings.emailDayOfWeek = 1
|
||||||
|
}}>Weekly</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Time</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input 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}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
|
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
|
||||||
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
|
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue