feat: push notifications

This commit is contained in:
Kieran 2023-12-18 12:20:14 +00:00
parent a3d4b81e23
commit fec6f48bce
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
5 changed files with 165 additions and 1 deletions

View File

@ -119,5 +119,11 @@
<path d="M16.6756 -0.00186977C16.4415 -0.00137503 16.3245 -0.00112766 16.229 0.0407906C16.1446 0.0778141 16.0724 0.13713 16.0197 0.212716C15.9601 0.298296 15.9371 0.413236 15.8911 0.643114L15.2111 4.04311C15.1455 4.37112 15.1127 4.53513 15.1571 4.66348C15.1961 4.77605 15.2739 4.87103 15.3767 4.9313C15.4938 5.00001 15.6611 5.00001 15.9956 5.00001H20.7455C20.9647 5.00001 21.0743 5.00001 21.1735 4.97972C21.5748 4.89762 21.9167 4.52668 21.9658 4.11999C21.978 4.01949 21.9706 3.92906 21.9558 3.74822C21.9099 3.18613 21.8113 2.66938 21.564 2.18405C21.1805 1.4314 20.5686 0.819476 19.816 0.435982C19.3306 0.188692 18.8139 0.0901274 18.2518 0.0442022C17.7281 0.00141033 17.2016 -0.00298175 16.6756 -0.00186977Z" fill="currentColor"/>
<path d="M21.891 7.54602C22 7.75993 22 8.03995 22 8.60001V14.2413C22 15.0463 22 15.7106 21.9558 16.2518C21.9099 16.8139 21.8113 17.3306 21.564 17.816C21.1805 18.5686 20.5686 19.1805 19.816 19.564C19.3306 19.8113 18.8139 19.9099 18.2518 19.9558C17.7106 20 17.0463 20 16.2413 20H5.75868C4.95372 20 4.28937 20 3.74818 19.9558C3.18608 19.9099 2.66938 19.8113 2.18404 19.564C1.43139 19.1805 0.819469 18.5686 0.435976 17.816C0.188685 17.3306 0.0901205 16.8139 0.0441953 16.2518C-2.13385e-05 15.7106 -1.15136e-05 15.0463 3.88855e-07 14.2413V8.60001C3.88855e-07 8.03995 3.8743e-07 7.75993 0.108994 7.54602C0.204868 7.35786 0.357848 7.20487 0.54601 7.109C0.759922 7.00001 1.03995 7.00001 1.6 7.00001H20.4C20.9601 7.00001 21.2401 7.00001 21.454 7.109C21.6422 7.20487 21.7951 7.35786 21.891 7.54602Z" fill="currentColor"/>
</symbol>
<symbol id="bell-ringing" viewBox="0 0 22 22" fill="none">
<path d="M8.35442 20C9.05956 20.6224 9.9858 21 11.0002 21C12.0147 21 12.9409 20.6224 13.6461 20M1.29414 4.81989C1.27979 3.36854 2.06227 2.01325 3.32635 1.3M20.7024 4.8199C20.7167 3.36855 19.9342 2.01325 18.6702 1.3M17.0002 7C17.0002 5.4087 16.3681 3.88258 15.2429 2.75736C14.1177 1.63214 12.5915 1 11.0002 1C9.40895 1 7.88283 1.63214 6.75761 2.75736C5.63239 3.88258 5.00025 5.4087 5.00025 7C5.00025 10.0902 4.22072 12.206 3.34991 13.6054C2.61538 14.7859 2.24811 15.3761 2.26157 15.5408C2.27649 15.7231 2.31511 15.7926 2.46203 15.9016C2.59471 16 3.19284 16 4.3891 16H17.6114C18.8077 16 19.4058 16 19.5385 15.9016C19.6854 15.7926 19.724 15.7231 19.7389 15.5408C19.7524 15.3761 19.3851 14.7859 18.6506 13.6054C17.7798 12.206 17.0002 10.0902 17.0002 7Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="bell-off" viewBox="0 0 24 24" fill="none">
<path d="M8.63306 3.03371C9.61959 2.3649 10.791 2 12 2C13.5913 2 15.1174 2.63214 16.2426 3.75736C17.3679 4.88258 18 6.4087 18 8C18 10.1008 18.2702 11.7512 18.6484 13.0324M6.25867 6.25724C6.08866 6.81726 6 7.40406 6 8C6 11.0902 5.22047 13.206 4.34966 14.6054C3.61513 15.7859 3.24786 16.3761 3.26132 16.5408C3.27624 16.7231 3.31486 16.7926 3.46178 16.9016C3.59446 17 4.19259 17 5.38885 17H17M9.35418 21C10.0593 21.6224 10.9856 22 12 22C13.0144 22 13.9407 21.6224 14.6458 21M21 21L3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -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 <AsyncButton onClick={subscribed ? unsubscribe : subscribe}>
<Icon name={subscribed ? "bell-off" : "bell-ringing"} />
</AsyncButton>
}

View File

@ -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
<>
<ShareMenu ev={ev} />
<ClipButton ev={ev} />
{service && <NotificationsButton host={host} service={service} />}
{zapTarget && (
<SendZapsDialog
lnurl={zapTarget}

View File

@ -100,6 +100,26 @@ export class NostrStreamProvider implements StreamProvider {
return await this.#getJson<{ url: string }>("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<Array<string>>("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}`;
}

View File

@ -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
);
}
});