From 346361da8a795256b757442ea3ac6fd1244a21a5 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 7 Jul 2026 16:20:18 +0530 Subject: [PATCH] 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 --- server/public/home.html | 21 +++++++++++++++++---- server/routes.js | 5 ++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index f8cdc50..3cd752f 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -809,7 +809,7 @@ - @@ -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&®.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(_){} } diff --git a/server/routes.js b/server/routes.js index f1d8ba2..40ec640 100644 --- a/server/routes.js +++ b/server/routes.js @@ -771,6 +771,7 @@ route('GET', '/api/messages/thread', async (req, res) => { R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13 } const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names); const reads = R.conversations.memberReads(group).filter((r) => r.user_id !== u.id); // others' read times @@ -784,7 +785,7 @@ route('GET', '/api/messages/thread', async (req, res) => { if (!other) return json(res, 400, { error: 'with or group required' }); if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); const rows = R.messages.thread(u.team_id, u.id, other, 500, before); - if (!peek && !before) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } + if (!peek && !before) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13 const rxBy = groupReactions(R.reactions.forPair(u.team_id, u.id, other), u.id, names); return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); }); @@ -1448,12 +1449,14 @@ route('POST', '/api/messages/read', async (req, res) => { R.conversations.markRead(group, u.id); const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() }; for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13: clear this chat's notifications on my other devices } return json(res, 200, { ok: true }); } if (!other) return json(res, 400, { error: 'with or group required' }); R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} + try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} // #13: multi-device dismissal json(res, 200, { ok: true }); });