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 236 additions and 608 deletions
+11 -6
View File
@@ -1,6 +1,5 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import {randomId, flatten, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
import type {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util"
@@ -21,6 +20,7 @@ import {
ALERT_ANDROID,
isSignedEvent,
makeEvent,
getAddress,
displayProfile,
normalizeRelayUrl,
makeList,
@@ -62,8 +62,8 @@ import {
NOTIFIER_PUBKEY,
NOTIFIER_RELAY,
userRoomsByUrl,
deviceAlertAddresses,
} from "@app/state"
import {getPushInfo} from "@app/push"
// Utils
@@ -388,10 +388,10 @@ export type AlertParams = {
auth: string
}
ios?: {
nothing: string
device_token: string
}
android?: {
nothing: string
device_token: string
}
}
@@ -433,5 +433,10 @@ export const makeAlert = async (params: AlertParams) => {
})
}
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
export const publishAlert = async (params: AlertParams) => {
const event = await signer.get().sign(await makeAlert(params))
deviceAlertAddresses.update($addresses => [...$addresses, getAddress(event)])
return publishThunk({event, relays: [NOTIFIER_RELAY]})
}
+40 -9
View File
@@ -1,18 +1,28 @@
<script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
import {ucFirst} from "@lib/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 {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 Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.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 {publishAlert} from "@app/commands"
import type {AlertParams} from "@app/commands"
@@ -114,20 +124,41 @@
try {
// @ts-ignore
params[platform] = await getPushInfo()
params.description = `${ucFirst(platform)} push notification ${description}.`
} catch (e: any) {
return pushToast({
theme: "error",
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
await loadAlertStatuses($pubkey!)
const thunk = await publishAlert(params)
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!"})
back()
+8 -9
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import {parseJson, nthEq} from "@welshman/lib"
import {parseJson} from "@welshman/lib"
import {displayFeeds} from "@welshman/feeds"
import {getAddress, getTagValue, getTagValues} from "@welshman/util"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import AlertDelete from "@app/components/AlertDelete.svelte"
import type {Alert} from "@app/state"
import {alertStatuses} from "@app/state"
import {deriveAlertStatus} from "@app/state"
import {pushModal} from "@app/modal"
type Props = {
@@ -15,8 +15,7 @@
const {alert}: Props = $props()
const address = $derived(getAddress(alert.event))
const status = $derived($alertStatuses.find(s => s.event.tags.some(nthEq(1, address))))
const status = deriveAlertStatus(getAddress(alert.event))
const cron = $derived(getTagValue("cron", alert.tags))
const channel = $derived(getTagValue("channel", alert.tags))
const feeds = $derived(getTagValues("feed", alert.tags))
@@ -39,24 +38,24 @@
</Button>
<div class="flex-inline gap-1">{description}</div>
</div>
{#if status}
{@const statusText = getTagValue("status", status.tags) || "error"}
{#if $status}
{@const statusText = getTagValue("status", $status.tags) || "error"}
{#if statusText === "ok"}
<span
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
</span>
{:else if statusText === "pending"}
<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"
data-tip={getTagValue("message", status.tags)}>
data-tip={getTagValue("message", $status.tags)}>
Pending
</span>
{:else}
<span
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())}
</span>
{/if}
+2 -2
View File
@@ -24,8 +24,8 @@
memberships,
deriveUserRooms,
deriveOtherRooms,
deviceAlerts,
hasNip29,
alerts,
} from "@app/state"
import {loadAlerts} from "@app/requests"
import {notifications} from "@app/notifications"
@@ -40,7 +40,7 @@
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(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 = () => {
showMenu = true
+51 -8
View File
@@ -1,8 +1,28 @@
import * as nip19 from "nostr-tools/nip19"
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"
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 getWebPushInfo = async () => {
@@ -36,7 +56,7 @@ export const getWebPushInfo = async () => {
})
}
const {endpoint, keys} = subscription.toJSON()
const {keys} = subscription.toJSON()
if (!keys) {
throw new Error(`Failed to get push info: no keys were returned`)
@@ -49,12 +69,36 @@ export const getWebPushInfo = async () => {
}
}
export const getIosPushInfo = async () => {
return {}
}
export const getCapacitorPushInfo = async () => {
let status = await PushNotifications.checkPermissions()
export const getAndroidPushInfo = async () => {
return {}
if (status.receive === "prompt") {
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>> => {
@@ -62,9 +106,8 @@ export const getPushInfo = (): Promise<Record<string, string>> => {
case "web":
return getWebPushInfo()
case "ios":
return getIosPushInfo()
case "android":
return getAndroidPushInfo()
return getCapacitorPushInfo()
default:
throw new Error(`Invalid push platform: ${platform}`)
}
+3 -1
View File
@@ -367,7 +367,7 @@ export const listenForNotifications = () => {
for (const [url, allRooms] of userRoomsByUrl.get()) {
// 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)
load({
@@ -375,6 +375,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
@@ -385,6 +386,7 @@ export const listenForNotifications = () => {
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
+3 -3
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 path = await getEventPath(event, urls)
if (path.includes('://')) {
if (path.includes("://")) {
window.open(path)
} else {
goto(path, options)
@@ -94,7 +94,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
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]
@@ -110,7 +110,7 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
}
if (parseInt(kind) === MESSAGE) {
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat')
return room ? makeRoomPath(url, room) : makeSpacePath(url, "chat")
}
}
}
+21 -2
View File
@@ -23,7 +23,14 @@ import {
} from "@welshman/lib"
import type {Socket} 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 {
getIdFilters,
WRAP,
@@ -56,7 +63,9 @@ import {
asDecryptedEvent,
normalizeRelayUrl,
getTag,
getTagValue,
getTagValues,
getAddress,
} from "@welshman/util"
import type {TrustedEvent, SignedEvent, PublishedList, List, Filter} from "@welshman/util"
import {Nip59, decrypt} from "@welshman/signer"
@@ -81,7 +90,6 @@ import {
appContext,
} 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)
@@ -341,6 +349,8 @@ export const {
// Alerts
export const deviceAlertAddresses = synced<string[]>("deviceAlertAddresses", [])
export type Alert = {
event: TrustedEvent
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
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
export const hasMembershipUrl = (list: List | undefined, url: string) =>
+6 -2
View File
@@ -63,6 +63,7 @@
} from "@app/state"
import {loadUserData, listenForNotifications} from "@app/requests"
import {theme} from "@app/theme"
import {initializePushNotifications} from "@app/push"
import * as commands from "@app/commands"
import * as requests from "@app/requests"
import * as notifications from "@app/notifications"
@@ -73,6 +74,9 @@
dropSession($session.pubkey)
}
// Initialize push notification handler asap
initializePushNotifications()
const {children} = $props()
const ready = $state(defer<void>())
@@ -95,8 +99,8 @@
})
// Listen for navigation messages from service worker
navigator.serviceWorker?.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NAVIGATE') {
navigator.serviceWorker?.addEventListener("message", event => {
if (event.data && event.data.type === "NAVIGATE") {
goto(event.data.url)
}
})
+1 -1
View File
@@ -2,7 +2,7 @@
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
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 {load} from "@welshman/net"
import {page} from "$app/stores"
+23 -24
View File
@@ -1,24 +1,12 @@
import * as nip19 from 'nostr-tools/nip19'
import {parse, renderAsText} from '@welshman/content'
import {MESSAGE, THREAD, COMMENT, EVENT_TIME} from '@welshman/util'
/* global clients */
const renderOptions = {
createElement: tag => ({
_text: "",
set innerText(text) {
this._text = text
},
get innerHTML() {
return this._text
},
})
}
import * as nip19 from "nostr-tools/nip19"
self.addEventListener('install', event => {
self.addEventListener("install", event => {
self.skipWaiting()
})
self.addEventListener('activate', event => {
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim())
})
@@ -26,26 +14,37 @@ self.addEventListener("push", e => {
console.log("Service Worker: Push event received", e)
let url = "/"
let title = "New activity"
let body = "You have a new message"
try {
const {event, relays = []} = e.data?.json() || {}
const data = e.data?.json()
if (event) {
url += nip19.neventEncode({id: event.id, relays})
body = renderAsText(parse(event), renderOptions).toString()
if (data?.event) {
url += nip19.neventEncode({
id: data.event.id,
relays: data.relays || []
})
}
if (data?.title) {
title = data.title
}
if (data?.body) {
body = data.body
}
} catch (e) {
console.log("Service Worker: Failed to parse push data", e)
}
e.waitUntil(
self.registration.showNotification("New message", {
self.registration.showNotification(title, {
body,
data: {url},
icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png",
tag: 'flotilla-notification',
tag: "flotilla-notification",
requireInteraction: false,
}),
)
@@ -74,8 +73,8 @@ self.addEventListener("notificationclick", e => {
for (const client of clientList) {
if (client.url.includes(location.origin)) {
client.postMessage({
type: 'NAVIGATE',
url: url
type: "NAVIGATE",
url: url,
})
return client.focus()