Clean up and fix push notifications implementation

This commit is contained in:
Jon Staab
2026-01-23 15:35:54 -08:00
parent 286d939097
commit 2528e4acad
7 changed files with 107 additions and 83 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ VITE_PLATFORM_RELAYS=
VITE_PLATFORM_ACCENT="#7161FF" VITE_PLATFORM_ACCENT="#7161FF"
VITE_PLATFORM_SECONDARY="#EB5E28" VITE_PLATFORM_SECONDARY="#EB5E28"
VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities." VITE_PLATFORM_DESCRIPTION="Flotilla is nostr — for communities."
VITE_PUSH_SERVER= VITE_PUSH_SERVER=https://nps.flotilla.social/
VITE_PUSH_BRIDGE= VITE_PUSH_BRIDGE=wss://npb.coracle.social/
VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one VITE_BLOCKED_RELAYS=brb.io,relay.nostr.band,nostr.mutinywallet.com,feeds.nostr.band,nostr.zbd.gg,wot.utxo.one
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
+6 -3
View File
@@ -11,12 +11,15 @@ const config: CapacitorConfig = {
adjustMarginsForEdgeToEdge: false, adjustMarginsForEdgeToEdge: false,
}, },
plugins: { plugins: {
SystemBars: {
insetsHandling: "disable",
},
SplashScreen: { SplashScreen: {
androidSplashResourceName: "splash" androidSplashResourceName: "splash",
}, },
Keyboard: { Keyboard: {
style: "DARK", style: "DARK",
resizeOnFullScreen: true, // resizeOnFullScreen: true,
}, },
Badge: { Badge: {
persist: true, persist: true,
@@ -25,7 +28,7 @@ const config: CapacitorConfig = {
}, },
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload // Use this for live reload https://capacitorjs.com/docs/guides/live-reload
// server: { // server: {
// url: "http://192.168.1.115:1847", // url: "http://192.168.1.65:1847",
// cleartext: true // cleartext: true
// }, // },
}; };
+1 -1
View File
@@ -125,7 +125,7 @@ export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios"
export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER
export const PUSH_BRIDGE = import.meta.env.VITE_PUSH_BRIDGE export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.env.VITE_PUSH_BRIDGE)
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
+71 -76
View File
@@ -23,7 +23,7 @@ import {
assoc, assoc,
poll, poll,
prop, prop,
sha256, hash,
textEncoder, textEncoder,
parseJson, parseJson,
flatten, flatten,
@@ -37,7 +37,7 @@ import {
postJson, postJson,
fetchJson, fetchJson,
} from "@welshman/lib" } from "@welshman/lib"
import type {TrustedEvent, Filter} from "@welshman/util" import type {TrustedEvent, RelayProfile, Filter} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store" import {deriveEventsByIdByUrl} from "@welshman/store"
import { import {
ZAP_GOAL, ZAP_GOAL,
@@ -51,6 +51,7 @@ import {
makeEvent, makeEvent,
RelayMode, RelayMode,
} from "@welshman/util" } from "@welshman/util"
import {buildUrl} from '@lib/util'
import { import {
makeSpacePath, makeSpacePath,
makeChatPath, makeChatPath,
@@ -73,6 +74,7 @@ import {
chatsById, chatsById,
hasNip29, hasNip29,
getSettings, getSettings,
userSettings,
userSettingsValues, userSettingsValues,
userGroupList, userGroupList,
getSpaceUrlsFromGroupList, getSpaceUrlsFromGroupList,
@@ -336,12 +338,13 @@ export const clearBadges = async () => {
// Local notifications // Local notifications
interface IPushAdapter { interface IPushAdapter {
request: () => Promise<string> request: (prompt: boolean) => Promise<string>
cancel: () => Promise<void>
start: () => Unsubscriber start: () => Unsubscriber
} }
class CapacitorNotifications implements IPushAdapter { class CapacitorNotifications implements IPushAdapter {
async sync() { async sync(signal: AbortSignal) {
const token = get(pushToken) const token = get(pushToken)
if (!token) { if (!token) {
@@ -355,21 +358,23 @@ class CapacitorNotifications implements IPushAdapter {
const secret = get(pushSecret) const secret = get(pushSecret)
if (secret) { if (secret) {
const {callback} = await fetchJson(`${PUSH_SERVER}/subscription/${secret}`) const res = await fetch(buildUrl(PUSH_SERVER, "subscription", secret), {signal})
if (callback) { if (res.ok) {
return {secret, callback} const {callback} = await res.json()
} else {
pushSecret.set(undefined) if (callback) {
return {secret, callback}
} else {
pushSecret.set(undefined)
}
} }
} }
const ios = Capacitor.getPlatform() === "ios" const ios = Capacitor.getPlatform() === "ios"
const channel = ios ? "apns" : "fcm" const channel = ios ? "apns" : "fcm"
const topic = "social.flotilla" const url = buildUrl(PUSH_SERVER, "subscription", channel)
const data = ios ? {token, topic} : {token} const json = await postJson(url, {token}, {signal})
const json = await postJson(`${PUSH_SERVER}/subscription/${channel}`, data)
if (json) { if (json) {
pushSecret.set(json.sk) pushSecret.set(json.sk)
@@ -382,10 +387,13 @@ class CapacitorNotifications implements IPushAdapter {
return return
} }
const canPush = (relay?: RelayProfile) =>
Boolean(relay?.self && relay?.supported_nips?.map(String)?.includes("9a"))
const getPushStuff = async (url: string) => { const getPushStuff = async (url: string) => {
let relay = await loadRelay(url) let relay = await loadRelay(url)
if (!relay?.self || !relay?.supported_nips?.includes("9a")) { if (!canPush(relay)) {
relay = await loadRelay(PUSH_BRIDGE) relay = await loadRelay(PUSH_BRIDGE)
} }
@@ -406,9 +414,10 @@ class CapacitorNotifications implements IPushAdapter {
console.warn(`Failed to subscribe ${relay} to ${key} notifications: unsupported`) console.warn(`Failed to subscribe ${relay} to ${key} notifications: unsupported`)
} else { } else {
const {url, pubkey} = stuff const {url, pubkey} = stuff
const identifier = await sha256(textEncoder.encode(info.callback + relay + key)) const identifier = String(hash(info.callback + relay + key))
const thunk = publishThunk({ const thunk = publishThunk({
signal,
relays: [url], relays: [url],
event: makeEvent(30390, { event: makeEvent(30390, {
content: await signer content: await signer
@@ -454,10 +463,10 @@ class CapacitorNotifications implements IPushAdapter {
} }
} }
async request() { async request(prompt = true) {
let status = await PushNotifications.checkPermissions() let status = await PushNotifications.checkPermissions()
if (status.receive === "prompt") { if (prompt && status.receive === "prompt") {
status = await PushNotifications.requestPermissions() status = await PushNotifications.requestPermissions()
} }
@@ -465,53 +474,50 @@ class CapacitorNotifications implements IPushAdapter {
return status.receive return status.receive
} }
let token = "" let token = get(pushToken)
PushNotifications.addListener("registration", ({value}: Token) => { if (!token) {
token = value PushNotifications.addListener("registration", ({value}: Token) => {
}) token = value
})
PushNotifications.addListener("registrationError", (error: RegistrationError) => { PushNotifications.addListener("registrationError", (error: RegistrationError) => {
console.error(error) console.error(error)
}) })
await PushNotifications.register() await Promise.all([
await poll({ PushNotifications.register(),
condition: () => Boolean(token), poll({
signal: AbortSignal.timeout(5000), condition: () => Boolean(token),
}) signal: AbortSignal.timeout(5000),
}),
])
if (token) {
pushToken.set(token) pushToken.set(token)
return "granted"
} }
pushToken.set(undefined) return token ? "granted" : "denied"
return "denied"
} }
start() { start() {
this.sync().then(() => { const controller = new AbortController()
PushNotifications.addListener(
"pushNotificationActionPerformed",
async (action: ActionPerformed) => {
const event = parseJson(action.notification.data.event)
const relays = [action.notification.data.relay]
goto(await getEventPath(event, relays)) this.sync(controller.signal)
},
)
})
return () => PushNotifications.removeAllListeners() return () => controller.abort()
}
async cancel() {
await PushNotifications.unregister()
pushSecret.set(undefined)
pushToken.set(undefined)
} }
} }
class WebNotifications implements IPushAdapter { class WebNotifications implements IPushAdapter {
async request() { async request(prompt = true) {
if (Notification?.permission === "default") { if (prompt && Notification?.permission === "default") {
await Notification.requestPermission() await Notification.requestPermission()
} }
@@ -561,6 +567,10 @@ class WebNotifications implements IPushAdapter {
} }
}) })
} }
async cancel() {
// pass
}
} }
export class Push { export class Push {
@@ -580,39 +590,24 @@ export class Push {
} }
static start() { static start() {
return Push._getAdapter().start() const adapter = Push._getAdapter()
const promise = adapter.request(false)
const controller = new AbortController()
promise.then(permissions => {
if (permissions === "granted" && !controller.signal.aborted) {
controller.signal.addEventListener("abort", adapter.start())
}
})
Push._unsubscriber = () => controller.abort()
} }
static request() { static request() {
return Push._getAdapter().request() return Push._getAdapter().request()
} }
static resume() { static cancel() {
const unsubscribe = userSettingsValues.subscribe(({alerts_push}) => { return Push._getAdapter().cancel()
if (alerts_push) {
const promise = Push.request()
const controller = new AbortController()
promise.then(permissions => {
if (permissions === "granted" && !controller.signal.aborted) {
controller.signal.addEventListener("abort", Push.start())
}
})
Push._unsubscriber = () => controller.abort()
} else {
Push.stop()
}
})
return () => {
unsubscribe()
Push.stop()
}
}
static stop() {
Push._unsubscriber?.()
Push._unsubscriber = undefined
} }
} }
+8
View File
@@ -16,3 +16,11 @@ export const day = (seconds: number) => Math.floor(seconds / DAY)
export const daysBetween = (start: number, end: number) => [...range(start, end, DAY)].map(day) export const daysBetween = (start: number, end: number) => [...range(start, end, DAY)].map(day)
export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base)
url.pathname = '/' + pathname.join('/')
return url.toString()
}
+13 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import "@src/app.css" import "@src/app.css"
import "@capacitor-community/safe-area" import "@capacitor-community/safe-area"
import {PushNotifications} from "@capacitor/push-notifications"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import type {Unsubscriber} from "svelte/store" import type {Unsubscriber} from "svelte/store"
import {get} from "svelte/store" import {get} from "svelte/store"
@@ -43,6 +44,17 @@
const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy] const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy]
PushNotifications.addListener(
"pushNotificationActionPerformed",
async (action: ActionPerformed) => {
console.log('====== action', JSON.stringify(action))
const event = parseJson(action.notification.data.event)
const relays = [action.notification.data.relay]
goto(await getEventPath(event, relays))
},
)
// Add stuff to window for convenience // Add stuff to window for convenience
Object.assign(window, { Object.assign(window, {
get, get,
@@ -129,7 +141,7 @@
unsubscribers.push(syncKeyboard()) unsubscribers.push(syncKeyboard())
// Initialize background notifications // Initialize background notifications
unsubscribers.push(notifications.Push.resume()) unsubscribers.push(notifications.Push.start())
// Listen for signer errors, report to user via toast // Listen for signer errors, report to user via toast
unsubscribers.push( unsubscribers.push(
+6
View File
@@ -48,6 +48,12 @@
const onsubmit = preventDefault(async () => { const onsubmit = preventDefault(async () => {
await publishSettings($state.snapshot(settings)) await publishSettings($state.snapshot(settings))
if (settings.alerts_push) {
await Alerts.start()
} else {
await Alerts.cancel()
}
pushToast({message: "Your settings have been saved!"}) pushToast({message: "Your settings have been saved!"})
}) })