forked from coracle/flotilla
Add android fallback for background push notifications (#102)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import {notificationSettings} from "@app/core/state"
|
||||
import {onNotification} from "@app/util/notifications"
|
||||
import {onNotification} from "@app/util/push"
|
||||
|
||||
let audioElement: HTMLAudioElement
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
} else {
|
||||
const permissions = await Push.request()
|
||||
|
||||
if (permissions === "granted") {
|
||||
if (permissions.startsWith("granted")) {
|
||||
await setSpaceNotifications(url, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
} else {
|
||||
const permissions = await Push.request()
|
||||
|
||||
if (permissions === "granted") {
|
||||
if (permissions.startsWith("granted")) {
|
||||
await setSpaceNotifications(url, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,10 +428,11 @@ export type PushSubscription = {
|
||||
|
||||
export type PushState = {
|
||||
token?: string
|
||||
useFallback?: boolean
|
||||
subscription?: PushSubscription
|
||||
}
|
||||
|
||||
export const notificationState = withGetter(writable<PushState>({}))
|
||||
export const pushState = withGetter(writable<PushState>({}))
|
||||
|
||||
// Chats
|
||||
|
||||
|
||||
@@ -1,86 +1,25 @@
|
||||
import type {Unsubscriber, Readable, Subscriber} from "svelte/store"
|
||||
import {derived, get} from "svelte/store"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {derived} from "svelte/store"
|
||||
import {Badge} from "@capawesome/capacitor-badge"
|
||||
import {PushNotifications} from "@capacitor/push-notifications"
|
||||
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
|
||||
import {synced, throttled, withGetter} from "@welshman/store"
|
||||
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import {
|
||||
pubkey,
|
||||
tracker,
|
||||
repository,
|
||||
publishThunk,
|
||||
loadRelay,
|
||||
relaysByUrl,
|
||||
waitForThunkError,
|
||||
userMessagingRelayList,
|
||||
} from "@welshman/app"
|
||||
import {
|
||||
on,
|
||||
call,
|
||||
assoc,
|
||||
poll,
|
||||
prop,
|
||||
hash,
|
||||
spec,
|
||||
first,
|
||||
identity,
|
||||
now,
|
||||
maybe,
|
||||
throttle,
|
||||
} from "@welshman/lib"
|
||||
import type {TrustedEvent, Filter} from "@welshman/util"
|
||||
import {pubkey, tracker, repository, relaysByUrl} from "@welshman/app"
|
||||
import {assoc, prop, spec, first, identity, now} from "@welshman/lib"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {deriveEventsByIdByUrl} from "@welshman/store"
|
||||
import {sortEventsDesc} from "@welshman/util"
|
||||
import {makeSpacePath, makeRoomPath, makeSpaceChatPath, makeChatPath} from "@app/util/routes"
|
||||
import {
|
||||
DELETE,
|
||||
getTagValue,
|
||||
getPubkeyTagValues,
|
||||
getRelaysFromList,
|
||||
matchFilter,
|
||||
matchFilters,
|
||||
getIdFilters,
|
||||
sortEventsDesc,
|
||||
makeEvent,
|
||||
Address,
|
||||
} from "@welshman/util"
|
||||
import {buildUrl} from "@lib/util"
|
||||
import {
|
||||
makeSpacePath,
|
||||
makeRoomPath,
|
||||
makeSpaceChatPath,
|
||||
makeChatPath,
|
||||
getEventPath,
|
||||
goToEvent,
|
||||
} from "@app/util/routes"
|
||||
import {
|
||||
DM_KINDS,
|
||||
CONTENT_KINDS,
|
||||
MESSAGE_KINDS,
|
||||
PUSH_BRIDGE,
|
||||
PUSH_SERVER,
|
||||
notificationSettings,
|
||||
notificationState,
|
||||
chatsById,
|
||||
userSettingsValues,
|
||||
userGroupList,
|
||||
getSpaceUrlsFromGroupList,
|
||||
getSpaceRoomsFromGroupList,
|
||||
makeCommentFilter,
|
||||
userSpaceUrls,
|
||||
shouldNotify,
|
||||
hasNip29,
|
||||
device,
|
||||
} from "@app/core/state"
|
||||
import {kv} from "@app/core/storage"
|
||||
import {goto} from "$app/navigation"
|
||||
import {page} from "$app/stores"
|
||||
|
||||
// Temporarily copied from welshman
|
||||
|
||||
type Stores = Readable<any> | [Readable<any>, ...Array<Readable<any>>] | Array<Readable<any>>
|
||||
|
||||
const merged = <S extends Stores>(stores: S) => derived(stores, identity)
|
||||
export {Push} from "@app/util/push"
|
||||
|
||||
// Checked state
|
||||
|
||||
@@ -209,51 +148,6 @@ export const notifications = derived([page, allNotifications], ([$page, $allNoti
|
||||
return new Set([...$allNotifications].filter(p => !$page.url.pathname.startsWith(p)))
|
||||
})
|
||||
|
||||
export const onNotification = call(() => {
|
||||
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
|
||||
const filters = allFilters.map(assoc("since", now()))
|
||||
const subscribers: Subscriber<TrustedEvent>[] = []
|
||||
|
||||
let unsubscribe: Unsubscriber | undefined
|
||||
|
||||
return (f: (event: TrustedEvent) => void) => {
|
||||
subscribers.push(f)
|
||||
|
||||
if (!unsubscribe) {
|
||||
unsubscribe = on(repository, "update", ({added}) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
for (const event of added) {
|
||||
if (event.pubkey == $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
|
||||
if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchFilters(filters, event)) {
|
||||
for (const f of subscribers) {
|
||||
f(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.splice(subscribers.indexOf(f), 1)
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Badges
|
||||
|
||||
export const syncBadges = () =>
|
||||
@@ -278,388 +172,3 @@ export const clearBadges = async () => {
|
||||
// pass - firefox doesn't support this
|
||||
}
|
||||
}
|
||||
|
||||
// Push notifications
|
||||
|
||||
interface IPushAdapter {
|
||||
request: (prompt?: boolean) => Promise<string>
|
||||
disable: () => Promise<void>
|
||||
enable: () => Promise<void>
|
||||
}
|
||||
|
||||
class CapacitorNotifications implements IPushAdapter {
|
||||
_controller = maybe<AbortController>()
|
||||
|
||||
async request(prompt = true) {
|
||||
let status = await PushNotifications.checkPermissions()
|
||||
|
||||
if (prompt && ["prompt", "prompt-with-rationale"].includes(status.receive)) {
|
||||
status = await PushNotifications.requestPermissions()
|
||||
}
|
||||
|
||||
if (status.receive !== "granted") {
|
||||
return status.receive
|
||||
}
|
||||
|
||||
let {token} = notificationState.get()
|
||||
let error = "failed to retrieve token"
|
||||
|
||||
if (!token) {
|
||||
const listeners = [
|
||||
PushNotifications.addListener("registration", ({value}: Token) => {
|
||||
token = value
|
||||
}),
|
||||
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
|
||||
error = err.error
|
||||
}),
|
||||
]
|
||||
|
||||
await Promise.all([
|
||||
PushNotifications.register(),
|
||||
poll({
|
||||
condition: () => Boolean(token),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}),
|
||||
])
|
||||
|
||||
listeners.forEach(p => p.then(listener => listener.remove()))
|
||||
notificationState.update(assoc("token", token))
|
||||
}
|
||||
|
||||
return token ? status.receive : error
|
||||
}
|
||||
|
||||
async _syncServer(signal: AbortSignal) {
|
||||
const {token, subscription} = notificationState.get()
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Attempted to sync push server without a token")
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
try {
|
||||
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
|
||||
const url = buildUrl(PUSH_SERVER, "subscription", channel)
|
||||
const res = await fetch(url, {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify({token}),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Failed to register with push server (status ${res.status})`)
|
||||
} else {
|
||||
const json = await res.json()
|
||||
|
||||
if (json?.callback && json?.key) {
|
||||
notificationState.update(assoc("subscription", json))
|
||||
} else {
|
||||
console.warn("Failed to register with push server (bad response)")
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to register with push server:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getSubscriptionIdentifier = (relay: string, key: string) =>
|
||||
String(hash(relay + key + device.get()))
|
||||
|
||||
_getPushUrl = async (url: string) => {
|
||||
for (const candidate of [url, PUSH_BRIDGE]) {
|
||||
const relay = await loadRelay(candidate)
|
||||
|
||||
if (relay?.supported_nips?.map(String)?.includes("9a")) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
|
||||
const {subscription} = notificationState.get()
|
||||
|
||||
if (!subscription) {
|
||||
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await this._getPushUrl(relay)
|
||||
|
||||
if (!url) {
|
||||
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(30390, {
|
||||
tags: [
|
||||
["d", identifier],
|
||||
["relay", relay],
|
||||
["callback", subscription.callback],
|
||||
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
|
||||
...filters.map(filter => ["filter", JSON.stringify(filter)]),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(thunk)
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
_unsyncRelay = async (relay: string, key: string) => {
|
||||
const url = await this._getPushUrl(relay)
|
||||
|
||||
if (!url) {
|
||||
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
|
||||
return
|
||||
}
|
||||
|
||||
const relays = [url]
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
const address = new Address(30390, pubkey.get()!, identifier).toString()
|
||||
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
||||
const error = await waitForThunkError(publishThunk({relays, event}))
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async _syncSpaceSubscription(signal: AbortSignal) {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
merged([userSpaceUrls, notificationSettings, userSettingsValues]).subscribe(
|
||||
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
|
||||
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
|
||||
|
||||
for (const url of $userSpaceUrls) {
|
||||
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
|
||||
const filters: Filter[] = []
|
||||
const ignore: Filter[] = []
|
||||
|
||||
// Build filters based on spaces setting
|
||||
if (spaces) {
|
||||
if (notify) {
|
||||
// notify=true: exceptions are opt-out (exclude those rooms)
|
||||
if (exceptions.length > 0) {
|
||||
ignore.push({"#h": exceptions})
|
||||
}
|
||||
// Include all other content
|
||||
filters.push(...baseFilters)
|
||||
} else {
|
||||
// notify=false: exceptions are opt-in (only include those rooms)
|
||||
if (exceptions.length > 0) {
|
||||
filters.push(...baseFilters.map(f => ({...f, "#h": exceptions})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build filters for mentions - always notify for p-tagged content
|
||||
if (mentions) {
|
||||
filters.push(...baseFilters.map(f => ({...f, "#p": [pubkey.get()!]})))
|
||||
}
|
||||
|
||||
// Sync or unsync based on whether we have filters
|
||||
if (filters.length > 0) {
|
||||
this._syncRelay(url, "spaces", filters, ignore)
|
||||
} else {
|
||||
this._unsyncRelay(url, "spaces")
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async _syncMessageSubscription(signal: AbortSignal) {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
merged([userMessagingRelayList, notificationSettings]).subscribe(
|
||||
throttle(3000, ([$userMessagingRelayList, {messages}]) => {
|
||||
for (const url of getRelaysFromList($userMessagingRelayList)) {
|
||||
if (messages) {
|
||||
this._syncRelay(url, "messages", [{kinds: DM_KINDS, "#p": [pubkey.get()!]}])
|
||||
} else {
|
||||
this._unsyncRelay(url, "messages")
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async enable() {
|
||||
if (!this._controller) {
|
||||
this._controller = new AbortController()
|
||||
|
||||
PushNotifications.addListener(
|
||||
"pushNotificationActionPerformed",
|
||||
async (action: ActionPerformed) => {
|
||||
const {relay, id} = action.notification.data
|
||||
|
||||
const [event] = await load({
|
||||
relays: [relay, LOCAL_RELAY_URL],
|
||||
filters: getIdFilters([id]),
|
||||
})
|
||||
|
||||
if (event) {
|
||||
goto(await getEventPath(event, [relay]))
|
||||
} else {
|
||||
goto(makeSpacePath(relay))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
this._controller.signal.addEventListener("abort", () => {
|
||||
PushNotifications.removeAllListeners()
|
||||
})
|
||||
|
||||
try {
|
||||
await this._syncServer(this._controller.signal)
|
||||
await this._syncSpaceSubscription(this._controller.signal)
|
||||
await this._syncMessageSubscription(this._controller.signal)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async disable() {
|
||||
this._controller?.abort()
|
||||
this._controller = undefined
|
||||
|
||||
const {subscription} = notificationState.get()
|
||||
|
||||
if (subscription) {
|
||||
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
|
||||
method: "delete",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn("Failed to delete push subscription")
|
||||
}
|
||||
}
|
||||
|
||||
notificationState.set({})
|
||||
|
||||
await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces")))
|
||||
|
||||
await Promise.all(
|
||||
getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class WebNotifications implements IPushAdapter {
|
||||
_unsubscriber = maybe<Unsubscriber>()
|
||||
|
||||
async request(prompt = true) {
|
||||
if (prompt && Notification?.permission === "default") {
|
||||
await Notification.requestPermission()
|
||||
}
|
||||
|
||||
return Notification?.permission || "denied"
|
||||
}
|
||||
|
||||
_notify(event: TrustedEvent, title: string, body: string) {
|
||||
console.log("notify:", event)
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
tag: event.id,
|
||||
icon: "/icon.png",
|
||||
badge: "/icon.png",
|
||||
})
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
goToEvent(event)
|
||||
notification.close()
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
notification.close()
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
|
||||
async enable() {
|
||||
if (!this._unsubscriber) {
|
||||
this._unsubscriber = onNotification(event => {
|
||||
const {push, messages, mentions, spaces} = notificationSettings.get()
|
||||
|
||||
if (push && document.hidden && Notification?.permission === "granted") {
|
||||
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
|
||||
this._notify(event, "New direct message", "Someone sent you a direct message.")
|
||||
} else if (
|
||||
mentions &&
|
||||
event.pubkey !== pubkey.get() &&
|
||||
getPubkeyTagValues(event.tags).includes(pubkey.get()!)
|
||||
) {
|
||||
this._notify(event, "Someone mentioned you", "Someone tagged you in a message.")
|
||||
} else if (spaces) {
|
||||
this._notify(event, "New activity", "Someone posted a new message.")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async disable() {
|
||||
this._unsubscriber?.()
|
||||
this._unsubscriber = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class Push {
|
||||
static _adapter: IPushAdapter | undefined
|
||||
|
||||
static _getAdapter() {
|
||||
if (!Push._adapter) {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
Push._adapter = new CapacitorNotifications()
|
||||
} else {
|
||||
Push._adapter = new WebNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
return Push._adapter
|
||||
}
|
||||
|
||||
static request() {
|
||||
return Push._getAdapter().request()
|
||||
}
|
||||
|
||||
static disable() {
|
||||
return Push._getAdapter().disable()
|
||||
}
|
||||
|
||||
static enable() {
|
||||
return Push._getAdapter().enable()
|
||||
}
|
||||
|
||||
static sync() {
|
||||
return notificationSettings.subscribe(({push}) => {
|
||||
if (push) {
|
||||
Push.enable()
|
||||
} else {
|
||||
Push.disable()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {throttle} from "throttle-debounce"
|
||||
import {App} from "@capacitor/app"
|
||||
import {registerPlugin} from "@capacitor/core"
|
||||
import {pubkey, getSession} from "@welshman/app"
|
||||
import type {Session} from "@welshman/app"
|
||||
import {maybe, now} from "@welshman/lib"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {pushState} from "@app/core/state"
|
||||
import type {IPushAdapter} from "@app/util/push/adapters/common"
|
||||
import {requestPermissions, syncRelaySubscriptions} from "@app/util/push/adapters/common"
|
||||
|
||||
type AndroidFallbackSubscription = {
|
||||
relay: string
|
||||
key: string
|
||||
filters: Array<Filter>
|
||||
ignore: Array<Filter>
|
||||
}
|
||||
|
||||
type AndroidPushFallbackState = {
|
||||
session?: Session
|
||||
activeSince?: number
|
||||
subscriptions?: Array<AndroidFallbackSubscription>
|
||||
}
|
||||
|
||||
type AndroidPushFallbackPlugin = {
|
||||
syncState: (args: {state: AndroidPushFallbackState}) => Promise<void>
|
||||
}
|
||||
|
||||
const AndroidPushFallback = registerPlugin<AndroidPushFallbackPlugin>("AndroidPushFallback")
|
||||
|
||||
export class AndroidFallbackNotifications implements IPushAdapter {
|
||||
_controller = maybe<AbortController>()
|
||||
_subscriptions = new Map<string, AndroidFallbackSubscription>()
|
||||
_activeSince = now()
|
||||
|
||||
async request() {
|
||||
return requestPermissions()
|
||||
}
|
||||
|
||||
async enable() {
|
||||
if (!this._controller) {
|
||||
this._controller = new AbortController()
|
||||
|
||||
const doSync = throttle(1000, () => {
|
||||
AndroidPushFallback.syncState({
|
||||
state: {
|
||||
session: pubkey.get() ? getSession(pubkey.get()!) : undefined,
|
||||
activeSince: this._activeSince,
|
||||
subscriptions: Array.from(this._subscriptions.values()),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
let appStateListener: Awaited<ReturnType<typeof App.addListener>> | undefined
|
||||
|
||||
App.addListener("appStateChange", ({isActive}) => {
|
||||
if (!isActive) {
|
||||
this._activeSince = now()
|
||||
doSync()
|
||||
}
|
||||
}).then(handle => {
|
||||
appStateListener = handle
|
||||
})
|
||||
|
||||
this._controller.signal.addEventListener("abort", () => {
|
||||
appStateListener?.remove()
|
||||
})
|
||||
|
||||
syncRelaySubscriptions(this._controller.signal, async (relay, key, filters, ignore) => {
|
||||
if (filters.length > 0) {
|
||||
this._subscriptions.set(`${relay}:${key}`, {relay, key, filters, ignore})
|
||||
} else {
|
||||
this._subscriptions.delete(`${relay}:${key}`)
|
||||
}
|
||||
|
||||
doSync()
|
||||
})
|
||||
|
||||
pushState.set({useFallback: true})
|
||||
}
|
||||
}
|
||||
|
||||
async disable() {
|
||||
this._controller?.abort()
|
||||
this._controller = undefined
|
||||
this._subscriptions.clear()
|
||||
|
||||
await AndroidPushFallback.syncState({state: {}})
|
||||
|
||||
pushState.set({})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {get} from "svelte/store"
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {PushNotifications} from "@capacitor/push-notifications"
|
||||
import {
|
||||
pubkey,
|
||||
publishThunk,
|
||||
loadRelay,
|
||||
waitForThunkError,
|
||||
userMessagingRelayList,
|
||||
} from "@welshman/app"
|
||||
import {assoc, hash, maybe} from "@welshman/lib"
|
||||
import type {Filter} from "@welshman/util"
|
||||
import {DELETE, getRelaysFromList, makeEvent, Address} from "@welshman/util"
|
||||
import {buildUrl} from "@lib/util"
|
||||
import {PUSH_BRIDGE, PUSH_SERVER, pushState, userSpaceUrls, device} from "@app/core/state"
|
||||
import type {IPushAdapter} from "@app/util/push/adapters/common"
|
||||
import {
|
||||
onPushNotificationAction,
|
||||
syncRelaySubscriptions,
|
||||
requestPermissions,
|
||||
requestToken,
|
||||
} from "@app/util/push/adapters/common"
|
||||
|
||||
export class CapacitorNotifications implements IPushAdapter {
|
||||
_controller = maybe<AbortController>()
|
||||
|
||||
async request() {
|
||||
const status = await requestPermissions()
|
||||
|
||||
if (status !== "granted") {
|
||||
return status
|
||||
}
|
||||
|
||||
const {token, error = "denied"} = await requestToken()
|
||||
|
||||
pushState.update(assoc("token", token))
|
||||
|
||||
return token ? "granted" : error
|
||||
}
|
||||
|
||||
async _syncServer(signal: AbortSignal) {
|
||||
const {token, subscription} = pushState.get()
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Attempted to sync push server without a token")
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
try {
|
||||
const channel = Capacitor.getPlatform() === "ios" ? "apns" : "fcm"
|
||||
const url = buildUrl(PUSH_SERVER, "subscription", channel)
|
||||
const res = await fetch(url, {
|
||||
signal,
|
||||
method: "POST",
|
||||
body: JSON.stringify({token}),
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Failed to register with push server (status ${res.status})`)
|
||||
} else {
|
||||
const json = await res.json()
|
||||
|
||||
if (json?.callback && json?.key) {
|
||||
pushState.update(assoc("subscription", json))
|
||||
} else {
|
||||
console.warn("Failed to register with push server (bad response)")
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to register with push server:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_getSubscriptionIdentifier = (relay: string, key: string) =>
|
||||
String(hash(relay + key + device.get()))
|
||||
|
||||
_getPushUrl = async (url: string) => {
|
||||
for (const candidate of [url, PUSH_BRIDGE]) {
|
||||
const relay = await loadRelay(candidate)
|
||||
|
||||
if (relay?.supported_nips?.map(String)?.includes("9a")) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
|
||||
const {subscription} = pushState.get()
|
||||
|
||||
if (!subscription) {
|
||||
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
|
||||
return
|
||||
}
|
||||
|
||||
const url = await this._getPushUrl(relay)
|
||||
|
||||
if (!url) {
|
||||
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
|
||||
const thunk = publishThunk({
|
||||
relays: [url],
|
||||
event: makeEvent(30390, {
|
||||
tags: [
|
||||
["d", identifier],
|
||||
["relay", relay],
|
||||
["callback", subscription.callback],
|
||||
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
|
||||
...filters.map(filter => ["filter", JSON.stringify(filter)]),
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const error = await waitForThunkError(thunk)
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
_unsyncRelay = async (relay: string, key: string) => {
|
||||
const url = await this._getPushUrl(relay)
|
||||
|
||||
if (!url) {
|
||||
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
|
||||
return
|
||||
}
|
||||
|
||||
const relays = [url]
|
||||
const identifier = this._getSubscriptionIdentifier(relay, key)
|
||||
const address = new Address(30390, pubkey.get()!, identifier).toString()
|
||||
const event = makeEvent(DELETE, {tags: [["a", address]]})
|
||||
const error = await waitForThunkError(publishThunk({relays, event}))
|
||||
|
||||
if (error) {
|
||||
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async enable() {
|
||||
if (!this._controller) {
|
||||
this._controller = new AbortController()
|
||||
|
||||
PushNotifications.addListener("pushNotificationActionPerformed", onPushNotificationAction)
|
||||
|
||||
this._controller.signal.addEventListener("abort", () => {
|
||||
PushNotifications.removeAllListeners()
|
||||
})
|
||||
|
||||
try {
|
||||
await this._syncServer(this._controller.signal)
|
||||
|
||||
syncRelaySubscriptions(this._controller.signal, (url, key, filters, ignore) => {
|
||||
if (filters.length > 0) {
|
||||
this._syncRelay(url, key, filters, ignore)
|
||||
} else {
|
||||
this._unsyncRelay(url, key)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async disable() {
|
||||
this._controller?.abort()
|
||||
this._controller = undefined
|
||||
|
||||
const {subscription} = pushState.get()
|
||||
|
||||
if (subscription) {
|
||||
const res = await fetch(buildUrl(PUSH_SERVER, "subscription", subscription.key), {
|
||||
method: "delete",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn("Failed to delete push subscription")
|
||||
}
|
||||
}
|
||||
|
||||
pushState.set({})
|
||||
|
||||
await Promise.all(get(userSpaceUrls).map(url => this._unsyncRelay(url, "spaces")))
|
||||
|
||||
await Promise.all(
|
||||
getRelaysFromList(get(userMessagingRelayList)).map(url => this._unsyncRelay(url, "messages")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {goto} from "$app/navigation"
|
||||
import type {Subscriber, Unsubscriber} from "svelte/store"
|
||||
import {
|
||||
PushNotifications,
|
||||
type ActionPerformed,
|
||||
type RegistrationError,
|
||||
type Token,
|
||||
} from "@capacitor/push-notifications"
|
||||
import type {PluginListenerHandle} from "@capacitor/core"
|
||||
import {pubkey, repository, tracker, userMessagingRelayList} from "@welshman/app"
|
||||
import {merged} from "@welshman/store"
|
||||
import {assoc, call, now, on, poll, spec, throttle} from "@welshman/lib"
|
||||
import {load, LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import type {RepositoryUpdate} from "@welshman/net"
|
||||
import {
|
||||
getIdFilters,
|
||||
getRelaysFromList,
|
||||
getTagValue,
|
||||
matchFilters,
|
||||
type Filter,
|
||||
type TrustedEvent,
|
||||
} from "@welshman/util"
|
||||
import {
|
||||
DM_KINDS,
|
||||
CONTENT_KINDS,
|
||||
MESSAGE_KINDS,
|
||||
notificationSettings,
|
||||
pushState,
|
||||
shouldNotify,
|
||||
userSpaceUrls,
|
||||
userSettingsValues,
|
||||
makeCommentFilter,
|
||||
} from "@app/core/state"
|
||||
import {makeSpacePath, getEventPath} from "@app/util/routes"
|
||||
|
||||
export interface IPushAdapter {
|
||||
request: (prompt?: boolean) => Promise<string>
|
||||
disable: () => Promise<void>
|
||||
enable: () => Promise<void>
|
||||
}
|
||||
|
||||
export type PushPermissionResult = {
|
||||
token?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const onNotification = call(() => {
|
||||
const allFilters = [{kinds: [...MESSAGE_KINDS, ...DM_KINDS]}, makeCommentFilter(MESSAGE_KINDS)]
|
||||
const filters = allFilters.map(assoc("since", now()))
|
||||
const subscribers: Subscriber<TrustedEvent>[] = []
|
||||
|
||||
let unsubscribe: Unsubscriber | undefined
|
||||
|
||||
return (f: (event: TrustedEvent) => void) => {
|
||||
subscribers.push(f)
|
||||
|
||||
if (!unsubscribe) {
|
||||
unsubscribe = on(repository, "update", ({added}: RepositoryUpdate) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
for (const event of added) {
|
||||
if (event.pubkey == $pubkey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const h = getTagValue("h", event.tags)
|
||||
|
||||
if (Array.from(tracker.getRelays(event.id)).every(url => !shouldNotify(url, h))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matchFilters(filters, event)) {
|
||||
for (const f of subscribers) {
|
||||
f(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscribers.splice(subscribers.indexOf(f), 1)
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const onPushNotificationAction = async (action: ActionPerformed) => {
|
||||
const {relay, id} = action.notification.data
|
||||
|
||||
const [event] = await load({
|
||||
relays: [relay, LOCAL_RELAY_URL],
|
||||
filters: getIdFilters([id]),
|
||||
})
|
||||
|
||||
if (event) {
|
||||
goto(await getEventPath(event, [relay]))
|
||||
} else {
|
||||
goto(makeSpacePath(relay))
|
||||
}
|
||||
}
|
||||
|
||||
export const requestPermissions = async (): Promise<string> => {
|
||||
let status = await PushNotifications.checkPermissions()
|
||||
|
||||
if (["prompt", "prompt-with-rationale"].includes(status.receive)) {
|
||||
status = await PushNotifications.requestPermissions()
|
||||
}
|
||||
|
||||
return status.receive
|
||||
}
|
||||
|
||||
export const requestToken = async (): Promise<PushPermissionResult> => {
|
||||
let {token} = pushState.get()
|
||||
let error = "failed to retrieve token"
|
||||
|
||||
if (!token) {
|
||||
const listeners = [
|
||||
PushNotifications.addListener("registration", ({value}: Token) => {
|
||||
token = value
|
||||
}),
|
||||
PushNotifications.addListener("registrationError", (err: RegistrationError) => {
|
||||
error = err.error
|
||||
}),
|
||||
]
|
||||
|
||||
await Promise.all([
|
||||
PushNotifications.register(),
|
||||
poll({
|
||||
condition: () => Boolean(token),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}),
|
||||
])
|
||||
|
||||
listeners.forEach(p => p.then((listener: PluginListenerHandle) => listener.remove()))
|
||||
}
|
||||
|
||||
return token ? {token} : {error}
|
||||
}
|
||||
|
||||
export const syncRelaySubscriptions = (
|
||||
signal: AbortSignal,
|
||||
sync: (url: string, key: string, filters: Filter[], ignore: Filter[]) => void,
|
||||
) => {
|
||||
const $pubkey = pubkey.get()
|
||||
|
||||
if (!$pubkey) {
|
||||
throw new Error("Attempted to sync push subscriptions without an active pubkey")
|
||||
}
|
||||
|
||||
const unsubscribeSpaces = merged([
|
||||
userSpaceUrls,
|
||||
notificationSettings,
|
||||
userSettingsValues,
|
||||
]).subscribe(
|
||||
throttle(3000, ([$userSpaceUrls, {spaces, mentions}, {alerts}]) => {
|
||||
const baseFilters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
|
||||
|
||||
for (const url of $userSpaceUrls) {
|
||||
const {notify = true, exceptions = []} = alerts.find(spec({url})) || {}
|
||||
const filters: Filter[] = []
|
||||
const ignore: Filter[] = []
|
||||
|
||||
if (spaces) {
|
||||
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})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mentions) {
|
||||
filters.push(...baseFilters.map(f => ({...f, "#p": [$pubkey]})))
|
||||
}
|
||||
|
||||
sync(url, "spaces", filters, ignore)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const unsubscribeMessages = merged([userMessagingRelayList, notificationSettings]).subscribe(
|
||||
throttle(3000, ([$userMessagingRelayList, {messages}]) => {
|
||||
for (const url of getRelaysFromList($userMessagingRelayList)) {
|
||||
const filters: Filter[] = []
|
||||
|
||||
if (messages) {
|
||||
filters.push({kinds: DM_KINDS, "#p": [$pubkey]})
|
||||
}
|
||||
|
||||
sync(url, "messages", filters, [])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
unsubscribeSpaces()
|
||||
unsubscribeMessages()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {pubkey} from "@welshman/app"
|
||||
import {maybe} from "@welshman/lib"
|
||||
import type {Unsubscriber} from "svelte/store"
|
||||
import {getPubkeyTagValues, matchFilter, type TrustedEvent} from "@welshman/util"
|
||||
import {DM_KINDS, notificationSettings} from "@app/core/state"
|
||||
import type {IPushAdapter} from "@app/util/push/adapters/common"
|
||||
import {onNotification} from "@app/util/push/adapters/common"
|
||||
import {goToEvent} from "@app/util/routes"
|
||||
|
||||
export class WebNotifications implements IPushAdapter {
|
||||
_unsubscriber = maybe<Unsubscriber>()
|
||||
|
||||
async request(prompt = true) {
|
||||
if (prompt && Notification?.permission === "default") {
|
||||
await Notification.requestPermission()
|
||||
}
|
||||
|
||||
return Notification?.permission || "denied"
|
||||
}
|
||||
|
||||
_notify(event: TrustedEvent, title: string, body: string) {
|
||||
console.log("notify:", event)
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
tag: event.id,
|
||||
icon: "/icon.png",
|
||||
badge: "/icon.png",
|
||||
})
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
goToEvent(event)
|
||||
notification.close()
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
notification.close()
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange)
|
||||
}
|
||||
|
||||
async enable() {
|
||||
if (!this._unsubscriber) {
|
||||
this._unsubscriber = onNotification(event => {
|
||||
const {push, messages, mentions, spaces} = notificationSettings.get()
|
||||
|
||||
if (push && document.hidden && Notification?.permission === "granted") {
|
||||
if (messages && matchFilter({kinds: DM_KINDS}, event)) {
|
||||
this._notify(event, "New direct message", "Someone sent you a direct message.")
|
||||
} else if (
|
||||
mentions &&
|
||||
event.pubkey !== pubkey.get() &&
|
||||
getPubkeyTagValues(event.tags).includes(pubkey.get()!)
|
||||
) {
|
||||
this._notify(event, "Someone mentioned you", "Someone tagged you in a message.")
|
||||
} else if (spaces) {
|
||||
this._notify(event, "New activity", "Someone posted a new message.")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async disable() {
|
||||
this._unsubscriber?.()
|
||||
this._unsubscriber = undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Capacitor} from "@capacitor/core"
|
||||
import {notificationSettings, pushState} from "@app/core/state"
|
||||
import {WebNotifications} from "@app/util/push/adapters/web"
|
||||
import {CapacitorNotifications} from "@app/util/push/adapters/capacitor"
|
||||
import {AndroidFallbackNotifications} from "@app/util/push/adapters/android"
|
||||
import type {IPushAdapter} from "@app/util/push/adapters/common"
|
||||
|
||||
export {onNotification} from "@app/util/push/adapters/common"
|
||||
|
||||
export class Push {
|
||||
static _adapter: IPushAdapter | undefined
|
||||
|
||||
static _getAdapter() {
|
||||
if (!Push._adapter) {
|
||||
const {useFallback} = pushState.get()
|
||||
|
||||
if (Capacitor.getPlatform() === "android" && useFallback) {
|
||||
Push._adapter = new AndroidFallbackNotifications()
|
||||
} else if (Capacitor.isNativePlatform()) {
|
||||
Push._adapter = new CapacitorNotifications()
|
||||
} else {
|
||||
Push._adapter = new WebNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
return Push._adapter
|
||||
}
|
||||
|
||||
static async request() {
|
||||
const adapter = Push._getAdapter()
|
||||
|
||||
let permission = await adapter.request()
|
||||
if (permission !== "granted" && adapter instanceof CapacitorNotifications) {
|
||||
Push._adapter = new AndroidFallbackNotifications()
|
||||
permission = await Push._adapter.request()
|
||||
|
||||
if (permission === "granted") {
|
||||
pushState.set({useFallback: true})
|
||||
}
|
||||
}
|
||||
|
||||
return permission
|
||||
}
|
||||
|
||||
static disable() {
|
||||
return Push._getAdapter().disable()
|
||||
}
|
||||
|
||||
static enable() {
|
||||
return Push._getAdapter().enable()
|
||||
}
|
||||
|
||||
static sync() {
|
||||
return notificationSettings.subscribe(({push}) => {
|
||||
if (push) {
|
||||
Push.enable()
|
||||
} else {
|
||||
Push.disable()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,7 @@
|
||||
import {setupAnalytics} from "@app/util/analytics"
|
||||
import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies"
|
||||
import {kv, db} from "@app/core/storage"
|
||||
import {
|
||||
device,
|
||||
userSettingsValues,
|
||||
notificationSettings,
|
||||
notificationState,
|
||||
} from "@app/core/state"
|
||||
import {device, userSettingsValues, notificationSettings, pushState} from "@app/core/state"
|
||||
import {syncApplicationData} from "@app/core/sync"
|
||||
import * as commands from "@app/core/commands"
|
||||
import * as requests from "@app/core/requests"
|
||||
@@ -41,6 +36,7 @@
|
||||
import {theme} from "@app/util/theme"
|
||||
import {toast, pushToast} from "@app/util/toast"
|
||||
import * as notifications from "@app/util/notifications"
|
||||
import {onPushNotificationAction} from "@app/util/push/adapters/common"
|
||||
import * as storage from "@app/util/storage"
|
||||
import {syncKeyboard} from "@app/util/keyboard"
|
||||
import {getPageTitle} from "@app/util/title"
|
||||
@@ -71,8 +67,16 @@
|
||||
})
|
||||
|
||||
// Listen for deep link events
|
||||
App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
|
||||
App.addListener("appUrlOpen", async (event: URLOpenListenerEvent) => {
|
||||
const url = new URL(event.url)
|
||||
const relay = url.searchParams.get("relay")
|
||||
const id = url.searchParams.get("id")
|
||||
|
||||
if (relay && id) {
|
||||
onPushNotificationAction({notification: {data: {relay, id}}} as any)
|
||||
return
|
||||
}
|
||||
|
||||
const target = `${url.pathname}${url.search}${url.hash}`
|
||||
goto(target, {replaceState: false, noScroll: false})
|
||||
})
|
||||
@@ -121,7 +125,7 @@
|
||||
}),
|
||||
sync({
|
||||
key: "notificationState",
|
||||
store: notificationState,
|
||||
store: pushState,
|
||||
storage: kv,
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -24,17 +24,19 @@
|
||||
clearBadges()
|
||||
}
|
||||
|
||||
if (settings.push) {
|
||||
const permissions = await Push.request()
|
||||
let permission = "granted"
|
||||
|
||||
if (permissions !== "granted") {
|
||||
if (settings.push) {
|
||||
permission = await Push.request()
|
||||
|
||||
if (!permission.startsWith("granted")) {
|
||||
await sleep(300)
|
||||
|
||||
settings.push = false
|
||||
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: `Failed to request notification permissions (${permissions}).`,
|
||||
message: `Failed to request notification permissions (${permission}).`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user