feat(push): Web Push notifications for backgrounded/closed/mobile tabs

Page-level Notifications can't fire when a tab is frozen/closed (and never on
mobile), which is why recipients on another tab/app got nothing. Adds a
notification-only service worker (sw.js, no caching) + Web Push:

- push.js: optional web-push wrapper (no-op unless web-push installed AND
  VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured).
- push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead).
- /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a
  Web Push to recipients.
- Client registers /sw.js, subscribes when permission granted; hidden-tab popups
  are left to push to avoid double-notifying (pushActive flag); SW suppresses the
  OS popup when a tab is visible. Removes the old code that unregistered SWs.

Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 21:58:49 +05:30
parent d50d4bde47
commit 1272b81cee
8 changed files with 356 additions and 13 deletions
+28 -7
View File
@@ -743,7 +743,7 @@ function openSettings(){
const setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} };
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); try{ await subscribePush(); }catch(_){} } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
}
// ---------- Chat (1:1 + groups) ----------
@@ -1443,11 +1443,28 @@ function ensureNotifyPermission(){ try{ if('Notification' in window && Notificat
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
document.addEventListener('click', ensureNotifyPermission, { once:true });
// System notification for a new message (shown when the tab isn't focused).
// Open a chat from a notification (called via the service worker, or the fallback path).
// Remove the old service worker (an earlier attempt) so it can't interfere.
if('serviceWorker' in navigator){ try{ navigator.serviceWorker.getRegistrations().then(rs=>rs.forEach(r=>r.unregister())).catch(()=>{}); }catch(_){} }
// Open the chat from a notification. Navigation reliably repaints across browsers (a
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
// 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; }
async function setupPush(){
if(!('serviceWorker' in navigator)) return;
try{ _swReg=await navigator.serviceWorker.register('/sw.js'); }catch(_){ return; }
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
try{ navigator.serviceWorker.addEventListener('message',(e)=>{ const d=e.data||{}; if(d.type==='open-chat' && d.id){ try{ selectChat(d.kind||'dm', d.id); }catch(_){} } }); }catch(_){}
try{ await subscribePush(); }catch(_){}
}
async function subscribePush(){
if(!_swReg || !('PushManager' in window)) return;
if(!('Notification' in window) || Notification.permission!=='granted') return; // only once allowed
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(_){ return; }
if(!cfg || !cfg.enabled || !cfg.key) return; // server hasn't configured VAPID -> stay on in-page notifs
let sub=null; try{ sub=await _swReg.pushManager.getSubscription(); }catch(_){}
if(!sub){ try{ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }catch(_){ return; } }
try{ await postJSON('/api/push/subscribe', sub.toJSON()); pushActive=true; }catch(_){}
}
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function notify(title, body, kind, id){
@@ -1509,7 +1526,10 @@ function onChatMessage(m){
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
if(m.from!==ME.id && !isSys){
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
if(notifOn(kind)){ playPing(); if(!(isOpen && !document.hidden)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
// back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); if(wantPopup && !(document.hidden && pushActive)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
// Activity-center entries for things easy to miss.
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
@@ -2285,6 +2305,7 @@ async function doRegister(){
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
loadNotifs(); wireBell(); wireProfile();
connectChatWs();
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
document.getElementById('loading').style.display='none';
// Fast-path: when opened from a notification, show the chat immediately (only needs the
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
+42
View File
@@ -0,0 +1,42 @@
// BizGaze Connect service worker — NOTIFICATIONS ONLY.
// Intentionally has NO 'fetch' handler and NO caching, so it can never serve a stale
// version of the app. Its only job is to show push notifications when the page is in
// the background / frozen / closed, and to open the right chat when one is clicked.
self.addEventListener('install', (e) => { self.skipWaiting(); });
self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); });
self.addEventListener('push', (event) => {
let d = {};
try { d = event.data ? event.data.json() : {}; } catch (_) {}
const title = d.title || 'BizGaze Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
};
event.waitUntil((async () => {
// If a BizGaze tab is currently VISIBLE, the page itself alerts the user (ping / in-page
// popup) — skip the OS popup to avoid a double. Only show when no tab is visible
// (another tab/app, minimized, or closed) — exactly when the page can't alert.
const clientsArr = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const visible = clientsArr.some((c) => c.visibilityState === 'visible');
if (visible) return;
await self.registration.showNotification(title, options);
})());
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { kind, id } = event.notification.data || {};
const url = '/home' + (id ? ('?openKind=' + encodeURIComponent(kind || 'dm') + '&openId=' + encodeURIComponent(id)) : '');
event.waitUntil((async () => {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const c of all) {
if (c.url.includes('/home')) { try { await c.focus(); c.postMessage({ type: 'open-chat', kind, id }); return; } catch (_) {} }
}
try { await self.clients.openWindow(url); } catch (_) {}
})());
});