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_GLITCHTIP_API_KEY=
|
||||
GLITCHTIP_AUTH_TOKEN=
|
||||
VITE_MAILSHIP_URL=https://mailship.flotilla.social
|
||||
VITE_HOSTING_BACKEND_URL=https://api.hosting.coracle.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 MAILSHIP_URL = env("VITE_MAILSHIP_URL")
|
||||
|
||||
export const DUFFLEPUD_URL = "https://dufflepud.coracle.social"
|
||||
|
||||
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
|
||||
alerts: SpaceNotificationSettings[]
|
||||
zap_amounts: number[]
|
||||
emailHour: number
|
||||
emailMinute: number
|
||||
emailDayOfWeek: number | undefined
|
||||
emailTimezone: string
|
||||
}
|
||||
|
||||
export const defaultSettings: SettingsValues = {
|
||||
|
|
@ -43,6 +47,10 @@ export const defaultSettings: SettingsValues = {
|
|||
font_size: 1.1,
|
||||
alerts: [],
|
||||
zap_amounts: [21, 210, 2100, 21000],
|
||||
emailHour: 17,
|
||||
emailMinute: 0,
|
||||
emailDayOfWeek: undefined,
|
||||
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
}
|
||||
|
||||
export class Settings extends DerivedPlugin<AppDataReader> {
|
||||
|
|
@ -97,6 +105,7 @@ export const notificationSettings = withGetter(
|
|||
spaces: true,
|
||||
mentions: 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]}]})
|
||||
}
|
||||
|
||||
export const publishEmailSettings = (
|
||||
params: Partial<
|
||||
Pick<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
|
||||
>,
|
||||
) => publishSettings(params)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {Capacitor} from "@capacitor/core"
|
||||
import {Badge} from "@capawesome/capacitor-badge"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import MailIcon from "@assets/icons/letter.svg?dataurl"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
import ToggleInput from "@lib/components/ToggleInput.svelte"
|
||||
|
|
@ -14,10 +15,21 @@
|
|||
import {pushToast} from "@app/toast"
|
||||
import {clearBadges} from "@app/notifications"
|
||||
import {Push} from "@app/push"
|
||||
import {notificationSettings} from "@app/settings"
|
||||
import {notificationSettings, userSettingsValues, publishEmailSettings} from "@app/settings"
|
||||
import {Mail} from "@app/mail"
|
||||
|
||||
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 () => {
|
||||
|
|
@ -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)
|
||||
|
||||
pushToast({message: "Your settings have been saved!"})
|
||||
|
|
@ -54,7 +105,18 @@
|
|||
})
|
||||
|
||||
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>
|
||||
|
||||
<form {onsubmit}>
|
||||
|
|
@ -97,6 +159,103 @@
|
|||
{/snippet}
|
||||
</FieldInline>
|
||||
</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
|
||||
class={cx("card bg-surface flex flex-col gap-4 shadow-md", {
|
||||
"pointer-events-none opacity-50": !settings.badge && !settings.sound && !settings.push,
|
||||
|
|
|
|||
Loading…
Reference in a new issue