feat(chat): dismiss a chat's notifications on other devices once seen (#13)

When you open/read a conversation, the server now also pushes a notif-clear to your
OWN other sockets. Each device tags notifications by conversation (kind:id), so on
notif-clear it closes the matching page Notification + any Service-Worker (Web Push)
notifications, and drops matching activity-center entries so the bell badge stays in
sync. (Desktop Electron native toasts are transient/auto-expire; the web+PWA surface
is covered.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:20:18 +05:30
parent ff436704ec
commit 346361da8a
2 changed files with 21 additions and 5 deletions
+17 -4
View File
@@ -809,7 +809,7 @@
<body>
<script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-07-batch51';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-07-batch52';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -2302,6 +2302,16 @@ function notifAvatarDataUrl(kind,id,fallbackName){
}
// Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
const _shownNotifs={};
function onNotifClear(d){
if(!d) return; const tag=(d.kind||'')+':'+(d.id||'');
try{ const n=_shownNotifs[tag]; if(n){ n.close(); } delete _shownNotifs[tag]; }catch(_){}
// Also close any Service-Worker (Web Push) notifications for this chat.
try{ if('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) navigator.serviceWorker.getRegistration().then(reg=>{ if(reg&&reg.getNotifications) reg.getNotifications({tag}).then(ns=>ns.forEach(n=>{ try{ n.close(); }catch(_){} })); }).catch(()=>{}); }catch(_){}
// Drop matching activity-center entries so the bell badge stays in sync.
try{ if(Array.isArray(NOTIFS)){ const before=NOTIFS.length; NOTIFS=NOTIFS.filter(x=>!(x.link&&x.link.kind===d.kind&&x.link.id===d.id)); if(NOTIFS.length!==before){ saveNotifs&&saveNotifs(); updateBellBadge&&updateBellBadge(); } } }catch(_){}
}
function notify(title, body, kind, id){
try{
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
@@ -2320,8 +2330,11 @@ function notify(title, body, kind, id){
// canvas taints it and silently fell back to initials (the bug). Brand icon only if no DP.
if(!('Notification' in window) || Notification.permission!=='granted') return;
const _row=rowFor(kind,id)||{};
const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png' });
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
const tag=(kind||'')+':'+(id||''); // #13: tag by conversation so it can be dismissed cross-device
const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png', tag });
_shownNotifs[tag]=n;
n.onclick=()=>{ n.close(); delete _shownNotifs[tag]; openFromNotif(kind, id); };
n.onclose=()=>{ if(_shownNotifs[tag]===n) delete _shownNotifs[tag]; };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}catch(_){}
}
@@ -2452,7 +2465,7 @@ function connectChatWs(){
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){}
}