Compare commits
No commits in common. "cbba1035e7a362ad289f0bbde24f3b0ba625c2f7" and "8047a4f08426e9d1a89f8a57c73a107dc3d6bfc6" have entirely different histories.
cbba1035e7
...
8047a4f084
4 changed files with 52 additions and 228 deletions
|
|
@ -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>
|
||||
|
|
@ -1,76 +1,13 @@
|
|||
import {hash, maybe, spec, throttle} from "@welshman/lib"
|
||||
import {hash, maybe, 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 {Address, DELETE, makeEvent, makeHttpAuth, makeHttpAuthHeader} from "@welshman/util"
|
||||
import {Relays, User} from "@welshman/app"
|
||||
import {buildUrl} from "@lib/util"
|
||||
import {app, messagingRelayLists, roomLists, thunks} from "@app/core"
|
||||
import {app, thunks} from "@app/core"
|
||||
import {device} from "@app/device"
|
||||
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()
|
||||
})
|
||||
}
|
||||
import {userSettingsValues} from "@app/settings"
|
||||
import {emailNotificationState, syncRelaySubscriptions} from "@app/push/adapters/common"
|
||||
|
||||
export class MailNotifications {
|
||||
_controller = maybe<AbortController>()
|
||||
|
|
@ -160,9 +97,8 @@ export class MailNotifications {
|
|||
await this.register(email, frequency)
|
||||
}
|
||||
|
||||
// Sync kind 30390 subscriptions for the email digest. Filters are derived
|
||||
// from the email setting + per-space alerts, independent of push toggles.
|
||||
syncRelaySubscriptionsForEmail(
|
||||
// Sync kind 30390 subscriptions using the same filters as push
|
||||
syncRelaySubscriptions(
|
||||
this._controller.signal,
|
||||
async (url: string, key: string, filters: Filter[], ignore: Filter[]) => {
|
||||
const {callback} = emailNotificationState.get()
|
||||
|
|
@ -202,11 +138,8 @@ export class MailNotifications {
|
|||
)
|
||||
}
|
||||
|
||||
_getSubscriptionIdentifier = async (relay: string, key: string) => {
|
||||
const salt = await ensureEmailSalt()
|
||||
|
||||
return String(hash(relay + key + salt + "email"))
|
||||
}
|
||||
_getSubscriptionIdentifier = (relay: string, key: string) =>
|
||||
String(hash(relay + key + device.get() + "email"))
|
||||
|
||||
_getPushUrl = async (url: string) => {
|
||||
for (const candidate of [url, PUSH_BRIDGE]) {
|
||||
|
|
@ -228,7 +161,7 @@ export class MailNotifications {
|
|||
return
|
||||
}
|
||||
|
||||
const identifier = await this._getSubscriptionIdentifier(relay, key)
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
|
||||
const thunk = thunks.get().publish({
|
||||
relays: [url],
|
||||
|
|
@ -255,7 +188,7 @@ export class MailNotifications {
|
|||
if (!url) return
|
||||
|
||||
const $pubkey = User.require(app.get()).pubkey
|
||||
const identifier = await this._getSubscriptionIdentifier(relay, key)
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
const address = new Address(30390, $pubkey, identifier).toString()
|
||||
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
||||
const error = await thunks
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {derived, writable} from "svelte/store"
|
||||
import {append, call, randomId, remove, spec} from "@welshman/lib"
|
||||
import {append, remove, spec} from "@welshman/lib"
|
||||
import {APP_DATA} from "@welshman/util"
|
||||
import {withGetter} from "@welshman/store"
|
||||
import {AppData} from "@welshman/domain"
|
||||
|
|
@ -10,8 +10,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,7 +34,6 @@ export type SettingsValues = {
|
|||
email: boolean
|
||||
emailAddress: string
|
||||
emailFrequency: string
|
||||
emailSalt: string
|
||||
}
|
||||
|
||||
export const defaultSettings: SettingsValues = {
|
||||
|
|
@ -52,7 +49,6 @@ export const defaultSettings: SettingsValues = {
|
|||
email: false,
|
||||
emailAddress: "",
|
||||
emailFrequency: "daily",
|
||||
emailSalt: "",
|
||||
}
|
||||
|
||||
export class Settings extends DerivedPlugin<AppDataReader> {
|
||||
|
|
@ -125,31 +121,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"))})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,16 +12,11 @@
|
|||
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) {
|
||||
|
|
@ -118,34 +111,6 @@
|
|||
}
|
||||
})
|
||||
|
||||
// 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())
|
||||
</script>
|
||||
|
|
@ -220,70 +185,51 @@
|
|||
{/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>
|
||||
<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}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Receive email digest of missed activity</p>
|
||||
<p>Email address</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<ToggleInput bind:checked={settings.email} />
|
||||
<input
|
||||
type="email"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={settings.emailAddress}
|
||||
oninput={clearError}
|
||||
placeholder="you@example.com" />
|
||||
{/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={clearError}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
{#if $emailNotificationState.error}
|
||||
<div class="text-sm text-error p-3">
|
||||
{$emailNotificationState.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<FieldInline>
|
||||
{#snippet label()}
|
||||
<p>Frequency</p>
|
||||
{/snippet}
|
||||
{#snippet input()}
|
||||
<select
|
||||
class="select select-bordered"
|
||||
bind:value={settings.emailFrequency}
|
||||
onchange={clearError}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
{/snippet}
|
||||
</FieldInline>
|
||||
{/if}
|
||||
{#if $emailNotificationState.error}
|
||||
<div class="text-sm text-error p-3">
|
||||
{$emailNotificationState.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue