Add fcm push notifications

This commit is contained in:
Jon Staab 2025-06-24 14:15:36 -07:00
parent 6cca823ed4
commit 3655790e5f
19 changed files with 189 additions and 67 deletions

View file

@ -12,6 +12,7 @@ dependencies {
implementation project(':capacitor-community-safe-area') implementation project(':capacitor-community-safe-area')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':nostr-signer-capacitor-plugin') implementation project(':nostr-signer-capacitor-plugin')
} }

View file

@ -34,4 +34,5 @@
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest> </manifest>

View file

@ -11,5 +11,8 @@ project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacito
include ':capacitor-keyboard' include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android') project(':capacitor-keyboard').projectDir = new File('../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications/android')
include ':nostr-signer-capacitor-plugin' include ':nostr-signer-capacitor-plugin'
project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android') project(':nostr-signer-capacitor-plugin').projectDir = new File('../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin/android')

View file

@ -46,4 +46,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
} }
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Enable push notifications https://capacitorjs.com/docs/apis/push-notifications
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
} }

View file

@ -49,5 +49,9 @@
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>

View file

@ -14,6 +14,7 @@ def capacitor_pods
pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area' pod 'CapacitorCommunitySafeArea', :path => '../../node_modules/.pnpm/@capacitor-community+safe-area@7.0.0-alpha.1_@capacitor+core@7.2.0/node_modules/@capacitor-community/safe-area'
pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/app'
pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard' pod 'CapacitorKeyboard', :path => '../../node_modules/.pnpm/@capacitor+keyboard@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/keyboard'
pod 'CapacitorPushNotifications', :path => '../../node_modules/.pnpm/@capacitor+push-notifications@7.0.1_@capacitor+core@7.2.0/node_modules/@capacitor/push-notifications'
pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin' pod 'NostrSignerCapacitorPlugin', :path => '../../node_modules/.pnpm/nostr-signer-capacitor-plugin@0.0.4_@capacitor+core@7.2.0/node_modules/nostr-signer-capacitor-plugin'
end end

View file

@ -45,6 +45,7 @@
"@capacitor/core": "^7.0.1", "@capacitor/core": "^7.0.1",
"@capacitor/ios": "^7.0.0", "@capacitor/ios": "^7.0.0",
"@capacitor/keyboard": "^7.0.0", "@capacitor/keyboard": "^7.0.0",
"@capacitor/push-notifications": "^7.0.1",
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sentry/browser": "^8.35.0", "@sentry/browser": "^8.35.0",
"@sveltejs/adapter-static": "^3.0.4", "@sveltejs/adapter-static": "^3.0.4",

Binary file not shown.

View file

@ -1,6 +1,5 @@
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store" import {get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib" import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds" import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
@ -21,6 +20,7 @@ import {
ALERT_ANDROID, ALERT_ANDROID,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
getAddress,
displayProfile, displayProfile,
normalizeRelayUrl, normalizeRelayUrl,
makeList, makeList,
@ -62,8 +62,8 @@ import {
NOTIFIER_PUBKEY, NOTIFIER_PUBKEY,
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
deviceAlertAddresses,
} from "@app/state" } from "@app/state"
import {getPushInfo} from "@app/push"
// Utils // Utils
@ -388,10 +388,10 @@ export type AlertParams = {
auth: string auth: string
} }
ios?: { ios?: {
nothing: string device_token: string
} }
android?: { android?: {
nothing: string device_token: string
} }
} }
@ -433,5 +433,10 @@ export const makeAlert = async (params: AlertParams) => {
}) })
} }
export const publishAlert = async (params: AlertParams) => export const publishAlert = async (params: AlertParams) => {
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) const event = await signer.get().sign(await makeAlert(params))
deviceAlertAddresses.update($addresses => [...$addresses, getAddress(event)])
return publishThunk({event, relays: [NOTIFIER_RELAY]})
}

View file

@ -1,18 +1,28 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib" import {ucFirst} from "@lib/util"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {decrypt} from "@welshman/signer"
import {randomInt, parseJson, fromPairs, displayList, TIMEZONE, identity} from "@welshman/lib"
import {
displayRelayUrl,
getTagValue,
getAddress,
THREAD,
MESSAGE,
EVENT_TIME,
COMMENT,
} from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds" import {makeIntersectionFeed, makeRelayFeed, feedFromFilters} from "@welshman/feeds"
import {pubkey} from "@welshman/app" import {pubkey, signer, getThunkError} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {alerts, getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state" import {alerts, getMembershipUrls, userMembership, NOTIFIER_PUBKEY} from "@app/state"
import {loadAlertStatuses, requestRelayClaims} from "@app/requests" import {loadAlertStatuses, requestRelayClaims} from "@app/requests"
import {publishAlert} from "@app/commands" import {publishAlert} from "@app/commands"
import type {AlertParams} from "@app/commands" import type {AlertParams} from "@app/commands"
@ -114,20 +124,41 @@
try { try {
// @ts-ignore // @ts-ignore
params[platform] = await getPushInfo() params[platform] = await getPushInfo()
params.description = `${ucFirst(platform)} push notification ${description}.`
} catch (e: any) { } catch (e: any) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: String(e), message: String(e),
}) })
} }
params.description = `Push notification alert ${description}.`
} }
const thunk = await publishAlert(params) // If we don't do this we'll get an event rejection
await Pool.get().get(NOTIFIER_RELAY).auth.attemptAuth()
await thunk.result const thunk = await publishAlert(params)
await loadAlertStatuses($pubkey!) const error = await getThunkError(thunk)
if (error) {
return pushToast({
theme: "error",
message: `Failed to send your alert to the notification server (${error}).`,
})
}
// Fetch our new status to make sure it's active
const address = getAddress(thunk.event)
const statusEvents = await loadAlertStatuses($pubkey!)
const statusEvent = statusEvents.find(event => getTagValue("d", event.tags) === address)
const statusTags = statusEvent
? parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, statusEvent.content))
: []
const {status = "error", message = "Your alert was not activated"}: Record<string, string> =
fromPairs(statusTags)
if (status === "error") {
return pushToast({theme: "error", message})
}
pushToast({message: "Your alert has been successfully created!"}) pushToast({message: "Your alert has been successfully created!"})
back() back()

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import {parseJson, nthEq} from "@welshman/lib" import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds" import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util" import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte" import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state" import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state" import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -15,8 +15,7 @@
const {alert}: Props = $props() const {alert}: Props = $props()
const address = $derived(getAddress(alert.event)) const status = deriveAlertStatus(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const cron = $derived(getTagValue("cron", alert.tags)) const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags)) const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags)) const feeds = $derived(getTagValues("feed", alert.tags))
@ -39,24 +38,24 @@
</Button> </Button>
<div class="flex-inline gap-1">{description}</div> <div class="flex-inline gap-1">{description}</div>
</div> </div>
{#if status} {#if $status}
{@const statusText = getTagValue("status", status.tags) || "error"} {@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"} {#if statusText === "ok"}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content px-3 py-1 text-sm"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
Active Active
</span> </span>
{:else if statusText === "pending"} {:else if statusText === "pending"}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-base-content border-yellow-500 px-3 py-1 text-sm text-yellow-500"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
Pending Pending
</span> </span>
{:else} {:else}
<span <span
class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error" class="tooltip tooltip-left cursor-pointer rounded-full border border-solid border-error px-3 py-1 text-sm text-error"
data-tip={getTagValue("message", status.tags)}> data-tip={getTagValue("message", $status.tags)}>
{statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())} {statusText.replace("-", " ").replace(/^(.)/, x => x.toUpperCase())}
</span> </span>
{/if} {/if}

View file

@ -24,8 +24,8 @@
memberships, memberships,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
deviceAlerts,
hasNip29, hasNip29,
alerts,
} from "@app/state" } from "@app/state"
import {loadAlerts} from "@app/requests" import {loadAlerts} from "@app/requests"
import {notifications} from "@app/notifications" import {notifications} from "@app/notifications"
@ -40,7 +40,7 @@
const calendarPath = makeSpacePath(url, "calendar") const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const alert = $derived($alerts.find(a => getTagValue("feed", a.tags)?.includes(url))) const alert = $derived($deviceAlerts.find(a => getTagValue("feed", a.tags)?.includes(url)))
const openMenu = () => { const openMenu = () => {
showMenu = true showMenu = true

View file

@ -1,8 +1,28 @@
import * as nip19 from "nostr-tools/nip19"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import type {ActionPerformed, RegistrationError, Token} from "@capacitor/push-notifications"
import {PushNotifications} from "@capacitor/push-notifications"
import {sleep, parseJson} from "@welshman/lib"
import {isSignedEvent} from "@welshman/util"
import {goto} from "$app/navigation"
import {VAPID_PUBLIC_KEY} from "@app/state" import {VAPID_PUBLIC_KEY} from "@app/state"
export const platform = Capacitor.getPlatform() export const platform = Capacitor.getPlatform()
export const initializePushNotifications = () => {
if (platform === "web") return
PushNotifications.addListener("pushNotificationActionPerformed", (action: ActionPerformed) => {
const event = parseJson(action.notification.data.event)
const parsedRelays = parseJson(action.notification.data.relays)
const relays = Array.isArray(parsedRelays) ? parsedRelays : []
if (isSignedEvent(event)) {
goto("/" + nip19.neventEncode({id: event.id, relays}))
}
})
}
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform) export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
export const getWebPushInfo = async () => { export const getWebPushInfo = async () => {
@ -36,7 +56,7 @@ export const getWebPushInfo = async () => {
}) })
} }
const {endpoint, keys} = subscription.toJSON() const {keys} = subscription.toJSON()
if (!keys) { if (!keys) {
throw new Error(`Failed to get push info: no keys were returned`) throw new Error(`Failed to get push info: no keys were returned`)
@ -49,12 +69,36 @@ export const getWebPushInfo = async () => {
} }
} }
export const getIosPushInfo = async () => { export const getCapacitorPushInfo = async () => {
return {} let status = await PushNotifications.checkPermissions()
}
export const getAndroidPushInfo = async () => { if (status.receive === "prompt") {
return {} status = await PushNotifications.requestPermissions()
}
if (status.receive !== "granted") {
throw new Error("Failed to register for push notifications")
}
let device_token = ""
let error = "Failed to register for push notifications"
PushNotifications.addListener("registration", (token: Token) => {
device_token = token.value
})
PushNotifications.addListener("registrationError", (_error: RegistrationError) => {
error = _error.error
})
await PushNotifications.register()
await sleep(100)
if (!device_token) {
throw new Error(error)
}
return {device_token}
} }
export const getPushInfo = (): Promise<Record<string, string>> => { export const getPushInfo = (): Promise<Record<string, string>> => {
@ -62,9 +106,8 @@ export const getPushInfo = (): Promise<Record<string, string>> => {
case "web": case "web":
return getWebPushInfo() return getWebPushInfo()
case "ios": case "ios":
return getIosPushInfo()
case "android": case "android":
return getAndroidPushInfo() return getCapacitorPushInfo()
default: default:
throw new Error(`Invalid push platform: ${platform}`) throw new Error(`Invalid push platform: ${platform}`)
} }

View file

@ -367,7 +367,7 @@ export const listenForNotifications = () => {
for (const [url, allRooms] of userRoomsByUrl.get()) { for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter // Limit how many rooms we load at a time, since we have to send a separate filter
// for each one due to nip 29 breaking postel's law // for each one due to relay29 being picky
const rooms = shuffle(Array.from(allRooms)).slice(0, 30) const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({ load({
@ -375,6 +375,7 @@ export const listenForNotifications = () => {
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], limit: 1}, {kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1}, {kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
], ],
@ -385,6 +386,7 @@ export const listenForNotifications = () => {
relays: [url], relays: [url],
filters: [ filters: [
{kinds: [THREAD], since: now()}, {kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()}, {kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})), ...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
], ],

View file

@ -69,7 +69,7 @@ export const goToEvent = async (event: TrustedEvent, options: Record<string, any
const urls = Array.from(tracker.getRelays(event.id)) const urls = Array.from(tracker.getRelays(event.id))
const path = await getEventPath(event, urls) const path = await getEventPath(event, urls)
if (path.includes('://')) { if (path.includes("://")) {
window.open(path) window.open(path)
} else { } else {
goto(path, options) goto(path, options)
@ -94,7 +94,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
} }
if (event.kind === MESSAGE) { if (event.kind === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat') return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
} }
const kind = event.tags.find(nthEq(0, "K"))?.[1] const kind = event.tags.find(nthEq(0, "K"))?.[1]
@ -110,7 +110,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
} }
if (parseInt(kind) === MESSAGE) { if (parseInt(kind) === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat') return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
} }
} }
} }

View file

@ -23,7 +23,14 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import type {Socket} from "@welshman/net" import type {Socket} from "@welshman/net"
import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net" import {Pool, load, AuthStateEvent, SocketEvent} from "@welshman/net"
import {collection, custom} from "@welshman/store" import {
collection,
custom,
deriveEvents,
deriveEventsMapped,
withGetter,
synced,
} from "@welshman/store"
import { import {
getIdFilters, getIdFilters,
WRAP, WRAP,
@ -56,7 +63,9 @@ import {
asDecryptedEvent, asDecryptedEvent,
normalizeRelayUrl, normalizeRelayUrl,
getTag, getTag,
getTagValue,
getTagValues, getTagValues,
getAddress,
} from "@welshman/util" } from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util" import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer" import {Nip59, decrypt} from "@welshman/signer"
@ -81,7 +90,6 @@ import {
appContext, appContext,
} from "@welshman/app" } from "@welshman/app"
import type {Thunk, Relay} from "@welshman/app" import type {Thunk, Relay} from "@welshman/app"
import {deriveEvents, deriveEventsMapped, withGetter, synced} from "@welshman/store"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity) export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@ -341,6 +349,8 @@ export const {
// Alerts // Alerts
export const deviceAlertAddresses = synced<string[]>("deviceAlertAddresses", [])
export type Alert = { export type Alert = {
event: TrustedEvent event: TrustedEvent
tags: string[][] tags: string[][]
@ -356,6 +366,12 @@ export const alerts = deriveEventsMapped<Alert>(repository, {
}, },
}) })
export const deviceAlerts = derived(
[deviceAlertAddresses, alerts],
([$deviceAlertAddresses, $alerts]) =>
$alerts.filter(a => $deviceAlertAddresses.includes(getAddress(a.event))),
)
// Alert Statuses // Alert Statuses
export type AlertStatus = { export type AlertStatus = {
@ -373,6 +389,9 @@ export const alertStatuses = deriveEventsMapped<AlertStatus>(repository, {
}, },
}) })
export const deriveAlertStatus = (address: string) =>
derived(alertStatuses, statuses => statuses.find(s => getTagValue("d", s.event.tags) === address))
// Membership // Membership
export const hasMembershipUrl = (list: List | undefined, url: string) => export const hasMembershipUrl = (list: List | undefined, url: string) =>

View file

@ -63,6 +63,7 @@
} from "@app/state" } from "@app/state"
import {loadUserData, listenForNotifications} from "@app/requests" import {loadUserData, listenForNotifications} from "@app/requests"
import {theme} from "@app/theme" import {theme} from "@app/theme"
import {initializePushNotifications} from "@app/push"
import * as commands from "@app/commands" import * as commands from "@app/commands"
import * as requests from "@app/requests" import * as requests from "@app/requests"
import * as notifications from "@app/notifications" import * as notifications from "@app/notifications"
@ -73,6 +74,9 @@
dropSession($session.pubkey) dropSession($session.pubkey)
} }
// Initialize push notification handler asap
initializePushNotifications()
const {children} = $props() const {children} = $props()
const ready = $state(defer<void>()) const ready = $state(defer<void>())
@ -95,8 +99,8 @@
}) })
// Listen for navigation messages from service worker // Listen for navigation messages from service worker
navigator.serviceWorker?.addEventListener('message', (event) => { navigator.serviceWorker?.addEventListener("message", event => {
if (event.data && event.data.type === 'NAVIGATE') { if (event.data && event.data.type === "NAVIGATE") {
goto(event.data.url) goto(event.data.url)
} }
}) })

View file

@ -2,7 +2,7 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {Address, getIdFilters, getTagValue} from "@welshman/util" import {Address, getIdFilters} from "@welshman/util"
import {LOCAL_RELAY_URL} from "@welshman/relay" import {LOCAL_RELAY_URL} from "@welshman/relay"
import {load} from "@welshman/net" import {load} from "@welshman/net"
import {page} from "$app/stores" import {page} from "$app/stores"

View file

@ -1,24 +1,12 @@
import * as nip19 from 'nostr-tools/nip19' /* global clients */
import {parse, renderAsText} from '@welshman/content'
import {MESSAGE, THREAD, COMMENT, EVENT_TIME} from '@welshman/util'
const renderOptions = { import * as nip19 from "nostr-tools/nip19"
createElement: tag => ({
_text: "",
set innerText(text) {
this._text = text
},
get innerHTML() {
return this._text
},
})
}
self.addEventListener('install', event => { self.addEventListener("install", event => {
self.skipWaiting() self.skipWaiting()
}) })
self.addEventListener('activate', event => { self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim()) event.waitUntil(self.clients.claim())
}) })
@ -26,26 +14,37 @@ self.addEventListener("push", e => {
console.log("Service Worker: Push event received", e) console.log("Service Worker: Push event received", e)
let url = "/" let url = "/"
let title = "New activity"
let body = "You have a new message" let body = "You have a new message"
try { try {
const {event, relays = []} = e.data?.json() || {} const data = e.data?.json()
if (event) { if (data?.event) {
url += nip19.neventEncode({id: event.id, relays}) url += nip19.neventEncode({
body = renderAsText(parse(event), renderOptions).toString() id: data.event.id,
relays: data.relays || []
})
}
if (data?.title) {
title = data.title
}
if (data?.body) {
body = data.body
} }
} catch (e) { } catch (e) {
console.log("Service Worker: Failed to parse push data", e) console.log("Service Worker: Failed to parse push data", e)
} }
e.waitUntil( e.waitUntil(
self.registration.showNotification("New message", { self.registration.showNotification(title, {
body, body,
data: {url}, data: {url},
icon: "/pwa-192x192.png", icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png", badge: "/pwa-64x64.png",
tag: 'flotilla-notification', tag: "flotilla-notification",
requireInteraction: false, requireInteraction: false,
}), }),
) )
@ -74,8 +73,8 @@ self.addEventListener("notificationclick", e => {
for (const client of clientList) { for (const client of clientList) {
if (client.url.includes(location.origin)) { if (client.url.includes(location.origin)) {
client.postMessage({ client.postMessage({
type: 'NAVIGATE', type: "NAVIGATE",
url: url url: url,
}) })
return client.focus() return client.focus()