Email notifications: settings UI + mail adapter #4
3 changed files with 147 additions and 39 deletions
|
|
@ -79,7 +79,14 @@ export class MailNotifications {
|
|||
// Mail.sync) coalesce into a single PUT instead of racing two subscriptions.
|
||||
_register = maybe<Promise<void>>()
|
||||
|
||||
async register(email: string, frequency: string) {
|
||||
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 () => {
|
||||
|
|
@ -99,7 +106,7 @@ export class MailNotifications {
|
|||
throw new Error("Email notification server not configured")
|
||||
}
|
||||
|
||||
const body = {email, frequency}
|
||||
const body = {email, frequency, hour, minute, dayOfWeek, timezone}
|
||||
const bodyJson = JSON.stringify(body)
|
||||
|
||||
let authEvent
|
||||
|
|
@ -224,7 +231,8 @@ export class MailNotifications {
|
|||
|
||||
// Register if we don't have a subscription yet
|
||||
if (!emailNotificationState.get().callback && email && frequency) {
|
||||
await this.register(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
|
||||
|
|
@ -254,46 +262,70 @@ export class MailNotifications {
|
|||
sync() {
|
||||
let reconciledPubkey: string | undefined
|
||||
|
||||
return userSettingsValues.subscribe(async ({email, emailAddress, emailFrequency}) => {
|
||||
const $pubkey = app.get().user?.pubkey
|
||||
if (!$pubkey || reconciledPubkey === $pubkey) return
|
||||
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
|
||||
// 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
|
||||
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()
|
||||
// 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)
|
||||
} else if (existing.email !== emailAddress || existing.frequency !== emailFrequency) {
|
||||
await this.register(emailAddress, emailFrequency)
|
||||
} else {
|
||||
emailNotificationState.set({
|
||||
key: existing.key,
|
||||
callback: existing.callback,
|
||||
email: existing.email,
|
||||
frequency: existing.frequency,
|
||||
error: undefined,
|
||||
})
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await this.enable()
|
||||
})
|
||||
await this.enable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
_getSubscriptionIdentifier = async (relay: string, key: string) => {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export type SettingsValues = {
|
|||
emailAddress: string
|
||||
emailFrequency: string
|
||||
emailSalt: string
|
||||
emailHour: number
|
||||
emailMinute: number
|
||||
emailDayOfWeek: number | undefined
|
||||
emailTimezone: string
|
||||
}
|
||||
|
||||
export const defaultSettings: SettingsValues = {
|
||||
|
|
@ -53,6 +57,10 @@ export const defaultSettings: SettingsValues = {
|
|||
emailAddress: "",
|
||||
emailFrequency: "daily",
|
||||
emailSalt: "",
|
||||
emailHour: 17,
|
||||
emailMinute: 0,
|
||||
emailDayOfWeek: undefined,
|
||||
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
}
|
||||
|
||||
export class Settings extends DerivedPlugin<AppDataReader> {
|
||||
|
|
@ -107,6 +115,7 @@ export const notificationSettings = withGetter(
|
|||
spaces: true,
|
||||
mentions: true,
|
||||
messages: true,
|
||||
email: false,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -183,3 +192,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)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@
|
|||
email: $userSettingsValues.email,
|
||||
emailAddress: $userSettingsValues.emailAddress,
|
||||
emailFrequency: $userSettingsValues.emailFrequency,
|
||||
emailHour: $userSettingsValues.emailHour,
|
||||
emailMinute: $userSettingsValues.emailMinute,
|
||||
emailDayOfWeek: $userSettingsValues.emailDayOfWeek,
|
||||
emailTimezone: $userSettingsValues.emailTimezone,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +84,14 @@
|
|||
// Handle email notification registration
|
||||
if (settings.email && settings.emailAddress) {
|
||||
try {
|
||||
await Mail.register(settings.emailAddress, settings.emailFrequency)
|
||||
await Mail.register(
|
||||
settings.emailAddress,
|
||||
settings.emailFrequency,
|
||||
settings.emailHour,
|
||||
settings.emailMinute,
|
||||
settings.emailDayOfWeek,
|
||||
settings.emailTimezone,
|
||||
)
|
||||
await Mail.enable()
|
||||
} catch (e) {
|
||||
settings.email = false
|
||||
|
|
@ -101,6 +112,10 @@
|
|||
email: settings.email,
|
||||
emailAddress: settings.emailAddress,
|
||||
emailFrequency: settings.emailFrequency,
|
||||
emailHour: settings.emailHour,
|
||||
emailMinute: settings.emailMinute,
|
||||
emailDayOfWeek: settings.emailDayOfWeek,
|
||||
emailTimezone: settings.emailTimezone,
|
||||
})
|
||||
|
||||
notificationSettings.set({
|
||||
|
|
@ -110,6 +125,7 @@
|
|||
spaces: settings.spaces,
|
||||
mentions: settings.mentions,
|
||||
messages: settings.messages,
|
||||
email: settings.email,
|
||||
})
|
||||
|
||||
pushToast({message: "Your settings have been saved!"})
|
||||
|
|
@ -281,12 +297,57 @@
|
|||
<select
|
||||
class="select select-bordered"
|
||||
bind:value={settings.emailFrequency}
|
||||
onchange={clearError}>
|
||||
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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue