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

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_SIGNER_RELAYS=wss://relay.nsec.app/,wss://bucket.coracle.social/
VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df VITE_NOTIFIER_PUBKEY=27b7c2ed89ef78322114225ea3ebf5f72c7767c2528d4d0c1854d039c00085df
VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/ VITE_NOTIFIER_RELAY=wss://anchor.coracle.social/
VITE_VAPID_PUBLIC_KEY=
VITE_GLITCHTIP_API_KEY= VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN= GLITCHTIP_AUTH_TOKEN=

View file

@ -1,6 +1,7 @@
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 {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 {Feed} from "@welshman/feeds"
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import { import {
@ -14,8 +15,10 @@ import {
AUTH_JOIN, AUTH_JOIN,
ROOMS, ROOMS,
COMMENT, COMMENT,
ALERT_REQUEST_PUSH, ALERT_EMAIL,
ALERT_REQUEST_EMAIL, ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
isSignedEvent, isSignedEvent,
makeEvent, makeEvent,
displayProfile, displayProfile,
@ -60,6 +63,7 @@ import {
NOTIFIER_RELAY, NOTIFIER_RELAY,
userRoomsByUrl, userRoomsByUrl,
} from "@app/state" } from "@app/state"
import {getPushInfo} from "@app/push"
// Utils // Utils
@ -369,41 +373,58 @@ export const makeComment = ({event, content, tags = []}: CommentParams) =>
export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) => export const publishComment = ({relays, ...params}: CommentParams & {relays: string[]}) =>
publishThunk({event: makeComment(params), relays}) publishThunk({event: makeComment(params), relays})
export type EmailAlertParams = { export type AlertParams = {
feed: Feed feed: Feed
cron: string
email: string
description: string description: string
claims: Record<string, 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 ({ export const makeAlert = async (params: AlertParams) => {
cron,
email,
feed,
claims,
description,
}: EmailAlertParams) => {
const tags = [ const tags = [
["feed", JSON.stringify(feed)], ["feed", JSON.stringify(params.feed)],
["cron", cron],
["email", email],
["locale", LOCALE], ["locale", LOCALE],
["timezone", TIMEZONE], ["timezone", TIMEZONE],
["description", description], ["description", params.description],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1737058597050",
"wss://relay.nostr.band/",
"web",
],
] ]
for (const [relay, claim] of Object.entries(claims)) { for (const [relay, claim] of Object.entries(params.claims)) {
tags.push(["claim", relay, claim]) 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)), content: await signer.get().nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [ tags: [
["d", randomId()], ["d", randomId()],
@ -412,37 +433,5 @@ export const makeEmailAlert = async ({
}) })
} }
export const publishEmailAlert = async (params: EmailAlertParams) => export const publishAlert = async (params: AlertParams) =>
publishThunk({event: await makeEmailAlert(params), relays: [NOTIFIER_RELAY]}) publishThunk({event: await makeAlert(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]})

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib" import {randomInt, displayList, TIMEZONE, identity} from "@welshman/lib"
import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util" import {displayRelayUrl, getTagValue, THREAD, MESSAGE, EVENT_TIME, COMMENT} from "@welshman/util"
@ -13,7 +14,9 @@
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, getMembershipRoomsByUrl, userMembership} from "@app/state"
import {loadAlertStatuses, requestRelayClaims} from "@app/requests" 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" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -45,7 +48,7 @@
const back = () => history.back() const back = () => history.back()
const submit = async () => { const submit = async () => {
if (!email.includes("@")) { if (channel === "email" && !email.includes("@")) {
return pushToast({ return pushToast({
theme: "error", theme: "error",
message: "Please provide an email address", message: "Please provide an email address",
@ -83,23 +86,45 @@
if (notifyChat) { if (notifyChat) {
display.push("chat") display.push("chat")
filters.push({ filters.push({kinds: [MESSAGE]})
kinds: [MESSAGE],
"#h": getMembershipRoomsByUrl(relay, $userMembership),
})
} }
loading = true loading = true
try { try {
const claims = await requestRelayClaims([relay]) 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 feed = makeIntersectionFeed(feedFromFilters(filters), makeRelayFeed(relay))
const thunk = const description = `for ${displayList(display)} on ${displayRelayUrl(relay)}`
channel === "email" const params: AlertParams = {feed, claims, description}
? await publishEmailAlert({cron, email, feed, claims, description})
: await publishPushAlert({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 thunk.result
await loadAlertStatuses($pubkey!) await loadAlertStatuses($pubkey!)
@ -110,6 +135,12 @@
loading = false loading = false
} }
} }
onMount(() => {
if (!canSendPushNotifications()) {
channel = "email"
}
})
</script> </script>
<form class="column gap-4" onsubmit={preventDefault(submit)}> <form class="column gap-4" onsubmit={preventDefault(submit)}>
@ -118,17 +149,19 @@
Add an Alert Add an Alert
{/snippet} {/snippet}
</ModalHeader> </ModalHeader>
<FieldInline> {#if canSendPushNotifications()}
{#snippet label()} <FieldInline>
<p>Alert Type*</p> {#snippet label()}
{/snippet} <p>Alert Type*</p>
{#snippet input()} {/snippet}
<select bind:value={channel} class="select select-bordered"> {#snippet input()}
<option value="push">Push Notification</option> <select bind:value={channel} class="select select-bordered">
<option value="email">Email Digest</option> <option value="email">Email Digest</option>
</select> <option value="push">Push Notification</option>
{/snippet} </select>
</FieldInline> {/snippet}
</FieldInline>
{/if}
{#if channel === "email"} {#if channel === "email"}
<FieldInline> <FieldInline>
{#snippet label()} {#snippet label()}

View file

@ -67,9 +67,9 @@
const addRoom = () => pushModal(RoomCreate, {url}, {replaceState}) 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 showMenu = $state(false)
let replaceState = $state(false) let replaceState = $state(false)

71
src/app/push.ts Normal file
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}`)
}
}

View file

@ -25,8 +25,10 @@ import {
EVENT_TIME, EVENT_TIME,
AUTH_INVITE, AUTH_INVITE,
COMMENT, COMMENT,
ALERT_REQUEST_EMAIL, ALERT_EMAIL,
ALERT_REQUEST_PUSH, ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS, ALERT_STATUS,
matchFilters, matchFilters,
getTagValues, getTagValues,
@ -349,7 +351,7 @@ export const makeCalendarFeed = ({
export const loadAlerts = (pubkey: string) => export const loadAlerts = (pubkey: string) =>
load({ load({
relays: [NOTIFIER_RELAY], 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) => export const loadAlertStatuses = (pubkey: string) =>

View file

@ -61,34 +61,40 @@ export const getPrimaryNavItemIndex = ($page: Page) => {
} }
} }
export const goToMessage = async (url: string, room: string | undefined, id: string) => { export const goToEvent = async (event: TrustedEvent, options: Record<string, any> = {}) => {
await goto(room ? makeRoomPath(url, room) : makeSpacePath(url, "chat"))
await sleep(300)
return scrollToEvent(id)
}
export const goToEvent = async (event: TrustedEvent) => {
if (event.kind === DIRECT_MESSAGE || event.kind === DIRECT_MESSAGE_FILE) { 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 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) const room = getTagValue(ROOM, event.tags)
if (urls.length > 0) { if (urls.length > 0) {
const url = urls[0] const url = urls[0]
if (event.kind === THREAD) { if (event.kind === THREAD) {
return goto(makeThreadPath(url, event.id)) return makeThreadPath(url, event.id)
} }
if (event.kind === EVENT_TIME) { if (event.kind === EVENT_TIME) {
return goto(makeCalendarPath(url, event.id)) return makeCalendarPath(url, event.id)
} }
if (event.kind === MESSAGE) { 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] const kind = event.tags.find(nthEq(0, "K"))?.[1]
@ -96,18 +102,18 @@ export const goToEvent = async (event: TrustedEvent) => {
if (id && kind) { if (id && kind) {
if (parseInt(kind) === THREAD) { if (parseInt(kind) === THREAD) {
return goto(makeThreadPath(url, id)) return makeThreadPath(url, id)
} }
if (parseInt(kind) === EVENT_TIME) { if (parseInt(kind) === EVENT_TIME) {
return goto(makeCalendarPath(url, id)) return makeCalendarPath(url, id)
} }
if (parseInt(kind) === MESSAGE) { 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}))
} }

View file

@ -41,8 +41,10 @@ import {
ROOM_JOIN, ROOM_JOIN,
ROOM_ADD_USER, ROOM_ADD_USER,
ROOM_REMOVE_USER, ROOM_REMOVE_USER,
ALERT_REQUEST_EMAIL, ALERT_EMAIL,
ALERT_REQUEST_PUSH, ALERT_WEB,
ALERT_IOS,
ALERT_ANDROID,
ALERT_STATUS, ALERT_STATUS,
getGroupTags, getGroupTags,
getRelayTagValues, 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 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 INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS)
export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_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, { 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, itemToEvent: item => item.event,
eventToItem: async event => { eventToItem: async event => {
const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content)) const tags = parseJson(await decrypt(signer.get(), NOTIFIER_PUBKEY, event.content))

View file

@ -102,6 +102,7 @@ export const isIntersecting = async (element: Element) =>
export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => { export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean> => {
const element = document.querySelector(`[data-event="${id}"]`) as any const element = document.querySelector(`[data-event="${id}"]`) as any
const elements = Array.from(document.querySelectorAll("[data-event]"))
if (element) { if (element) {
element.scrollIntoView({behavior: "smooth", block: "center"}) element.scrollIntoView({behavior: "smooth", block: "center"})
@ -116,8 +117,8 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
}, 800 + 400) }, 800 + 400)
return true return true
} else { } else if (elements.length > 0) {
const lastElement = last(Array.from(document.querySelectorAll("[data-event]"))) const lastElement = last(elements)
if (lastElement && !isIntersecting(lastElement)) { if (lastElement && !isIntersecting(lastElement)) {
lastElement.scrollIntoView({behavior: "smooth", block: "center"}) lastElement.scrollIntoView({behavior: "smooth", block: "center"})
@ -131,4 +132,6 @@ export const scrollToEvent = async (id: string, attempts = 3): Promise<boolean>
return false return false
} }
} }
return false
} }

View file

@ -44,6 +44,7 @@
} from "@welshman/app" } from "@welshman/app"
import * as lib from "@welshman/lib" import * as lib from "@welshman/lib"
import * as util from "@welshman/util" import * as util from "@welshman/util"
import * as feeds from "@welshman/feeds"
import * as router from "@welshman/router" import * as router from "@welshman/router"
import * as welshmanSigner from "@welshman/signer" import * as welshmanSigner from "@welshman/signer"
import * as net from "@welshman/net" import * as net from "@welshman/net"
@ -84,6 +85,7 @@
...welshmanSigner, ...welshmanSigner,
...router, ...router,
...util, ...util,
...feeds,
...net, ...net,
...app, ...app,
...appState, ...appState,
@ -92,6 +94,13 @@
...notifications, ...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 // Nstart login
if (window.location.hash?.startsWith("#nostr-login")) { if (window.location.hash?.startsWith("#nostr-login")) {
const params = new URLSearchParams(window.location.hash.slice(1)) const params = new URLSearchParams(window.location.hash.slice(1))

View file

@ -3,12 +3,12 @@
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, getTagValue} from "@welshman/util"
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"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import {scrollToEvent} from "@lib/html"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import {makeRoomPath, makeThreadPath} from "@app/routes" import {goToEvent} from "@app/routes"
const {bech32} = $page.params const {bech32} = $page.params
@ -22,19 +22,11 @@
let found = false let found = false
load({ load({
relays: data.relays, relays: [LOCAL_RELAY_URL, ...data.relays],
filters: getIdFilters([type === "nevent" ? data.id : Address.fromNaddr(bech32).toString()]), filters: getIdFilters([type === "nevent" ? data.id : Address.fromNaddr(bech32).toString()]),
onEvent: (event: TrustedEvent) => { onEvent: (event: TrustedEvent) => {
found = true found = true
goToEvent(event, {replaceState: 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})
}
}, },
onClose: () => { onClose: () => {
if (!found) { if (!found) {

91
src/service-worker.js Normal file
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)
}
}),
)
})