From 9e80aee1c650e01656187fe0bc9ecb30ce4c0deb Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 11 Aug 2026 22:23:16 +0530 Subject: [PATCH] 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 --- server/public/home.html | 38 ++++++++++++++++++++++++++++++++++---- server/routes.js | 12 ++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index 3fd4cff..3ee0507 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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&¤tTab()==='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')}); } diff --git a/server/routes.js b/server/routes.js index b435b42..679117e 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1712,6 +1712,18 @@ route('POST', '/api/messages/react', async (req, res) => { try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {} } + // #3: notify the message OWNER that someone reacted — a native/web push so a CLOSED app is alerted too + // (previously reactions only pushed over the live socket, so a backgrounded owner got nothing). Only when + // the reaction was ADDED (not removed) and by someone other than the owner. + if (added && msg.sender_id && msg.sender_id !== u.id) { + const reactor = u.name || u.email; + if (msg.conversation_id) { + const conv = await R.conversations.byId(msg.conversation_id); const gname = (conv && conv.name) || 'Group'; + try { PUSH.sendToUser(msg.sender_id, { title: gname, body: reactor + ' reacted ' + e + ' to your message', kind: 'group', id: msg.conversation_id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {} + } else { + try { PUSH.sendToUser(msg.sender_id, { title: reactor, body: 'reacted ' + e + ' to your message', kind: 'dm', id: u.id, tag: 'react:' + messageId, icon: u.avatar_url || undefined }); } catch (_) {} + } + } json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) }); });