Get web push working

This commit is contained in:
Jon Staab
2025-06-19 10:22:17 -07:00
parent 18a383edab
commit 6cca823ed4
12 changed files with 321 additions and 120 deletions
+1
View File
@@ -13,5 +13,6 @@ VITE_INDEXER_RELAYS=wss://purplepag.es/,wss://relay.damus.io/,wss://relay.nostr.
VITE_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+48 -59
View File
@@ -1,6 +1,7 @@
import * as nip19 from "nostr-tools/nip19"
import {get} from "svelte/store"
import {randomId, poll, uniq, equals, TIMEZONE, LOCALE} from "@welshman/lib"
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"
import {
@@ -14,8 +15,10 @@ import {
AUTH_JOIN,
ROOMS,
COMMENT,
ALERT_REQUEST_PUSH,
ALERT_REQUEST_EMAIL,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent,
makeEvent,
displayProfile,
@@ -60,6 +63,7 @@ import {
NOTIFIER_RELAY,
userRoomsByUrl,
} from "@app/state"
import {getPushInfo} from "@app/push"
// Utils
@@ -369,41 +373,58 @@ export const makeComment = ({event, content, tags = []}: CommentParams) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays})
export type EmailAlertParams = {
export type AlertParams = {
feed: Feed
cron: string
email: string
description: string
claims: Record<string, string>
email?: {
cron: string
email: string
handler: string[]
}
web?: {
endpoint: string
p256dh: string
auth: string
}
ios?: {
nothing: string
}
android?: {
nothing: string
}
}
export const makeEmailAlert = async ({
cron,
email,
feed,
claims,
description,
}: EmailAlertParams) => {
export const makeAlert = async (params: AlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["cron", cron],
["email", email],
["feed", JSON.stringify(params.feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
["description", params.description],
]
for (const [relay, claim] of Object.entries(claims)) {
for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim])
}
return makeEvent(ALERT_REQUEST_EMAIL, {
let kind: number
if (params.email) {
kind = ALERT_EMAIL
tags.push(...Object.entries(params.email).map(flatten))
} else if (params.web) {
kind = ALERT_WEB
tags.push(...Object.entries(params.web).map(flatten))
} else if (params.ios) {
kind = ALERT_IOS
tags.push(...Object.entries(params.ios).map(flatten))
} else if (params.android) {
kind = ALERT_ANDROID
tags.push(...Object.entries(params.android).map(flatten))
} else {
throw new Error("Alert has invalid params")
}
return makeEvent(kind, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
@@ -412,37 +433,5 @@ export const makeEmailAlert = async ({
})
}
export const publishEmailAlert = async (params: EmailAlertParams) =>
publishThunk({event: await makeEmailAlert(params), relays: [NOTIFIER_RELAY]})
export type PushAlertParams = {
feed: Feed
description: string
claims: Record<string, string>
}
export const makePushAlert = async ({feed, claims, description}: PushAlertParams) => {
const tags = [
["feed", JSON.stringify(feed)],
["locale", LOCALE],
["timezone", TIMEZONE],
["description", description],
["token", ""],
["platform", ""],
]
for (const [relay, claim] of Object.entries(claims)) {
tags.push(["claim", relay, claim])
}
return makeEvent(ALERT_REQUEST_PUSH, {
content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
}
export const publishPushAlert = async (params: PushAlertParams) =>
publishThunk({event: await makePushAlert(params), relays: [NOTIFIER_RELAY]})
export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
+56 -23
View File
@@ -1,4 +1,5 @@
<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"
@@ -13,7 +14,9 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {alerts, getMembershipUrls, getMembershipRoomsByUrl, userMembership} from "@app/state"
import {loadAlertStatuses, requestRelayClaims} from "@app/requests"
import {publishEmailAlert, publishPushAlert} from "@app/commands"
import {publishAlert} from "@app/commands"
import type {AlertParams} from "@app/commands"
import {platform, canSendPushNotifications, getPushInfo} from "@app/push"
import {pushToast} from "@app/toast"
type Props = {
@@ -45,7 +48,7 @@
const back = () => history.back()
const submit = async () => {
if (!email.includes("@")) {
if (channel === "email" && !email.includes("@")) {
return pushToast({
theme: "error",
message: "Please provide an email address",
@@ -83,23 +86,45 @@
if (notifyChat) {
display.push("chat")
filters.push({
kinds: [MESSAGE],
"#h": getMembershipRoomsByUrl(relay, $userMembership),
})
filters.push({kinds: [MESSAGE]})
}
loading = true
try {
const claims = await requestRelayClaims([relay])
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
const description = `${cadence} alert for ${displayList(display)} on ${displayRelayUrl(relay)}, sent via email.`
const feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk =
channel === "email"
? await publishEmailAlert({cron, email, feed, claims, description})
: await publishPushAlert({feed, claims, description})
const description = `for ${displayList(display)} on ${displayRelayUrl(relay)}`
const params: AlertParams = {feed, claims, description}
if (channel === "email") {
const cadence = cron?.endsWith("1") ? "Weekly" : "Daily"
params.description = `${cadence} alert ${description}, sent via email.`
params.email = {
cron,
email,
handler: [
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
}
} else {
try {
// @ts-ignore
params[platform] = await getPushInfo()
} catch (e: any) {
return pushToast({
theme: "error",
message: String(e),
})
}
params.description = `Push notification alert ${description}.`
}
const thunk = await publishAlert(params)
await thunk.result
await loadAlertStatuses($pubkey!)
@@ -110,6 +135,12 @@
loading = false
}
}
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
})
</script>
<form class="column gap-4" onsubmit={preventDefault(submit)}>
@@ -118,17 +149,19 @@
Add an Alert
{/snippet}
</ModalHeader>
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="push">Push Notification</option>
<option value="email">Email Digest</option>
</select>
{/snippet}
</FieldInline>
{#if canSendPushNotifications()}
<FieldInline>
{#snippet label()}
<p>Alert Type*</p>
{/snippet}
{#snippet input()}
<select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option>
<option value="push">Push Notification</option>
</select>
{/snippet}
</FieldInline>
{/if}
{#if channel === "email"}
<FieldInline>
{#snippet label()}
+2 -2
View File
@@ -67,9 +67,9 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState})
const addAlert = () => pushModal(AlertAdd, {relay: url, channel: "push"})
const addAlert = () => pushModal(AlertAdd, {relay: url, channel: "push"}, {replaceState})
const deleteAlert = () => pushModal(AlertDelete, {alert})
const deleteAlert = () => pushModal(AlertDelete, {alert}, {replaceState})
let showMenu = $state(false)
let replaceState = $state(false)
+71
View File
@@ -0,0 +1,71 @@
import {Capacitor} from "@capacitor/core"
import {VAPID_PUBLIC_KEY} from "@app/state"
export const platform = Capacitor.getPlatform()
export const canSendPushNotifications = () => ["web", "android", "ios"].includes(platform)
export const getWebPushInfo = async () => {
if (!("serviceWorker" in navigator)) {
throw new Error("Service Worker not supported")
}
if (!("PushManager" in window)) {
throw new Error("Push messaging not supported")
}
if (Notification.permission === "denied") {
throw new Error("Push notifications are blocked")
}
if (Notification.permission !== "granted") {
const permission = await Notification.requestPermission()
if (permission !== "granted") {
throw new Error("Push notification permission denied")
}
}
const registration = await navigator.serviceWorker.ready
let subscription = await registration.pushManager.getSubscription()
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
})
}
const {endpoint, keys} = subscription.toJSON()
if (!keys) {
throw new Error(`Failed to get push info: no keys were returned`)
}
return {
endpoint: subscription.endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
}
}
export const getIosPushInfo = async () => {
return {}
}
export const getAndroidPushInfo = async () => {
return {}
}
export const getPushInfo = (): Promise<Record<string, string>> => {
switch (platform) {
case "web":
return getWebPushInfo()
case "ios":
return getIosPushInfo()
case "android":
return getAndroidPushInfo()
default:
throw new Error(`Invalid push platform: ${platform}`)
}
}
+5 -3
View File
@@ -25,8 +25,10 @@ import {
EVENT_TIME,
AUTH_INVITE,
COMMENT,
ALERT_REQUEST_EMAIL,
ALERT_REQUEST_PUSH,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
matchFilters,
getTagValues,
@@ -349,7 +351,7 @@ export const makeCalendarFeed = ({
export const loadAlerts = (pubkey: string) =>
load({
relays: [NOTIFIER_RELAY],
filters: [{kinds: [ALERT_REQUEST_EMAIL, ALERT_REQUEST_PUSH], authors: [pubkey]}],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID], authors: [pubkey]}],
})
export const loadAlertStatuses = (pubkey: string) =>
+22 -16
View File
@@ -61,34 +61,40 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
}
}
export const goToMessage = async (url: string, room: string | undefined, id: string) => {
await goto(room ? makeRoomPath(url, room) : makeSpacePath(url, "chat"))
await sleep(300)
return scrollToEvent(id)
}
export const goToEvent = async (event: TrustedEvent) => {
export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) {
return await scrollToEvent(event.id)
await scrollToEvent(event.id)
}
const urls = Array.from(tracker.getRelays(event.id))
const path = await getEventPath(event, urls)
if (path.includes('://')) {
window.open(path)
} else {
goto(path, options)
await sleep(300)
await scrollToEvent(event.id)
}
}
export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
const room = getTagValue(ROOM, event.tags)
if (urls.length > 0) {
const url = urls[0]
if (event.kind === THREAD) {
return goto(makeThreadPath(url, event.id))
return makeThreadPath(url, event.id)
}
if (event.kind === EVENT_TIME) {
return goto(makeCalendarPath(url, event.id))
return makeCalendarPath(url, event.id)
}
if (event.kind === MESSAGE) {
return goToMessage(url, room, event.id)
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat')
}
const kind = event.tags.find(nthEq(0, "K"))?.[1]
@@ -96,18 +102,18 @@ export const goToEvent = async (event: TrustedEvent) => {
if (id && kind) {
if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id))
return makeThreadPath(url, id)
}
if (parseInt(kind) === EVENT_TIME) {
return goto(makeCalendarPath(url, id))
return makeCalendarPath(url, id)
}
if (parseInt(kind) === MESSAGE) {
return goToMessage(url, room, id)
return room ? makeRoomPath(url, room) : makeSpacePath(url, 'chat')
}
}
}
window.open(entityLink(nip19.neventEncode({id: event.id, relays: urls})))
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
+7 -3
View File
@@ -41,8 +41,10 @@ import {
ROOM_JOIN,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
ALERT_REQUEST_EMAIL,
ALERT_REQUEST_PUSH,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS,
getGroupTags,
getRelayTagValues,
@@ -91,6 +93,8 @@ export const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
export const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY
export const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY
export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS)
@@ -343,7 +347,7 @@ export type Alert = {
}
export const alerts = deriveEventsMapped<Alert>(repository, {
filters: [{kinds: [ALERT_REQUEST_EMAIL, ALERT_REQUEST_PUSH]}],
filters: [{kinds: [ALERT_EMAIL, ALERT_WEB, ALERT_IOS, ALERT_ANDROID]}],
itemToEvent: item => item.event,
eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))
+5 -2
View File
@@ -102,6 +102,7 @@ export const isIntersecting = async (element: Element) =>
export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => {
const element = document.querySelector(`[data-event="${id}"]`) as any
const elements = Array.from(document.querySelectorAll("[data-event]"))
if (element) {
element.scrollIntoView({behavior: "smooth", block: "center"})
@@ -116,8 +117,8 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
}, 800 + 400)
return true
} else {
const lastElement = last(Array.from(document.querySelectorAll("[data-event]")))
} else if (elements.length > 0) {
const lastElement = last(elements)
if (lastElement && !isIntersecting(lastElement)) {
lastElement.scrollIntoView({behavior: "smooth", block: "center"})
@@ -131,4 +132,6 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
return false
}
}
return false
}
+9
View File
@@ -44,6 +44,7 @@
} from "@welshman/app"
import * as lib from "@welshman/lib"
import * as util from "@welshman/util"
import * as feeds from "@welshman/feeds"
import * as router from "@welshman/router"
import * as welshmanSigner from "@welshman/signer"
import * as net from "@welshman/net"
@@ -84,6 +85,7 @@
...welshmanSigner,
...router,
...util,
...feeds,
...net,
...app,
...appState,
@@ -92,6 +94,13 @@
...notifications,
})
// Listen for navigation messages from service worker
navigator.serviceWorker?.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NAVIGATE') {
goto(event.data.url)
}
})
// Nstart login
if (window.location.hash?.startsWith("#nostr-login")) {
const params = new URLSearchParams(window.location.hash.slice(1))
+4 -12
View File
@@ -3,12 +3,12 @@
import * as nip19 from "nostr-tools/nip19"
import type {TrustedEvent} from "@welshman/util"
import {Address, getIdFilters, getTagValue} from "@welshman/util"
import {LOCAL_RELAY_URL} from "@welshman/relay"
import {load} from "@welshman/net"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {scrollToEvent} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte"
import {makeRoomPath, makeThreadPath} from "@app/routes"
import {goToEvent} from "@app/routes"
const {bech32} = $page.params
@@ -22,19 +22,11 @@
let found = false
load({
relays: data.relays,
relays: [LOCAL_RELAY_URL, ...data.relays],
filters: getIdFilters([type === "nevent" ? data.id : Address.fromNaddr(bech32).toString()]),
onEvent: (event: TrustedEvent) => {
found = true
if (event.kind === 9) {
goto(makeRoomPath(data.relays[0], getTagValue("h", event.tags)!), {replaceState: true})
scrollToEvent(event.id)
} else if (event.kind === 11) {
goto(makeThreadPath(data.relays[0], event.id), {replaceState: true})
} else {
goto("/", {replaceState: true})
}
goToEvent(event, {replaceState: true})
},
onClose: () => {
if (!found) {
+91
View File
@@ -0,0 +1,91 @@
import * as nip19 from 'nostr-tools/nip19'
import {parse, renderAsText} from '@welshman/content'
import {MESSAGE, THREAD, COMMENT, EVENT_TIME} from '@welshman/util'
const renderOptions = {
createElement: tag => ({
_text: "",
set innerText(text) {
this._text = text
},
get innerHTML() {
return this._text
},
})
}
self.addEventListener('install', event => {
self.skipWaiting()
})
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim())
})
self.addEventListener("push", e => {
console.log("Service Worker: Push event received", e)
let url = "/"
let body = "You have a new message"
try {
const {event, relays = []} = e.data?.json() || {}
if (event) {
url += nip19.neventEncode({id: event.id, relays})
body = renderAsText(parse(event), renderOptions).toString()
}
} catch (e) {
console.log("Service Worker: Failed to parse push data", e)
}
e.waitUntil(
self.registration.showNotification("New message", {
body,
data: {url},
icon: "/pwa-192x192.png",
badge: "/pwa-64x64.png",
tag: 'flotilla-notification',
requireInteraction: false,
}),
)
})
self.addEventListener("notificationclick", e => {
console.log("Service Worker: Notification click event", e)
e.notification.close()
if (e.action === "close") {
return
}
// Default action or 'open' action
const url = e.notification.data?.url
e.waitUntil(
clients
.matchAll({
type: "window",
includeUncontrolled: true,
})
.then(clientList => {
// Check if app is already open and send navigation message
for (const client of clientList) {
if (client.url.includes(location.origin)) {
client.postMessage({
type: 'NAVIGATE',
url: url
})
return client.focus()
}
}
// Open new window if app is not open
if (clients.openWindow) {
return clients.openWindow(url)
}
}),
)
})