feat(dashboard): offline web push — service worker, subscribe flow, deep-link
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>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"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 };
|
||||
}
|
||||
Reference in New Issue
Block a user