From fec6f48bce1c8934d8d62d0cb43cc751463c4041 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 18 Dec 2023 12:20:14 +0000 Subject: [PATCH] feat: push notifications --- public/icons.svg | 6 ++ src/element/notifications-button.tsx | 86 ++++++++++++++++++++++++++++ src/pages/stream-page.tsx | 4 +- src/providers/zsz.ts | 20 +++++++ src/service-worker.ts | 50 ++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/element/notifications-button.tsx diff --git a/public/icons.svg b/public/icons.svg index 3c59b87..43aa514 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -119,5 +119,11 @@ + + + + + + diff --git a/src/element/notifications-button.tsx b/src/element/notifications-button.tsx new file mode 100644 index 0000000..d08c06a --- /dev/null +++ b/src/element/notifications-button.tsx @@ -0,0 +1,86 @@ +import AsyncButton from "./async-button"; +import { useLogin } from "@/hooks/login"; +import { NostrStreamProvider } from "@/providers"; +import { base64 } from "@scure/base"; +import { unwrap } from "@snort/shared"; +import { useEffect, useState } from "react"; +import { Icon } from "./icon"; + +export function NotificationsButton({ host, service }: { host: string, service: string }) { + const login = useLogin(); + const publisher = login?.publisher(); + const [subscribed, setSubscribed] = useState(false); + const api = new NostrStreamProvider("", service, publisher); + + async function isSubscribed() { + const reg = await navigator.serviceWorker.ready; + if (reg) { + const sub = await reg.pushManager.getSubscription(); + if (sub) { + const auth = base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))); + const subs = await api.listStreamerSubscriptions(auth); + setSubscribed(subs.includes(host)); + } + } + } + + async function enableNotifications() { + // request permissions to send notifications + if ("Notification" in window) { + try { + if (Notification.permission !== "granted") { + const res = await Notification.requestPermission(); + console.debug(res); + } + return Notification.permission === "granted"; + } catch (e) { + console.error(e); + } + } + return false; + } + + async function subscribe() { + if (await enableNotifications()) { + try { + if ("serviceWorker" in navigator) { + const reg = await navigator.serviceWorker.ready; + if (reg && publisher) { + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: (await api.getNotificationsInfo()).publicKey, + }); + await api.subscribeNotifications({ + endpoint: sub.endpoint, + key: base64.encode(new Uint8Array(unwrap(sub.getKey("p256dh")))), + auth: base64.encode(new Uint8Array(unwrap(sub.getKey("auth")))), + scope: `${location.protocol}//${location.hostname}`, + }); + await api.addStreamerSubscription(host); + setSubscribed(true); + } + } else { + console.warn("No service worker") + } + } catch (e) { + console.error(e); + } + } + } + + async function unsubscribe() { + if (publisher) { + await api.removeStreamerSubscription(host); + setSubscribed(false); + } + + } + + useEffect(() => { + isSubscribed().catch(console.error); + }, []); + + return + + +} \ No newline at end of file diff --git a/src/pages/stream-page.tsx b/src/pages/stream-page.tsx index 07f6abf..1e16c7d 100644 --- a/src/pages/stream-page.tsx +++ b/src/pages/stream-page.tsx @@ -28,6 +28,7 @@ import { useStreamLink } from "@/hooks/stream-link"; import { FollowButton } from "@/element/follow-button"; import { ClipButton } from "@/element/clip-button"; import { StreamState } from "@/const"; +import { NotificationsButton } from "@/element/notifications-button"; function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEvent }) { const system = useContext(SnortContext); @@ -37,7 +38,7 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv const profile = useUserProfile(host); const zapTarget = profile?.lud16 ?? profile?.lud06; - const { status, participants, title, summary } = extractStreamInfo(ev); + const { status, participants, title, summary, service } = extractStreamInfo(ev); const isMine = ev?.pubkey === login?.pubkey; async function deleteStream() { @@ -86,6 +87,7 @@ function ProfileInfo({ ev, goal }: { ev?: TaggedNostrEvent; goal?: TaggedNostrEv <> + {service && } {zapTarget && ( ("POST", `clip/${id}/${clipId}?start=${start}&length=${length}`); } + async getNotificationsInfo() { + return await this.#getJson<{ publicKey: string }>("GET", "notifications/info"); + } + + async subscribeNotifications(req: { endpoint: string; key: string; auth: string; scope: string }) { + return await this.#getJson<{ id: string }>("POST", "notifications/register", req); + } + + async listStreamerSubscriptions(auth: string) { + return await this.#getJson>("GET", `notifications?auth=${auth}`); + } + + async addStreamerSubscription(pubkey: string) { + return await this.#getJson("PATCH", `notifications?pubkey=${pubkey}`); + } + + async removeStreamerSubscription(pubkey: string) { + return await this.#getJson("DELETE", `notifications?pubkey=${pubkey}`); + } + getTempClipUrl(id: string, clipId: string) { return `${this.url}clip/${id}/${clipId}`; } diff --git a/src/service-worker.ts b/src/service-worker.ts index 691d432..ec61a6a 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -3,6 +3,7 @@ declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: (string | PrecacheEntry)[]; }; +import { hexToBech32 } from "@snort/shared"; import { clientsClaim } from "workbox-core"; import { PrecacheEntry, precacheAndRoute } from "workbox-precaching"; @@ -29,3 +30,52 @@ self.addEventListener("install", event => { // always skip waiting self.skipWaiting(); }); + +const enum NotificationType { + StreamStarted = 1, +} + +interface PushNotification { + type: NotificationType; + pubkey: string; + name?: string; + avatar?: string; +} + +self.addEventListener("notificationclick", event => { + const ev = JSON.parse(event.notification.data) as PushNotification; + + event.notification.close(); + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: "window" }); + const url = () => { + return `/${hexToBech32("npub", ev.pubkey)}`; + }; + for (const client of windows) { + if (client.url === url() && "focus" in client) return client.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow(url()); + })() + ); +}); + +self.addEventListener("push", async e => { + console.debug(e); + const data = e.data?.json() as PushNotification | undefined; + if (!data) return; + + const icon = data.avatar ?? `${location.protocol}//${location.hostname}/logo_256.png`; + if (data?.type == NotificationType.StreamStarted) { + const ret = { + icon, + timestamp: new Date().getTime(), + data: JSON.stringify(data), + }; + console.debug(ret); + await self.registration.showNotification( + `${data.name ?? hexToBech32("npub", data.pubkey).slice(0, 12)} went live`, + ret + ); + } +});