Compare commits
No commits in common. "email-notifications" and "dev" have entirely different histories.
email-noti
...
dev
8 changed files with 11 additions and 772 deletions
20
Dockerfile
20
Dockerfile
|
|
@ -5,11 +5,6 @@
|
||||||
#
|
#
|
||||||
# Pass --build-arg VITE_BUILD_HASH=$(git rev-parse --short HEAD) to stamp the build.
|
# 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.
|
# 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
|
# https://pnpm.io/docker#example-3-build-on-cicd
|
||||||
FROM node:24-slim AS builder
|
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 ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm i --frozen-lockfile
|
RUN pnpm i --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_BUILD_HASH
|
ARG VITE_BUILD_HASH
|
||||||
ARG VITE_PLATFORM_URL
|
RUN pnpm run build
|
||||||
ARG VITE_PLATFORM_NAME
|
RUN pnpm run build:server
|
||||||
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'
|
|
||||||
|
|
||||||
FROM node:24-slim AS production
|
FROM node:24-slim AS production
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -16,8 +16,6 @@ export const PUSH_SERVER = env("VITE_PUSH_SERVER")
|
||||||
|
|
||||||
export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE"))
|
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 ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
|
||||||
|
|
||||||
export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl)
|
export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -40,16 +40,6 @@ export const requiresFallback =
|
||||||
|
|
||||||
export const pushState = withGetter(writable<PushState>({useFallback: 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 {
|
export interface IPushAdapter {
|
||||||
request: (prompt?: boolean) => Promise<string>
|
request: (prompt?: boolean) => Promise<string>
|
||||||
disable: () => Promise<void>
|
disable: () => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type {Writable} from "svelte/store"
|
import type {Writable} from "svelte/store"
|
||||||
import {derived, 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 {APP_DATA} from "@welshman/util"
|
||||||
import {withGetter} from "@welshman/store"
|
import {withGetter} from "@welshman/store"
|
||||||
import {AppData} from "@welshman/domain"
|
import {AppData} from "@welshman/domain"
|
||||||
|
|
@ -11,8 +11,6 @@ import {app, fromApp, usePlugin} from "@app/core"
|
||||||
|
|
||||||
export const SETTINGS = "flotilla/settings"
|
export const SETTINGS = "flotilla/settings"
|
||||||
|
|
||||||
const emailSaltsByPubkey = new Map<string, Promise<string>>()
|
|
||||||
|
|
||||||
export enum RelayAuthMode {
|
export enum RelayAuthMode {
|
||||||
Aggressive = "aggressive",
|
Aggressive = "aggressive",
|
||||||
Conservative = "conservative",
|
Conservative = "conservative",
|
||||||
|
|
@ -36,17 +34,9 @@ export type SettingsValues = {
|
||||||
font_size: number
|
font_size: number
|
||||||
alerts: SpaceNotificationSettings[]
|
alerts: SpaceNotificationSettings[]
|
||||||
zap_amounts: number[]
|
zap_amounts: number[]
|
||||||
email: boolean
|
|
||||||
emailAddress: string
|
|
||||||
emailFrequency: string
|
|
||||||
emailSalt: string
|
|
||||||
openrouter_key: string
|
openrouter_key: string
|
||||||
min_dm_pow: number
|
min_dm_pow: number
|
||||||
min_dm_wot: number
|
min_dm_wot: number
|
||||||
emailHour: number
|
|
||||||
emailMinute: number
|
|
||||||
emailDayOfWeek: number | undefined
|
|
||||||
emailTimezone: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: SettingsValues = {
|
export const defaultSettings: SettingsValues = {
|
||||||
|
|
@ -59,17 +49,9 @@ 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],
|
||||||
email: false,
|
|
||||||
emailAddress: "",
|
|
||||||
emailFrequency: "daily",
|
|
||||||
emailSalt: "",
|
|
||||||
openrouter_key: "",
|
openrouter_key: "",
|
||||||
min_dm_pow: 16,
|
min_dm_pow: 16,
|
||||||
min_dm_wot: 3,
|
min_dm_wot: 3,
|
||||||
emailHour: 17,
|
|
||||||
emailMinute: 0,
|
|
||||||
emailDayOfWeek: undefined,
|
|
||||||
emailTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Settings extends DerivedPlugin<AppDataReader> {
|
export class Settings extends DerivedPlugin<AppDataReader> {
|
||||||
|
|
@ -182,7 +164,6 @@ export const notificationSettings = withGetter(
|
||||||
spaces: true,
|
spaces: true,
|
||||||
mentions: true,
|
mentions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
email: false,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -201,35 +182,6 @@ export const publishSettings = async (params: Partial<SettingsValues>) => {
|
||||||
return command.publish()
|
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) =>
|
export const addTrustedRelay = (url: string) =>
|
||||||
publishSettings({trusted_relays: append(url, getSetting("trusted_relays"))})
|
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]}]})
|
return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publishEmailSettings = (
|
|
||||||
params: Partial<
|
|
||||||
Pick<SettingsValues, "emailHour" | "emailMinute" | "emailDayOfWeek" | "emailTimezone">
|
|
||||||
>,
|
|
||||||
) => publishSettings(params)
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@
|
||||||
import * as notifications from "@app/notifications"
|
import * as notifications from "@app/notifications"
|
||||||
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
|
import {notificationCount, backgroundNotificationCount} from "@app/notifications"
|
||||||
import {Push} from "@app/push"
|
import {Push} from "@app/push"
|
||||||
import {Mail} from "@app/mail"
|
|
||||||
import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
|
import {onPushNotificationAction, pushState} from "@app/push/adapters/common"
|
||||||
import {syncKeyboard} from "@app/keyboard"
|
import {syncKeyboard} from "@app/keyboard"
|
||||||
import {getPageTitle} from "@app/title"
|
import {getPageTitle} from "@app/title"
|
||||||
|
|
@ -241,9 +240,6 @@
|
||||||
// Initialize background notifications
|
// Initialize background notifications
|
||||||
unsubscribers.push(Push.sync())
|
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
|
// 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
|
// 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.
|
// load the way startup does, so sync reconciles against cached events.
|
||||||
|
|
@ -283,6 +279,10 @@
|
||||||
theme: "error",
|
theme: "error",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
message: "Your signer isn't responding.",
|
message: "Your signer isn't responding.",
|
||||||
|
action: {
|
||||||
|
message: "Details",
|
||||||
|
onclick: () => goto("/settings/profile"),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {derived} from "svelte/store"
|
|
||||||
import {sleep} from "@welshman/lib"
|
import {sleep} from "@welshman/lib"
|
||||||
import type {Relay} from "@welshman/domain"
|
|
||||||
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"
|
||||||
|
|
@ -14,46 +12,12 @@
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
import {clearBadges} from "@app/notifications"
|
import {clearBadges} from "@app/notifications"
|
||||||
import {Push} from "@app/push"
|
import {Push} from "@app/push"
|
||||||
import {Mail} from "@app/mail"
|
import {notificationSettings} from "@app/settings"
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
settings = buildSettings()
|
settings = {...notificationSettings.get()}
|
||||||
clearError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onsubmit = preventDefault(async () => {
|
const onsubmit = preventDefault(async () => {
|
||||||
|
|
@ -81,52 +45,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle email notification registration
|
notificationSettings.set(settings)
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
pushToast({message: "Your settings have been saved!"})
|
pushToast({message: "Your settings have been saved!"})
|
||||||
} finally {
|
} 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 loading = $state(false)
|
||||||
let settings = $state(buildSettings())
|
let settings = $state({...notificationSettings.get()})
|
||||||
let settingsHydrated = $state(false)
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (settingsHydrated) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const {email, emailAddress} = $userSettingsValues
|
|
||||||
if (!email && !emailAddress) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsHydrated = true
|
|
||||||
settings = buildSettings()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form {onsubmit}>
|
<form {onsubmit}>
|
||||||
|
|
@ -251,115 +127,6 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
</div>
|
</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">
|
<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}
|
<Button class="button button-neutral" onclick={reset} disabled={loading}
|
||||||
>Discard Changes</Button>
|
>Discard Changes</Button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue