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
+4 -1
View File
@@ -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 });
});