Fix notification bugs #2 (open+backgrounded chat) and #3 (reactions)

#2: a message arriving in the currently-OPEN chat produced no notification
when the app was minimized. onChatMessage marked the open chat read even
while document.hidden, which fired a notif-clear that closed the very
notification the service worker had just shown. Now the open chat is only
marked read while visible; markOpenChatRead() catches up on focus/visibility
return, and a message received while hidden stays unread with its alert intact.

#3: reacting to a message fired no notification. The react route only pushed
over the live socket (nothing for a closed app) and the client added a silent
bell entry. Now the server sends a native/web push to the message owner, and
onChatReaction pings + shows an OS/in-page popup (unless you're viewing that chat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:23:16 +05:30
parent a0f9ab10f2
commit 9e80aee1c6
2 changed files with 46 additions and 4 deletions
+34 -4
View File
@@ -2491,7 +2491,21 @@ function onGroupUpdate(d){ if(!d||!d.group) return;
const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(!d.removed && selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } // refresh open group-info
}
function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && d.reactions){ m.reactions=d.reactions; updateBubble(m); }
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){
const kind=d.convId?'group':'dm'; const rid=d.convId||d.byId;
addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:{kind,id:rid}});
// #3: someone reacted to MY message → actually alert (ping + OS/in-page popup), like a new message,
// unless I'm already looking at that chat. When hidden with push active, the SW/native push shows it.
if(notifOn(kind)){
const isViewing=selected&&selected.kind===kind&&selected.id===rid&&currentTab()==='chat'&&!document.hidden;
if(!isViewing){ playPing();
if(!(document.hidden && pushActive)){
const title=(kind==='group')?(((rowFor('group',rid)||{}).name)||m&&m.groupName||'Group'):(d.by||'Reaction');
notify(title, (d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', kind, rid);
}
}
}
}
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead(d){ if(!d||!d.by) return;
@@ -4061,8 +4075,17 @@ async function hardReloadApp(){
// Cheap by design: one ~35-byte request, answered from a variable the server read at startup. The
// throttle above means refocusing the app repeatedly costs nothing.
setInterval(()=>checkWebBuild(true), 5*60*1000); // every 5 min (the interval IS the schedule)
window.addEventListener('focus', ()=>checkWebBuild()); // coming back to the app (throttled)
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); });
// #2: when we come back to the app, mark the OPEN chat read (we deliberately skip that while hidden so a
// backgrounded message keeps its notification). Only runs when actually visible + a chat is open.
function markOpenChatRead(){
if(document.hidden) return;
if(!(selected && (selected.kind==='dm'||selected.kind==='group') && currentTab()==='chat')) return;
const it=rowFor(selected.kind, selected.id); if(it && it.unread){ it.unread=0; try{ renderChats(searchVal()); updateRailUnread(); }catch(_){} }
const body=JSON.stringify(selected.kind==='group'?{group:selected.id}:{with:selected.id});
try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){}
}
window.addEventListener('focus', ()=>{ checkWebBuild(); markOpenChatRead(); }); // coming back to the app (throttled)
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden){ checkWebBuild(); markOpenChatRead(); } });
setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot
// Deliberately NO banner and NO toast: a web build is an implementation detail. Surfacing it would make
// users think about "web build vs app version". It just updates.
@@ -4214,7 +4237,14 @@ function onChatMessage(m){
// Dedup by id: the server echoes our own sent message back (multi-tab/device sync), and
// sendMessage already appended it optimistically — so skip if it's already in the thread.
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
// #2: only mark the OPEN chat read when the app is actually VISIBLE. Marking it read while minimized
// told the server we'd seen it → a notif-clear closed the notification the SW had just shown, so a
// message from the OPEN chat produced no alert while backgrounded. When hidden, keep it unread; the
// focus/visibilitychange handler (markOpenChatRead) catches up the moment we come back.
if(m.from!==ME.id && !isSys){
if(!document.hidden){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
else if(it) it.unread=(it.unread||0)+1; // hidden → stays unread (and the notification stands) until we look
}
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
if(window.BZToast) BZToast.message(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment', {title:(m.fromName||'New message')});
}