Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
7.5 kB · 194 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195import { AppStore } from "store";import { StateCreator } from "zustand";
export interface PlatformSlice { status: "idle" | "loading" | "failed"; notificationToken: string | null; notificationDestination: string | null; // actions handleNotification: (payload?: { [key: string]: string | object }) => void; clearNotification: () => void; openLoginLink: (url: string) => Promise<void>; initPushNotifications: () => Promise<void>; registerNotificationToken: () => Promise<void>; // web-only: subscribe/unsubscribe the browser's PushManager. Returns the // permission state so the settings toggle can reflect reality. enableWebNotifications: () => Promise<NotificationPermission>; disableWebNotifications: () => Promise<void>; webNotificationPermission: () => NotificationPermission;}
// VAPID public key must be converted from base64url to a Uint8Array for the// PushManager.subscribe() applicationServerKey argument.function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const rawData = atob(base64); const output = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { output[i] = rawData.charCodeAt(i); } return output;}
export const createPlatformSlice: StateCreator< AppStore, [], [], PlatformSlice> = (set, get) => ({ status: "idle", notificationToken: null, notificationDestination: null, handleNotification: (payload) => { if (!payload) return; if (typeof payload.path !== "string") return; set({ notificationDestination: payload.path }); }, clearNotification: () => { set({ notificationDestination: null }); }, openLoginLink: async (url: string) => { set({ status: "loading" }); try { window.location.href = url; set({ status: "idle" }); } catch (error) { set({ status: "failed" }); } }, initPushNotifications: async () => { // Register the service worker that receives push events. This must // happen early (on app mount) so that pushes delivered while the tab is // backgrounded still surface as system notifications. The actual // subscription + permission request is deferred to the settings toggle // (enableWebNotifications) because browsers require a user gesture for // the permission prompt. if (!("serviceWorker" in navigator)) { return; } try { await navigator.serviceWorker.register("/sw.js"); // Restore an existing PushSubscription into the store. The browser // keeps subscriptions across reloads (and the server still has the // row), but the Zustand store isn't persisted — so without this the // toggle would show "off" after a reload even though the user is // still subscribed. This mirrors the native path, where the FCM token // is re-acquired on startup. const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing) { set({ notificationToken: JSON.stringify(existing) }); } } catch (e) { console.log("service worker registration failed", e); } }, registerNotificationToken: async () => { // On web, token registration is driven by enableWebNotifications (which // subscribes and posts the subscription). This no-op keeps the shared // shell effect happy without double-registering. }, enableWebNotifications: async () => { const url = get().url; if (!url) { console.log( "no streamplace url configured, cannot enable web notifications", ); return "denied"; } try { const permission = await Notification.requestPermission(); if (permission !== "granted") { return permission; }
// Make sure the service worker is active before subscribing. const reg = await navigator.serviceWorker.ready;
// Fetch the server's VAPID public key. const vapidRes = await fetch(`${url}/api/notification/vapid-public-key`); if (!vapidRes.ok) { throw new Error(`failed to fetch vapid public key: ${vapidRes.status}`); } const { publicKey } = await vapidRes.json();
// Subscribe the browser's PushManager. const subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, }); const subJSON = JSON.stringify(subscription);
// Register the subscription with the backend. Only commit the token to // the store after a confirmed successful POST — otherwise the toggle // shows "on" while the server has no subscription row, and the user // believes they're subscribed when they aren't. const { oauthSession } = get(); const body: { token: string; type: string; repoDID?: string } = { token: subJSON, type: "web", }; if (oauthSession?.did) { body.repoDID = oauthSession.did; } const res = await fetch(`${url}/api/notification`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { throw new Error(`server registration failed: ${res.status}`); } set({ notificationToken: subJSON }); console.log("web notification registration status:", res.status); return permission; } catch (e) { console.error("enableWebNotifications error", e); return "denied"; } }, disableWebNotifications: async () => { const url = get().url; const { notificationToken } = get(); try { if (notificationToken) { // Unsubscribe the browser side so it stops accepting pushes. const sub = JSON.parse(notificationToken); // We need the live PushSubscription object to call unsubscribe(); get // it from the service worker registration by matching endpoint. const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing && existing.endpoint === sub.endpoint) { await existing.unsubscribe(); } // Tell the server to drop the row. Only clear the local token after // the DELETE succeeds — otherwise the toggle shows "off" while the // server keeps pushing to a subscription the user thought they // disabled, with no way to retry. if (url) { const res = await fetch(`${url}/api/notification`, { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: notificationToken }), }); if (!res.ok) { throw new Error(`server delete failed: ${res.status}`); } } set({ notificationToken: null }); } } catch (e) { console.error("disableWebNotifications error", e); // Leave notificationToken set so the toggle stays "on" and the user // can retry. The browser-side unsubscribe may have already succeeded, // but the server row is what matters for stopping future pushes. } }, webNotificationPermission: () => { if (typeof Notification === "undefined") { return "denied"; } return Notification.permission; },});