041ad0e44a
Notifications phase 2: a /push-sw.js service worker shows a system notification on push and, on click, focuses an open CRM tab (or opens one) and deep-links to the thread. usePushNotifications registers the SW, requests permission, subscribes with IIOS's VAPID key, and stores the subscription via the be-crm door. The topbar bell becomes a real enable/disable control (hidden when push is unsupported/demo). Dashboard listens for the SW's notif-click message + a ?thread= deep link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.4 KiB
TypeScript
139 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
|
|
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
|
|
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
|
|
// when the Shell is configured (live); demo mode reports it unsupported.
|
|
//
|
|
// Live contract (be-crm data door → IIOS /v1/notifications/*):
|
|
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
|
|
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
|
|
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
|
import { isShellConfigured } from "./appshell";
|
|
|
|
const SHELL = isShellConfigured();
|
|
const SW_URL = "/push-sw.js";
|
|
|
|
export type PushPermission = "default" | "granted" | "denied";
|
|
|
|
export interface PushState {
|
|
/** Browser can do Web Push AND we have a live backend to deliver it. */
|
|
supported: boolean;
|
|
/** OS/browser permission for notifications. */
|
|
permission: PushPermission;
|
|
/** This browser currently has an active push subscription registered with the backend. */
|
|
subscribed: boolean;
|
|
/** A subscribe/unsubscribe round-trip is in flight. */
|
|
busy: boolean;
|
|
error: string | null;
|
|
enable: () => Promise<void>;
|
|
disable: () => Promise<void>;
|
|
}
|
|
|
|
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
|
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
|
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const raw = atob(normalized);
|
|
const out = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
function browserSupportsPush(): boolean {
|
|
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
|
}
|
|
|
|
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
|
|
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
|
|
const json = sub.toJSON();
|
|
return {
|
|
endpoint: sub.endpoint,
|
|
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
|
|
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
|
|
};
|
|
}
|
|
|
|
export function usePushNotifications(): PushState {
|
|
const { sdk } = useAppShell();
|
|
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
|
|
const [permission, setPermission] = useState<PushPermission>("default");
|
|
const [subscribed, setSubscribed] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
|
|
useEffect(() => {
|
|
if (!supported) return;
|
|
setPermission(Notification.permission as PushPermission);
|
|
let cancelled = false;
|
|
navigator.serviceWorker.ready
|
|
.then((reg) => reg.pushManager.getSubscription())
|
|
.then((sub) => {
|
|
if (!cancelled) setSubscribed(Boolean(sub));
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [supported]);
|
|
|
|
const enable = useCallback(async () => {
|
|
if (!supported) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const perm = await Notification.requestPermission();
|
|
setPermission(perm as PushPermission);
|
|
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
|
|
|
|
const reg = await navigator.serviceWorker.register(SW_URL);
|
|
await navigator.serviceWorker.ready;
|
|
|
|
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
|
|
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
|
|
|
|
const existing = await reg.pushManager.getSubscription();
|
|
const sub =
|
|
existing ??
|
|
(await reg.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
|
|
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
|
|
}));
|
|
|
|
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
|
|
setSubscribed(true);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Could not enable notifications.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [supported, sdk]);
|
|
|
|
const disable = useCallback(async () => {
|
|
if (!supported) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const reg = await navigator.serviceWorker.ready;
|
|
const sub = await reg.pushManager.getSubscription();
|
|
if (sub) {
|
|
// Tell the backend first (still has the endpoint), then drop the local subscription.
|
|
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
|
|
await sub.unsubscribe();
|
|
}
|
|
setSubscribed(false);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Could not disable notifications.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}, [supported, sdk]);
|
|
|
|
return { supported, permission, subscribed, busy, error, enable, disable };
|
|
}
|