2026-03-19 15:32:32 +00:00
|
|
|
import type {Subscriber, Unsubscriber} from "svelte/store"
|
2026-09-18 18:46:27 +00:00
|
|
|
import {Capacitor} from "@capacitor/core"
|
2026-09-26 03:45:58 +00:00
|
|
|
import {call, ms, now, on, parseJson, poll, spec, throttle, uniq} from "@welshman/lib"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {LOCAL_RELAY_URL} from "@welshman/net"
|
2026-03-19 15:32:32 +00:00
|
|
|
import type {RepositoryUpdate} from "@welshman/net"
|
|
|
|
|
import {
|
|
|
|
|
getIdFilters,
|
|
|
|
|
matchFilters,
|
2026-07-28 16:16:26 +00:00
|
|
|
tagSpec,
|
|
|
|
|
tagValue,
|
2026-09-03 21:59:59 +00:00
|
|
|
verifyEvent,
|
2026-04-10 17:11:48 +00:00
|
|
|
MESSAGE,
|
2026-09-02 18:09:17 +00:00
|
|
|
WRAP,
|
2026-03-19 15:32:32 +00:00
|
|
|
type Filter,
|
|
|
|
|
type TrustedEvent,
|
|
|
|
|
} from "@welshman/util"
|
2026-09-28 17:01:59 +00:00
|
|
|
import {merged, synced, withGetter} from "@welshman/store"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {User} from "@welshman/app"
|
2026-09-18 17:48:04 +00:00
|
|
|
import {navigate} from "@app/modal"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {app, messagingRelayLists, network, roomLists} from "@app/core"
|
2026-09-26 03:45:58 +00:00
|
|
|
import {DM_KINDS} from "@app/content"
|
2026-09-26 07:09:35 +00:00
|
|
|
import {getMutedRooms, shouldNotify, userSettingsValues} from "@app/settings"
|
2026-09-02 18:09:17 +00:00
|
|
|
import {makeEventPath, goToChat, goToSpace} from "@app/routes"
|
2026-09-28 17:01:59 +00:00
|
|
|
import {kv} from "@app/storage"
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-06-08 22:33:38 +00:00
|
|
|
export type PushSubscription = {
|
|
|
|
|
key: string
|
|
|
|
|
callback: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type PushState = {
|
|
|
|
|
token?: string
|
|
|
|
|
useFallback?: boolean
|
|
|
|
|
subscription?: PushSubscription
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 18:46:27 +00:00
|
|
|
// The F-Droid build ships without the push plugin, so those installs can only ever poll
|
|
|
|
|
export const requiresFallback =
|
|
|
|
|
Capacitor.getPlatform() === "android" && !Capacitor.isPluginAvailable("PushNotifications")
|
|
|
|
|
|
2026-09-28 17:01:59 +00:00
|
|
|
export const pushState = withGetter(
|
|
|
|
|
synced<PushState>({
|
|
|
|
|
key: "notificationState",
|
|
|
|
|
storage: kv,
|
|
|
|
|
defaultValue: {useFallback: requiresFallback},
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-09-26 07:09:35 +00:00
|
|
|
|
2026-09-28 17:01:59 +00:00
|
|
|
export const notificationSettings = withGetter(
|
|
|
|
|
synced({
|
|
|
|
|
key: "notificationSettings",
|
|
|
|
|
storage: kv,
|
|
|
|
|
defaultValue: {
|
|
|
|
|
push: false,
|
|
|
|
|
sound: true,
|
|
|
|
|
badge: false,
|
|
|
|
|
spaces: true,
|
|
|
|
|
mentions: true,
|
|
|
|
|
messages: true,
|
|
|
|
|
events: true,
|
|
|
|
|
reminder_minutes: [5],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
)
|
2026-06-08 22:33:38 +00:00
|
|
|
|
2026-03-19 15:32:32 +00:00
|
|
|
export interface IPushAdapter {
|
|
|
|
|
request: (prompt?: boolean) => Promise<string>
|
|
|
|
|
disable: () => Promise<void>
|
|
|
|
|
enable: () => Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const onNotification = call(() => {
|
|
|
|
|
const subscribers: Subscriber<TrustedEvent>[] = []
|
|
|
|
|
|
|
|
|
|
let unsubscribe: Unsubscriber | undefined
|
|
|
|
|
|
|
|
|
|
return (f: (event: TrustedEvent) => void) => {
|
|
|
|
|
subscribers.push(f)
|
|
|
|
|
|
|
|
|
|
if (!unsubscribe) {
|
2026-09-16 16:59:22 +00:00
|
|
|
let unsubscribeRepository: Unsubscriber | undefined
|
|
|
|
|
const unsubscribeApp = app.subscribe($app => {
|
|
|
|
|
unsubscribeRepository?.()
|
2026-09-26 03:45:58 +00:00
|
|
|
const filters = [{kinds: [MESSAGE, ...DM_KINDS], since: now()}]
|
2026-09-16 16:59:22 +00:00
|
|
|
unsubscribeRepository = on($app.repository, "update", ({added}: RepositoryUpdate) => {
|
|
|
|
|
const $pubkey = $app.user?.pubkey
|
|
|
|
|
|
|
|
|
|
for (const event of added) {
|
|
|
|
|
if (event.pubkey == $pubkey) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-09-16 16:59:22 +00:00
|
|
|
const h = tagValue(tagSpec("h"), event.tags)
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-09-16 16:59:22 +00:00
|
|
|
if (Array.from($app.tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-09-16 16:59:22 +00:00
|
|
|
if (matchFilters(filters, event)) {
|
|
|
|
|
for (const f of subscribers) {
|
|
|
|
|
f(event)
|
|
|
|
|
}
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-16 16:59:22 +00:00
|
|
|
})
|
2026-03-19 15:32:32 +00:00
|
|
|
})
|
2026-09-16 16:59:22 +00:00
|
|
|
unsubscribe = () => {
|
|
|
|
|
unsubscribeApp()
|
|
|
|
|
unsubscribeRepository?.()
|
|
|
|
|
}
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
subscribers.splice(subscribers.indexOf(f), 1)
|
|
|
|
|
|
|
|
|
|
if (subscribers.length === 0) {
|
|
|
|
|
unsubscribe?.()
|
|
|
|
|
unsubscribe = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-03 21:59:59 +00:00
|
|
|
const ingestNotification = async (relay: string, id: string, json?: string) => {
|
|
|
|
|
const pushed = parseJson<TrustedEvent>(json)
|
|
|
|
|
|
|
|
|
|
if (pushed && verifyEvent(pushed)) {
|
|
|
|
|
app.get().tracker.track(pushed.id, relay)
|
|
|
|
|
app.get().repository.publish(pushed)
|
|
|
|
|
|
|
|
|
|
return pushed
|
|
|
|
|
} else {
|
|
|
|
|
const events = await network.get().request({
|
|
|
|
|
filters: getIdFilters([id]),
|
|
|
|
|
relays: [relay, LOCAL_RELAY_URL],
|
|
|
|
|
// The local relay eoses immediately, so anything less waits on it alone
|
|
|
|
|
threshold: 1,
|
|
|
|
|
autoClose: true,
|
2026-09-18 17:48:04 +00:00
|
|
|
signal: AbortSignal.timeout(ms(5)),
|
2026-09-03 21:59:59 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return events[0]
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-09-02 18:09:17 +00:00
|
|
|
// Wraps are ingested into the repository and unwrapped in the background, so wait for the rumor
|
|
|
|
|
const loadNotificationRumor = async (wrap: TrustedEvent) => {
|
|
|
|
|
const getRumor = () => app.get().wrapManager.getRumor(wrap.id)
|
|
|
|
|
|
2026-09-18 17:48:04 +00:00
|
|
|
await poll({condition: () => Boolean(getRumor()), signal: AbortSignal.timeout(ms(5))})
|
2026-09-02 18:09:17 +00:00
|
|
|
|
|
|
|
|
return getRumor()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 17:48:04 +00:00
|
|
|
export const onPushNotificationAction = async (action: {
|
|
|
|
|
notification: {data: {relay: string; id: string; event?: string}}
|
|
|
|
|
}) => {
|
2026-09-03 21:59:59 +00:00
|
|
|
const {relay, id, event: json} = action.notification.data
|
|
|
|
|
const event = await ingestNotification(relay, id, json)
|
2026-09-02 18:09:17 +00:00
|
|
|
const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event
|
|
|
|
|
const path = target && makeEventPath(target, [relay])
|
|
|
|
|
|
2026-09-26 07:00:18 +00:00
|
|
|
if (path) {
|
2026-09-08 17:23:22 +00:00
|
|
|
navigate(path)
|
2026-09-02 18:09:17 +00:00
|
|
|
} else if (event?.kind === WRAP) {
|
|
|
|
|
goToChat()
|
2026-03-19 15:32:32 +00:00
|
|
|
} else {
|
2026-06-16 22:04:39 +00:00
|
|
|
goToSpace(relay)
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const syncRelaySubscriptions = (
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
|
|
|
|
|
) => {
|
2026-07-28 16:16:26 +00:00
|
|
|
const $pubkey = User.require(app.get()).pubkey
|
2026-03-19 15:32:32 +00:00
|
|
|
|
|
|
|
|
const unsubscribeSpaces = merged([
|
2026-07-28 16:16:26 +00:00
|
|
|
roomLists.get().urls($pubkey).$,
|
2026-03-19 15:32:32 +00:00
|
|
|
notificationSettings,
|
|
|
|
|
userSettingsValues,
|
|
|
|
|
]).subscribe(
|
2026-09-01 22:43:57 +00:00
|
|
|
throttle(3000, ([$spaceUrls, {spaces, mentions}, $settings]) => {
|
2026-09-26 03:45:58 +00:00
|
|
|
const baseFilter = {kinds: [MESSAGE]}
|
2026-03-19 15:32:32 +00:00
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
for (const url of $spaceUrls) {
|
2026-09-01 22:43:57 +00:00
|
|
|
const {notify = true, exceptions = []} = $settings.alerts.find(spec({url})) || {}
|
|
|
|
|
const muted = getMutedRooms($settings, url)
|
2026-03-19 15:32:32 +00:00
|
|
|
const filters: Filter[] = []
|
|
|
|
|
const ignore: Filter[] = []
|
|
|
|
|
|
|
|
|
|
if (spaces) {
|
|
|
|
|
if (notify) {
|
2026-09-01 22:43:57 +00:00
|
|
|
const skipped = uniq([...exceptions, ...muted])
|
|
|
|
|
|
|
|
|
|
if (skipped.length > 0) {
|
|
|
|
|
ignore.push({"#h": skipped})
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
2026-09-26 03:45:58 +00:00
|
|
|
filters.push(baseFilter)
|
2026-03-19 15:32:32 +00:00
|
|
|
} else {
|
2026-09-01 22:43:57 +00:00
|
|
|
const included = exceptions.filter(h => !muted.includes(h))
|
|
|
|
|
|
|
|
|
|
if (included.length > 0) {
|
2026-09-26 03:45:58 +00:00
|
|
|
filters.push({...baseFilter, "#h": included})
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mentions) {
|
2026-09-26 03:45:58 +00:00
|
|
|
filters.push({...baseFilter, "#p": [$pubkey]})
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sync(url, "spaces", filters, ignore)
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-28 16:16:26 +00:00
|
|
|
const unsubscribeMessages = merged([
|
|
|
|
|
messagingRelayLists.get().urls($pubkey).$,
|
|
|
|
|
notificationSettings,
|
|
|
|
|
]).subscribe(
|
|
|
|
|
throttle(3000, ([$messagingUrls, {messages}]) => {
|
|
|
|
|
for (const url of $messagingUrls) {
|
2026-03-19 15:32:32 +00:00
|
|
|
const filters: Filter[] = []
|
|
|
|
|
|
|
|
|
|
if (messages) {
|
2026-09-02 18:09:17 +00:00
|
|
|
filters.push({kinds: [WRAP], "#p": [$pubkey]})
|
2026-03-19 15:32:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sync(url, "messages", filters, [])
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
signal.addEventListener("abort", () => {
|
|
|
|
|
unsubscribeSpaces()
|
|
|
|
|
unsubscribeMessages()
|
|
|
|
|
})
|
|
|
|
|
}
|