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:
+17
-4
@@ -809,7 +809,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=4"></script>
|
<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 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
|
// 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)
|
// (emojis stay as plain Unicode). (#5)
|
||||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
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).
|
// 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(_){} }
|
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){
|
function notify(title, body, kind, id){
|
||||||
try{
|
try{
|
||||||
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
|
// 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.
|
// 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;
|
if(!('Notification' in window) || Notification.permission!=='granted') return;
|
||||||
const _row=rowFor(kind,id)||{};
|
const _row=rowFor(kind,id)||{};
|
||||||
const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png' });
|
const tag=(kind||'')+':'+(id||''); // #13: tag by conversation so it can be dismissed cross-device
|
||||||
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
|
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);
|
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
@@ -2452,7 +2465,7 @@ function connectChatWs(){
|
|||||||
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
||||||
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
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.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)
|
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -771,6 +771,7 @@ route('GET', '/api/messages/thread', async (req, res) => {
|
|||||||
R.conversations.markRead(group, u.id);
|
R.conversations.markRead(group, u.id);
|
||||||
const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
|
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 (_) {} } }
|
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 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
|
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 (!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' });
|
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);
|
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);
|
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; }));
|
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);
|
R.conversations.markRead(group, u.id);
|
||||||
const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() };
|
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 (_) {} } }
|
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 });
|
return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||||
R.messages.markRead(u.team_id, u.id, other);
|
R.messages.markRead(u.team_id, u.id, other);
|
||||||
try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {}
|
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 });
|
json(res, 200, { ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user