refactor notification syncing

This commit is contained in:
Jon Staab
2026-01-27 17:15:22 -08:00
parent 143a1dd39b
commit bf6abd301c
6 changed files with 245 additions and 254 deletions
+10 -10
View File
@@ -1,11 +1,11 @@
import type { CapacitorConfig } from '@capacitor/cli';
import type {CapacitorConfig} from "@capacitor/cli"
const config: CapacitorConfig = {
appId: 'social.flotilla',
appName: 'Flotilla',
webDir: 'build'
appId: "social.flotilla",
appName: "Flotilla",
webDir: "build",
server: {
androidScheme: "https"
androidScheme: "https",
},
android: {
adjustMarginsForEdgeToEdge: false,
@@ -23,14 +23,14 @@ const config: CapacitorConfig = {
},
Badge: {
persist: true,
autoClear: true
autoClear: true,
},
},
// Use this for live reload https://capacitorjs.com/docs/guides/live-reload
server: {
url: "http://192.168.1.65:1847",
cleartext: true
url: "http://192.168.1.148:1847",
cleartext: true,
},
};
}
export default config;
export default config
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {getProfile, loadProfile} from "@welshman/app"
import {isMobile} from '@lib/html'
import {isMobile} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
type Props = {
+13 -9
View File
@@ -24,6 +24,7 @@ import {
addToMapKey,
identity,
always,
randomId,
tryCatch,
fromPairs,
remove,
@@ -337,6 +338,8 @@ export const relaysMostlyRestricted = writable<Record<string, string>>({})
// Push notifications
export const device = withGetter(writable(randomId()))
export const notificationSettings = withGetter(
writable({
push: false,
@@ -348,16 +351,17 @@ export const notificationSettings = withGetter(
}),
)
export const notificationState = withGetter(
writable<{
token?: string
subscription?: {
key: string
callback: string
}
}>({}),
)
export type PushSubscription = {
key: string
callback: string
}
export type PushState = {
token?: string
subscription?: PushSubscription
}
export const notificationState = withGetter(writable<PushState>({}))
// Chats
+208 -224
View File
@@ -1,10 +1,10 @@
import type {Unsubscriber, Subscriber} from "svelte/store"
import type {Unsubscriber, Readable, Subscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {Badge} from "@capawesome/capacitor-badge"
import {PushNotifications} from "@capacitor/push-notifications"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {synced, throttled, deriveDeduplicated, deriveDeduplicatedByValue} from "@welshman/store"
import {synced, throttled} from "@welshman/store"
import {
pubkey,
tracker,
@@ -34,8 +34,9 @@ import {
postJson,
nth,
nthEq,
maybe,
} from "@welshman/lib"
import type {TrustedEvent, RelayProfile, Filter} from "@welshman/util"
import type {TrustedEvent, Filter} from "@welshman/util"
import {deriveEventsByIdByUrl} from "@welshman/store"
import {
ZAP_GOAL,
@@ -82,10 +83,17 @@ import {
userSpaceUrls,
splitRoomId,
makeRoomId,
device,
} from "@app/core/state"
import {kv} from "@app/core/storage"
import {goto} from "$app/navigation"
// 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)
// Checked state
export const checked = synced<Record<string, number>>({
@@ -313,9 +321,9 @@ export const onNotification = call(() => {
// Badges
export const syncBadges = () =>
derived([notifications, userSettingsValues], identity).subscribe(
async ([$notifications, {alerts_badge}]) => {
if (alerts_badge) {
derived([notifications, notificationSettings], identity).subscribe(
async ([$notifications, $notificationSettings]) => {
if ($notificationSettings.badge) {
try {
await Badge.set({count: $notifications.size})
} catch (err) {
@@ -339,16 +347,15 @@ export const clearBadges = async () => {
interface IPushAdapter {
request: (prompt?: boolean) => Promise<string>
enable: () => Promise<void>
disable: () => Promise<void>
start: () => Unsubscriber
enable: () => Promise<void>
stop: () => Promise<void>
}
if (Capacitor.isNativePlatform()) {
PushNotifications.addListener(
"pushNotificationActionPerformed",
async (action: ActionPerformed) => {
console.log('=======', JSON.stringify(action))
const event = parseJson(action.notification.data.event)
const relays = [action.notification.data.relay]
@@ -358,6 +365,8 @@ if (Capacitor.isNativePlatform()) {
}
class CapacitorNotifications implements IPushAdapter {
_controller = maybe<AbortController>()
async request(prompt = true) {
let status = await PushNotifications.checkPermissions()
@@ -394,7 +403,7 @@ class CapacitorNotifications implements IPushAdapter {
return token ? "granted" : "denied"
}
async syncServer(signal: AbortSignal) {
async _syncServer(signal: AbortSignal) {
const {token, subscription} = notificationState.get()
if (!token) {
@@ -414,205 +423,160 @@ class CapacitorNotifications implements IPushAdapter {
}
}
async syncRelays(signal: AbortSignal) {
_getSubscriptionIdentifier = (relay: string, key: string) =>
String(hash(relay + key + device.get()))
_getPushStuff = async (url: string) => {
let relay = await loadRelay(url)
if (!relay?.self || !relay?.supported_nips?.map(String)?.includes("9a")) {
relay = await loadRelay(PUSH_BRIDGE)
}
if (relay?.self) {
return {url: relay.url, pubkey: relay.self}
}
}
_syncRelay = async (relay: string, key: string, filters: Filter[], ignore: Filter[] = []) => {
console.log(`=== syncing ${relay} ${key}`)
const {subscription} = notificationState.get()
if (signal.aborted) {
if (!subscription) {
console.warn(`Failed to subscribe ${relay} to notifications: no subscription`)
return
}
if (!subscription) {
throw new Error("Attempted to sync relays without a subscription")
const stuff = await this._getPushStuff(relay)
if (!stuff) {
console.warn(`Failed to subscribe ${relay} to notifications: unsupported`)
return
}
const canPush = (relay?: RelayProfile) =>
Boolean(relay?.self && relay?.supported_nips?.map(String)?.includes("9a"))
const {url, pubkey} = stuff
const identifier = this._getSubscriptionIdentifier(relay, key)
const getPushStuff = async (url: string) => {
let relay = await loadRelay(url)
const thunk = publishThunk({
relays: [url],
event: makeEvent(30390, {
content: await signer
.get()
.nip44.encrypt(
pubkey,
JSON.stringify([
["relay", relay],
["callback", subscription.callback],
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
...filters.map(filter => ["filter", JSON.stringify(filter)]),
]),
),
tags: [
["d", identifier],
["p", pubkey],
],
}),
})
if (!canPush(relay)) {
relay = await loadRelay(PUSH_BRIDGE)
}
const error = await waitForThunkError(thunk)
if (relay?.self) {
return {url: relay.url, pubkey: relay.self}
}
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
}
const syncRelay = async (
key: string,
relay: string,
filters: Filter[],
ignore: Filter[] = [],
) => {
const stuff = await getPushStuff(relay)
if (!stuff) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications: unsupported`)
} else {
const {url, pubkey} = stuff
const identifier = String(hash(subscription.callback + relay + key))
const thunk = publishThunk({
signal,
relays: [url],
event: makeEvent(30390, {
content: await signer
.get()
.nip44.encrypt(
pubkey,
JSON.stringify([
["relay", relay],
["callback", subscription.callback],
...ignore.map(filter => ["ignore", JSON.stringify(filter)]),
...filters.map(filter => ["filter", JSON.stringify(filter)]),
]),
),
tags: [
["d", identifier],
["p", pubkey],
],
}),
})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to subscribe ${relay} to ${key} notifications:`, error)
}
}
}
const unsyncRelay = async (key: string, relay: string) => {
const stuff = await getPushStuff(relay)
if (!stuff) {
console.warn(`Failed to unsubscribe ${relay} from ${key} notifications: unsupported`)
} else {
const identifier = String(hash(subscription.callback + relay + key))
const address = new Address(30390, pubkey.get()!, identifier).toString()
const thunk = publishThunk({
signal,
relays: [stuff.url],
event: makeEvent(DELETE, {tags: [["a", address]]}),
})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to unsubscribe ${relay} from ${key} notifications:`, error)
}
}
}
const syncedSpaceUrls = new Set<string>()
const syncSpaceRelay = (url: string) => {
const {spaces, mentions} = notificationSettings.get()
const filters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
const mutedRooms = getSettings().muted_rooms.map(splitRoomId).filter(nthEq(0, url)).map(nth(1))
if (spaces) {
syncRelay("spaces", url, filters, [{"#h": [mutedRooms]}])
} else {
unsyncRelay("spaces", url)
}
if (mentions) {
const mentionFilters = filters.map(assoc("#p", [pubkey.get()!]))
if (!spaces) {
syncRelay("mentions", url, mentionFilters)
} else if (mutedRooms.length > 0) {
syncRelay("mentions", url, mentionFilters.map(assoc('#h', [mutedRooms])))
} else {
unsyncRelay("mentions", url)
}
} else {
unsyncRelay("mentions", url)
}
}
const syncSpaceRelays = () => {
const $userSpaceUrls = get(userSpaceUrls)
const {spaces, mentions} = notificationSettings.get()
for (const url of $userSpaceUrls) {
syncSpaceRelay(url)
syncedSpaceUrls.add(url)
}
for (const url of syncedSpaceUrls) {
if (!$userSpaceUrls.includes(url)) {
unsyncRelay("spaces", url)
syncedSpaceUrls.delete(url)
}
}
}
const syncedMessagingUrls = new Set<string>()
const syncMessagingRelay = (url: string) => {
const {messages} = notificationSettings.get()
if (messages) {
syncRelay("messages", url, [{kinds: DM_KINDS, "#p": [pubkey.get()!]}])
} else {
unsyncRelay("messages", url)
}
}
const syncMessagingRelays = () => {
const messagingRelayUrls = getRelaysFromList(get(userMessagingRelayList))
for (const url of messagingRelayUrls) {
syncMessagingRelay(url)
syncedMessagingUrls.add(url)
}
for (const url of syncedMessagingUrls) {
if (!messagingRelayUrls.includes(url)) {
unsyncRelay("messages", url)
syncedMessagingUrls.delete(url)
}
}
}
const unsubscribers = [
userSpaceUrls.subscribe(syncSpaceRelays),
userMessagingRelayList.subscribe(syncMessagingRelays),
userSettingsValues.subscribe(syncSpaceRelays),
userSettingsValues.subscribe(syncMessagingRelays),
]
signal.addEventListener("abort", () => unsubscribers.forEach(call))
}
start() {
const {token} = notificationState.get()
const controller = new AbortController()
const {signal} = controller
_unsyncRelay = async (relay: string, keys: string[]) => {
console.log(`=== unsyncing ${relay} ${keys.join(", ")}`)
if (!token) {
console.warn("Attempted to start push notifications without a callback")
} else {
call(async () => {
try {
await this.syncServer(signal)
await this.syncRelays(signal)
} catch (e) {
console.error(e)
}
})
const stuff = await this._getPushStuff(relay)
if (!stuff) {
console.warn(`Failed to unsubscribe ${relay} from notifications: unsupported`)
return
}
return () => controller.abort()
const {url} = stuff
const tags: string[][] = []
for (const key of keys) {
const identifier = this._getSubscriptionIdentifier(relay, key)
const address = new Address(30390, pubkey.get()!, identifier).toString()
tags.push(["a", address])
}
const thunk = publishThunk({relays: [url], event: makeEvent(DELETE, {tags})})
const error = await waitForThunkError(thunk)
if (error) {
console.warn(`Failed to unsubscribe ${relay} from notifications:`, error)
}
}
async _syncSpaceSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userSpaceUrls, notificationSettings, userSettingsValues]).subscribe(
([$userSpaceUrls, {spaces, mentions}, {muted_rooms}]) => {
const filters = [{kinds: MESSAGE_KINDS}, makeCommentFilter(CONTENT_KINDS)]
for (const url of $userSpaceUrls) {
if (!spaces && !mentions) {
this._unsyncRelay(url, ["spaces", "mentions"])
} else if (!spaces) {
this._unsyncRelay(url, ["spaces"])
} else if (!mentions) {
this._unsyncRelay(url, ["mentions"])
}
const mutedRooms = muted_rooms.map(splitRoomId).filter(nthEq(0, url)).map(nth(1))
if (spaces) {
this._syncRelay(url, "spaces", filters, [{"#h": [mutedRooms]}])
}
if (mentions) {
const mentionFilters = filters.map(assoc("#p", [pubkey.get()!]))
if (!spaces) {
this._syncRelay(url, "mentions", mentionFilters)
} else if (mutedRooms.length > 0) {
this._syncRelay(url, "mentions", mentionFilters.map(assoc("#h", [mutedRooms])))
}
}
}
},
),
)
}
async _syncMessageSubscription(signal: AbortSignal) {
signal.addEventListener(
"abort",
merged([userMessagingRelayList, notificationSettings]).subscribe(
([$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() {
notificationSettings.update(assoc("push", true))
this._controller = new AbortController()
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() {
@@ -628,12 +592,26 @@ class CapacitorNotifications implements IPushAdapter {
}
}
notificationSettings.update(assoc('push', false))
for (const url of get(userSpaceUrls)) {
this._unsyncRelay(url, ["spaces", "mentions"])
}
for (const url of getRelaysFromList(get(userMessagingRelayList))) {
this._unsyncRelay(url, ["messages"])
}
notificationState.set({})
}
async stop() {
this._controller?.abort()
this._controller = undefined
}
}
class WebNotifications implements IPushAdapter {
_unsubscriber = maybe<Unsubscriber>()
async request(prompt = true) {
if (prompt && Notification?.permission === "default") {
await Notification.requestPermission()
@@ -642,7 +620,7 @@ class WebNotifications implements IPushAdapter {
return Notification?.permission || "denied"
}
notify(event: TrustedEvent, title: string, body: string) {
_notify(event: TrustedEvent, title: string, body: string) {
const notification = new Notification(title, {
body,
tag: event.id,
@@ -666,32 +644,33 @@ class WebNotifications implements IPushAdapter {
document.addEventListener("visibilitychange", onVisibilityChange)
}
start() {
return onNotification(event => {
const {alerts_push, alerts_messages, alerts_mentions, alerts_spaces} = getSettings()
async enable() {
this._unsubscriber = onNotification(event => {
const {push, messages, mentions, spaces} = notificationSettings.get()
if (alerts_push && document.hidden && Notification?.permission === "granted") {
if (alerts_messages && matchFilters(dmFilters, event)) {
this.notify(event, "New direct message", "Someone sent you a direct message.")
if (push && document.hidden && Notification?.permission === "granted") {
if (messages && matchFilters(dmFilters, event)) {
this._notify(event, "New direct message", "Someone sent you a direct message.")
} else if (
alerts_mentions &&
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 (alerts_spaces) {
this.notify(event, "New activity", "Someone posted a new message.")
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 enable() {
notificationSettings.update(assoc("push", true))
async disable() {
// pass
}
async disable() {
notificationSettings.update(assoc("push", false))
async stop() {
this._unsubscriber?.()
this._unsubscriber = undefined
}
}
@@ -710,29 +689,34 @@ export class Push {
return Push._adapter
}
static 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())
}
})
return () => controller.abort()
}
static request() {
return Push._getAdapter().request()
}
static enable() {
return Push._getAdapter().enable()
}
static disable() {
return Push._getAdapter().disable()
}
static enable() {
return Push._getAdapter().enable()
}
static stop() {
return Push._getAdapter().stop()
}
static sync() {
const adapter = Push._getAdapter()
const unsubscriber = notificationSettings.subscribe($notificationSettings => {
if ($notificationSettings.push) {
adapter.enable()
} else {
adapter.disable()
}
})
return () => {
unsubscriber()
adapter.stop()
}
}
}
+12 -2
View File
@@ -27,7 +27,12 @@
import {setupAnalytics} from "@app/util/analytics"
import {authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy} from "@app/util/policies"
import {kv, db} from "@app/core/storage"
import {userSettingsValues, notificationSettings, notificationState} from "@app/core/state"
import {
device,
userSettingsValues,
notificationSettings,
notificationState,
} from "@app/core/state"
import {syncApplicationData} from "@app/core/sync"
import * as commands from "@app/core/commands"
import * as requests from "@app/core/requests"
@@ -87,6 +92,11 @@
// Sync stuff to localstorage
await Promise.all([
sync({
key: "device",
store: device,
storage: kv,
}),
sync({
key: "pubkey",
store: pubkey,
@@ -139,7 +149,7 @@
unsubscribers.push(syncKeyboard())
// Initialize background notifications
unsubscribers.push(notifications.Push.start())
unsubscribers.push(notifications.Push.sync())
// Listen for signer errors, report to user via toast
unsubscribers.push(
+1 -8
View File
@@ -39,18 +39,11 @@
settings.push = false
pushToast({
return pushToast({
theme: "error",
message: "Failed to request notification permissions.",
})
return
}
await Push.enable()
await Push.start()
} else {
await Push.disable()
}
if (!equals(muted_rooms, $userSettingsValues.muted_rooms)) {