- Add ui/src/api/push.ts with getVapidPublicKey, subscribe, unsubscribe methods - Add ui/src/hooks/usePushNotifications.ts with SW pushManager subscription flow - urlBase64ToUint8Array utility converts VAPID key for applicationServerKey - NotificationPermissionPrompt shows after 3rd agent response (engagement gate) - Checks nexus.notifPromptDismissed localStorage key for dismiss state - ChatPanel tracks agentResponseCount from assistant messages and renders prompt - Install idb package (missing dependency from plan 26-00 prerequisites)
76 lines
2.4 KiB
TypeScript
76 lines
2.4 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { pushApi } from "../api/push";
|
|
|
|
/**
|
|
* Converts a base64url-encoded VAPID public key to a Uint8Array
|
|
* as required by pushManager.subscribe({ applicationServerKey }).
|
|
*/
|
|
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 outputArray = new Uint8Array(rawData.length);
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
const isSupported =
|
|
typeof navigator !== "undefined" &&
|
|
"serviceWorker" in navigator &&
|
|
"PushManager" in window &&
|
|
"Notification" in window;
|
|
|
|
export function usePushNotifications(): {
|
|
isSupported: boolean;
|
|
permission: NotificationPermission | "unsupported";
|
|
subscribe: () => Promise<void>;
|
|
unsubscribe: () => Promise<void>;
|
|
} {
|
|
const [permission, setPermission] = useState<NotificationPermission | "unsupported">(
|
|
isSupported ? Notification.permission : "unsupported",
|
|
);
|
|
|
|
// Poll for permission changes (e.g., user revokes permission in browser settings)
|
|
useEffect(() => {
|
|
if (!isSupported) return;
|
|
const interval = setInterval(() => {
|
|
const current = Notification.permission;
|
|
setPermission((prev) => (prev !== current ? current : prev));
|
|
}, 2000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const subscribe = useCallback(async () => {
|
|
if (!isSupported) return;
|
|
|
|
const perm = await Notification.requestPermission();
|
|
setPermission(perm);
|
|
if (perm !== "granted") return;
|
|
|
|
const { publicKey } = await pushApi.getVapidPublicKey();
|
|
if (!publicKey) return;
|
|
|
|
const reg = await navigator.serviceWorker.ready;
|
|
const sub = await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,
|
|
});
|
|
|
|
await pushApi.subscribe(sub.toJSON() as ReturnType<PushSubscription["toJSON"]>);
|
|
}, []);
|
|
|
|
const unsubscribe = useCallback(async () => {
|
|
if (!isSupported) return;
|
|
|
|
const reg = await navigator.serviceWorker.ready;
|
|
const sub = await reg.pushManager.getSubscription();
|
|
if (!sub) return;
|
|
|
|
await sub.unsubscribe();
|
|
await pushApi.unsubscribe(sub.endpoint);
|
|
}, []);
|
|
|
|
return { isSupported, permission, subscribe, unsubscribe };
|
|
}
|