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:
+28
-7
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user