From 6cca823ed420dcb7455534cb2a103047496aa13a Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 19 Jun 2025 10:22:17 -0700 Subject: [PATCH] Get web push working --- .env.template | 1 + src/app/commands.ts | 107 +++++++++++++--------------- src/app/components/AlertAdd.svelte | 79 ++++++++++++++------ src/app/components/MenuSpace.svelte | 4 +- src/app/push.ts | 71 ++++++++++++++++++ src/app/requests.ts | 8 ++- src/app/routes.ts | 38 +++++----- src/app/state.ts | 10 ++- src/lib/html.ts | 7 +- src/routes/+layout.svelte | 9 +++ src/routes/[bech32]/+page.svelte | 16 ++--- src/service-worker.js | 91 +++++++++++++++++++++++ 12 files changed, 321 insertions(+), 120 deletions(-) create mode 100644 src/app/push.ts create mode 100644 src/service-worker.js diff --git a/.env.template b/.env.template index 8e497646d..e98af526a 100644 --- a/.env.template +++ b/.env.template @@ -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= diff --git a/src/app/commands.ts b/src/app/commands.ts index 14ff37e43..b1e5298b3 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -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 + 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 -} - -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]}) diff --git a/src/app/components/AlertAdd.svelte b/src/app/components/AlertAdd.svelte index ffad1be5d..58d56df8e 100644 --- a/src/app/components/AlertAdd.svelte +++ b/src/app/components/AlertAdd.svelte @@ -1,4 +1,5 @@
@@ -118,17 +149,19 @@ Add an Alert {/snippet} - - {#snippet label()} -

Alert Type*

- {/snippet} - {#snippet input()} - - {/snippet} -
+ {#if canSendPushNotifications()} + + {#snippet label()} +

Alert Type*

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {/if} {#if channel === "email"} {#snippet label()} diff --git a/src/app/components/MenuSpace.svelte b/src/app/components/MenuSpace.svelte index 6ad9fa678..e8152817a 100644 --- a/src/app/components/MenuSpace.svelte +++ b/src/app/components/MenuSpace.svelte @@ -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) diff --git a/src/app/push.ts b/src/app/push.ts new file mode 100644 index 000000000..582fdf10d --- /dev/null +++ b/src/app/push.ts @@ -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> => { + switch (platform) { + case "web": + return getWebPushInfo() + case "ios": + return getIosPushInfo() + case "android": + return getAndroidPushInfo() + default: + throw new Error(`Invalid push platform: ${platform}`) + } +} diff --git a/src/app/requests.ts b/src/app/requests.ts index c84f84b6a..8a9119ea2 100644 --- a/src/app/requests.ts +++ b/src/app/requests.ts @@ -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) => diff --git a/src/app/routes.ts b/src/app/routes.ts index e45bb5841..ffc41a6ea 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -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 = {}) => { 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})) } diff --git a/src/app/state.ts b/src/app/state.ts index c7d473931..73c2850f3 100644 --- a/src/app/state.ts +++ b/src/app/state.ts @@ -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(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)) diff --git a/src/lib/html.ts b/src/lib/html.ts index b4ee2db92..8ce1ef83c 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -102,6 +102,7 @@ export const isIntersecting = async (element: Element) => export const scrollToEvent = async (id: string, attempts = 3): Promise => { 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 }, 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 return false } } + + return false } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1281d364e..8678579c5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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)) diff --git a/src/routes/[bech32]/+page.svelte b/src/routes/[bech32]/+page.svelte index ef6d4c3f8..694eb4d79 100644 --- a/src/routes/[bech32]/+page.svelte +++ b/src/routes/[bech32]/+page.svelte @@ -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) { diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 000000000..b68c8b729 --- /dev/null +++ b/src/service-worker.js @@ -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) + } + }), + ) +})