// Push notification subscription flow. // // Flow: // 1. Component mounts, checks /api/push/config to see if push is server- // configured (VAPID keys set). If not, hides itself entirely. // 2. Checks Notification.permission state ('default' / 'granted' / 'denied'). // 3. If 'default', renders an "Enable notifications" pill in the topbar. // 4. On click → Notification.requestPermission() → if granted, calls // navigator.serviceWorker.ready → registration.pushManager.subscribe(). // 5. POSTs the subscription to /api/push/subscribe so the server can // dispatch to this device on autonomy events. // // Renders a tiny status pill that shows the current state: enabled, // muted, or "click to enable". Click toggles. Can also fire a test push // from the pill's hover/dropdown menu. // // Works on Chrome / Edge / Firefox / Safari 16.4+ on macOS / iOS PWA // installs (Add to Home Screen). Also works inside the Capacitor-wrapped // iOS / Android app via the native push plugin (which goes through // APNs / FCM but appears to JS as the same PushManager API). const { useState: pUseState, useEffect: pUseEffect, useCallback: pUseCallback } = React; // Convert URL-safe base64 (VAPID public key) into the Uint8Array that // PushManager.subscribe() requires. Standard helper from the Web Push docs. function urlB64ToUint8Array(b64) { const padding = '='.repeat((4 - b64.length % 4) % 4); const padded = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(padded); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } function PushControl() { const [supported, setSupported] = pUseState(false); const [serverConfigured, setServerConfigured] = pUseState(false); const [publicKey, setPublicKey] = pUseState(null); const [permission, setPermission] = pUseState('default'); const [subscribed, setSubscribed] = pUseState(false); const [busy, setBusy] = pUseState(false); const [msg, setMsg] = pUseState(''); // Initial probe — runs once on mount pUseEffect(() => { const sw = 'serviceWorker' in navigator; const pn = 'PushManager' in window; const nf = 'Notification' in window; if (!(sw && pn && nf)) { setSupported(false); return; } setSupported(true); setPermission(Notification.permission); (async () => { try { // Server config const r = await fetch('/api/push/config'); if (r.ok) { const j = await r.json(); setServerConfigured(Boolean(j.configured)); setPublicKey(j.public_key || null); } } catch {} try { // Existing subscription on this device const reg = await navigator.serviceWorker.getRegistration(); if (reg) { const sub = await reg.pushManager.getSubscription(); setSubscribed(Boolean(sub)); } } catch {} })(); }, []); const subscribe = pUseCallback(async () => { if (busy) return; setBusy(true); setMsg(''); try { // Permission first — Notification.requestPermission MUST be called // from a user-gesture handler (click), so we keep this flow inside // the button's onClick. let perm = Notification.permission; if (perm === 'default') { perm = await Notification.requestPermission(); } setPermission(perm); if (perm !== 'granted') { setMsg('Permission denied. You can enable in browser settings.'); return; } const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); if (!sub) { if (!publicKey) { throw new Error('Server has no VAPID public key configured.'); } sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64ToUint8Array(publicKey), }); } // POST to backend const subJson = sub.toJSON(); const r = await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: subJson.endpoint, keys: subJson.keys, }), }); if (!r.ok) { const t = await r.text(); throw new Error(`HTTP ${r.status}: ${t.slice(0, 80)}`); } setSubscribed(true); setMsg('subscribed · phone will buzz on autonomy fires'); setTimeout(() => setMsg(''), 4000); } catch (e) { setMsg(`failed: ${e.message || e}`); } finally { setBusy(false); } }, [busy, publicKey]); const unsubscribe = pUseCallback(async () => { if (busy) return; setBusy(true); setMsg(''); try { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (sub) { const ep = sub.endpoint; await sub.unsubscribe(); await fetch('/api/push/subscribe', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: ep }), }).catch(() => {}); } setSubscribed(false); setMsg('unsubscribed'); setTimeout(() => setMsg(''), 3000); } catch (e) { setMsg(`failed: ${e.message || e}`); } finally { setBusy(false); } }, [busy]); const test = pUseCallback(async () => { try { const r = await fetch('/api/push/test', { method: 'POST' }); const j = await r.json().catch(() => ({})); setMsg(j.sent ? `test sent · ${j.sent} device${j.sent === 1 ? '' : 's'}` : 'no devices subscribed'); setTimeout(() => setMsg(''), 4000); } catch (e) { setMsg(`test failed: ${e.message || e}`); } }, []); // If browser doesn't support push or the server isn't configured, // hide entirely — don't tease a feature that doesn't work. if (!supported || !serverConfigured) return null; // Render: small pill in the topbar. State-dependent styling. const denied = permission === 'denied'; const enabled = subscribed && permission === 'granted'; const color = denied ? '#d96a6a' : enabled ? '#5fb37c' : 'var(--text-2)'; const bg = enabled ? 'rgba(95,179,124,0.10)' : 'transparent'; const label = denied ? '🔕 blocked' : enabled ? '🔔 on' : '🔔 enable'; const onClick = denied ? null : (enabled ? unsubscribe : subscribe); const title = denied ? 'Notifications blocked. Enable in browser/system settings, then refresh.' : enabled ? `Tibeb pushes a banner when an event/alert/twitter memo lands. Click to disable. Shift+click to send a test.` : 'Get a push notification when an autonomy memo lands. Click to enable.'; return ( {msg && ( {msg} )} ); } window.PushControl = PushControl;