nexus/ui/src/hooks/usePushNotifications.ts
Nexus Dev 862cf7fef3 feat(26-04): create push API client, usePushNotifications hook, and NotificationPermissionPrompt
- 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)
2026-04-04 03:55:48 +00:00

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