Files
lynkeduppro-crm/src/lib/push-notifications.ts
T
maaz519 041ad0e44a 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>
2026-07-23 15:38:14 +05:30

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