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:
+5
-2
@@ -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`,
|
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
|
`push.js`.) *Mobile app still needs the Capacitor push plugin wired + FCM/APNs creds
|
||||||
to deliver end-to-end.*
|
to deliver end-to-end.*
|
||||||
- [ ] **Wire the Capacitor push plugin** in the mobile app → register the token via
|
- [x] **Capacitor push plugin wired** in the web UI (`setupNativePush` in home.html):
|
||||||
`POST /api/v1/devices` on launch; handle taps (deep link to the chat/session).
|
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).
|
- [ ] **Mobile screen capture** for "Share Screen" from a phone (ReplayKit / MediaProjection plugin).
|
||||||
- [ ] **Deep links / universal links** so a session/meeting link opens the app.
|
- [ ] **Deep links / universal links** so a session/meeting link opens the app.
|
||||||
|
|
||||||
|
|||||||
+31
-2
@@ -721,7 +721,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=4"></script>
|
<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>
|
<div class="loading" id="loading">Loading…</div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
@@ -1766,7 +1766,7 @@ async function sendMessage(){
|
|||||||
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
|
// 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
|
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
|
||||||
// won't work until a subscription exists.
|
// 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.
|
// Notification preferences (set in Settings; stored per browser). Default ON.
|
||||||
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
|
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
|
// 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).
|
// iOS (permission must be requested from a tap inside the installed PWA).
|
||||||
function maybeNotifPrompt(){
|
function maybeNotifPrompt(){
|
||||||
try{
|
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(!('Notification' in window) || Notification.permission!=='default') return;
|
||||||
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
|
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
|
||||||
const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt';
|
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).
|
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
|
||||||
let pushActive=false, _swReg=null;
|
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; }
|
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(){
|
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; }
|
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{ 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()
|
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.
|
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
|
||||||
async function unsubscribePush(){
|
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{
|
try{
|
||||||
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
|
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
|
||||||
if(!_swReg) return;
|
if(!_swReg) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user