feat(push): wire Capacitor native push into the web UI

home.html: in a Capacitor app shell, setupPush() now uses the native FCM/APNs path
instead of Web Push — requests permission, registers, POSTs the OS device token to
/api/v1/devices, deep-links on notification tap (selectChat), and unregisters the
token on logout. Web Notification prompts are suppressed on native. Fully inert in a
normal browser (Web Push unchanged). build batch15.

CLIENTS.md Phase B push items checked off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:56:59 +05:30
parent 4c75db2029
commit 7ae0cacf74
2 changed files with 36 additions and 4 deletions
+5 -2
View File
@@ -60,8 +60,11 @@ absolute API base if offline-launch or store policy requires it.
tokens are pruned on 404/410/UNREGISTERED. (db `device_tokens`, repo `deviceTokens`,
`push.js`.) *Mobile app still needs the Capacitor push plugin wired + FCM/APNs creds
to deliver end-to-end.*
- [ ] **Wire the Capacitor push plugin** in the mobile app → register the token via
`POST /api/v1/devices` on launch; handle taps (deep link to the chat/session).
- [x] **Capacitor push plugin wired** in the web UI (`setupNativePush` in home.html):
inside the app it requests permission, registers, and `POST`s the FCM/APNs token to
`/api/v1/devices`; notification taps deep-link via `selectChat`; logout unregisters the
token. Web Push is skipped when running natively. Inert in a normal browser. *Activates
once the Capacitor Android/iOS app is built and FCM/APNs creds are set.*
- [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin).
- [ ] **Deep links / universal links** so a session/meeting link opens the app.
+31 -2
View File
@@ -721,7 +721,7 @@
</head>
<body>
<script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-06-30-batch14';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<script>window.__BUILD='2026-06-30-batch15';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div>
<header>
@@ -1766,7 +1766,7 @@ async function sendMessage(){
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
// won't work until a subscription exists.
function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission().then(r=>{ if(r==='granted'){ try{ subscribePush(); }catch(_){} } }).catch(()=>{}); }catch(_){} }
function ensureNotifyPermission(){ try{ const p=nativePlatform(); if(p==='ios'||p==='android') return; if('Notification' in window && Notification.permission==='default') Notification.requestPermission().then(r=>{ if(r==='granted'){ try{ subscribePush(); }catch(_){} } }).catch(()=>{}); }catch(_){} }
// Notification preferences (set in Settings; stored per browser). Default ON.
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
// Mobile has no top bar (#7): keep bell+profile in the chat-list header; desktop keeps them in
@@ -1782,6 +1782,7 @@ function placeHdrRight(){
// iOS (permission must be requested from a tap inside the installed PWA).
function maybeNotifPrompt(){
try{
const p=nativePlatform(); if(p==='ios'||p==='android') return; // native app handles its own permission flow
if(!('Notification' in window) || Notification.permission!=='default') return;
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt';
@@ -1796,7 +1797,33 @@ function maybeNotifPrompt(){
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
let pushActive=false, _swReg=null;
function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); const b=(base64+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return arr; }
// ----- Native push (Capacitor app: FCM on Android, APNs on iOS) -----
// In a native shell, Capacitor injects window.Capacitor and exposes the PushNotifications
// plugin. We register the OS device token with /api/v1/devices so the server can deliver via
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop') return 'desktop'; return ''; }
let _nativeTok=null;
async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
try{
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
catch(e){ console.warn('[push] device register failed:', e); } });
PN.addListener('registrationError', (e)=>console.warn('[push] native registration error:', e));
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
let perm=await PN.checkPermissions();
if(perm.receive!=='granted') perm=await PN.requestPermissions();
if(perm.receive!=='granted'){ console.log('[push] native permission not granted'); return true; }
await PN.register();
console.log('[push] native push registered');
}catch(e){ console.warn('[push] native push setup failed:', e); }
return true; // handled the native path (skip Web Push regardless of outcome)
}
async function setupPush(){
if(await setupNativePush()) return; // native app → FCM/APNs, not Web Push
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
@@ -1819,6 +1846,8 @@ async function subscribePush(){
}
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
async function unsubscribePush(){
// Native app: drop this device's FCM/APNs token so push stops for the logged-out user.
try{ if(_nativeTok){ await postJSON('/api/v1/devices/remove',{ token:_nativeTok }); _nativeTok=null; pushActive=false; return; } }catch(_){}
try{
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
if(!_swReg) return;