feat(chat): per-message group read receipts (seen-by + read-by-all ticks)
Thread: every group message I send now carries a receipt tick — sent (1 grey)
-> seen-by-some (2 grey) -> seen-by-all (2 yellow) — computed from the per-message
seenBy the server already returns and kept live by onGroupRead. Tap the tick to see
exactly who ('Seen by X, Y') with an 'N of M' tooltip. Replaces the old last-message-
only 'Seen by' line with a universal, tappable per-message receipt.
Sidebar: the group row tick now reflects real read state (read/delivered/sent) via
memberReads vs member count, instead of a hardcoded 'sent' — and refreshes live when
the open group is read by all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+23
-6
@@ -307,6 +307,7 @@
|
|||||||
.bubble.mine .rcpt{opacity:.85;}
|
.bubble.mine .rcpt{opacity:.85;}
|
||||||
/* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */
|
/* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */
|
||||||
.bubble.mine .rcpt.seen{color:var(--brand);opacity:1;}
|
.bubble.mine .rcpt.seen{color:var(--brand);opacity:1;}
|
||||||
|
.bubble.mine .rcpt.grp{cursor:pointer;} /* tap a group tick to see who's read it */
|
||||||
.att-img{cursor:zoom-in;}
|
.att-img{cursor:zoom-in;}
|
||||||
.fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);}
|
.fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);}
|
||||||
.fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;}
|
.fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;}
|
||||||
@@ -770,7 +771,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-03-batch39';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-06-batch40';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>
|
||||||
@@ -1294,12 +1295,20 @@ function bubbleHTML(m){
|
|||||||
? '<img class="att-img" src="/files/'+pEsc(m.attachment.id)+'" data-img="/files/'+pEsc(m.attachment.id)+'" alt="'+pEsc(m.attachment.name)+'" title="Click to view">'
|
? '<img class="att-img" src="/files/'+pEsc(m.attachment.id)+'" data-img="/files/'+pEsc(m.attachment.id)+'" alt="'+pEsc(m.attachment.name)+'" title="Click to view">'
|
||||||
: '<a class="att-file" href="/files/'+pEsc(m.attachment.id)+'" download="'+pEsc(m.attachment.name)+'">'+ic('file',15)+' <span>'+pEsc(m.attachment.name)+'</span> <span class="att-sz">'+fmtSize(m.attachment.size)+'</span></a>') : '';
|
: '<a class="att-file" href="/files/'+pEsc(m.attachment.id)+'" download="'+pEsc(m.attachment.name)+'">'+ic('file',15)+' <span>'+pEsc(m.attachment.name)+'</span> <span class="att-sz">'+fmtSize(m.attachment.size)+'</span></a>') : '';
|
||||||
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
||||||
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 blue).
|
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||||||
let rcpt='';
|
let rcpt='';
|
||||||
if(mine && !convoIsGroup){ const st=m.read_at?'seen':(m.delivered_at?'delivered':'sent'); rcpt='<span class="rcpt '+st+'" title="'+(st==='seen'?'Seen':st==='delivered'?'Delivered':'Sent')+'">'+ic(st==='sent'?'check':'checkCheck',13)+'</span>'; }
|
if(mine && !convoIsGroup){ const st=m.read_at?'seen':(m.delivered_at?'delivered':'sent'); rcpt='<span class="rcpt '+st+'" title="'+(st==='seen'?'Seen':st==='delivered'?'Delivered':'Sent')+'">'+ic(st==='sent'?'check':'checkCheck',13)+'</span>'; }
|
||||||
// Group "Seen by …" on my own messages.
|
// Group ticks: a per-message receipt on MY messages — sent (1 grey) → seen-by-some (2 grey) →
|
||||||
|
// seen-by-all (2 yellow). Tap to see exactly who ("Seen by X, Y · N of M"). seenBy = other
|
||||||
|
// members who've read this message (computed server-side, kept live by onGroupRead).
|
||||||
|
else if(mine && convoIsGroup){
|
||||||
|
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1);
|
||||||
|
const ns=Array.isArray(m.seenBy)?m.seenBy:[]; const rc=ns.length;
|
||||||
|
const all=others>0 && rc>=others;
|
||||||
|
const ttl=rc?('Seen by '+ns.slice(0,6).join(', ')+(ns.length>6?(' +'+(ns.length-6)):'')+(others>0?(' · '+rc+' of '+others):'')):'Sent';
|
||||||
|
rcpt='<span class="rcpt grp '+(all?'seen':'')+'" data-seen="'+pEsc(ns.join('|'))+'" title="'+pEsc(ttl)+'">'+ic(rc>0?'checkCheck':'check',13)+'</span>';
|
||||||
|
}
|
||||||
let seen='';
|
let seen='';
|
||||||
if(mine && convoIsGroup && m.id===_lastMineId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,3).join(', '), more=ns.length>3?(' +'+(ns.length-3)+' more'):''; seen='<button class="seenby" data-seen="'+pEsc(ns.join('|'))+'">'+ic('checkCheck',12)+' Seen by '+pEsc(head)+more+'</button>'; }
|
|
||||||
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
|
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
|
||||||
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
|
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
|
||||||
+ (m.deleted?'':'<div class="msg-actions">'
|
+ (m.deleted?'':'<div class="msg-actions">'
|
||||||
@@ -1489,7 +1498,15 @@ function onChatDelivered(d){ if(!d||!d.id) return;
|
|||||||
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status==='sent'){ it.last_status='delivered'; renderChats(searchVal()); }
|
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status==='sent'){ it.last_status='delivered'; renderChats(searchVal()); }
|
||||||
}
|
}
|
||||||
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
|
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
|
||||||
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); } }
|
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); refreshGroupRowTick(d.group); } }
|
||||||
|
// Live-refresh a group's sidebar tick from the open thread (read-by-all → yellow double tick).
|
||||||
|
function refreshGroupRowTick(gid){
|
||||||
|
const row=rowFor('group',gid); if(!row||!row.last_from_me||!(selected&&selected.kind==='group'&&selected.id===gid)) return;
|
||||||
|
const mine=THREAD.filter(m=>m.from===ME.id && !m.system && !m.deleted); const last=mine[mine.length-1]; if(!last) return;
|
||||||
|
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1); const rc=Array.isArray(last.seenBy)?last.seenBy.length:0;
|
||||||
|
row.last_status=(others>0&&rc>=others)?'read':(rc>0?'delivered':'sent');
|
||||||
|
if(!typingLabel('group',gid)){ const el=listEl&&listEl.querySelector('.chat-row[data-kind="group"][data-id="'+_cssEsc(gid)+'"] .chat-prev'); if(el) el.innerHTML=rowPreviewHTML(row); }
|
||||||
|
}
|
||||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||||
async function startOrJoinGroupCall(group){
|
async function startOrJoinGroupCall(group){
|
||||||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
|
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
|
||||||
@@ -1717,7 +1734,7 @@ async function openConvo(kind,id){
|
|||||||
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
|
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
|
||||||
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
|
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
|
||||||
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
|
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
|
||||||
const sb=e.target.closest('.seenby'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast('Seen by: '+ns.join(', ')); return; }
|
const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast(ns.length?('Seen by: '+ns.join(', ')):'Not seen yet'); return; }
|
||||||
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
|
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
|
||||||
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
|
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
|
||||||
// until revealed). Tapping elsewhere hides them.
|
// until revealed). Tapping elsewhere hides them.
|
||||||
|
|||||||
+11
-2
@@ -717,12 +717,21 @@ route('GET', '/api/messages/conversations', async (req, res) => {
|
|||||||
const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => {
|
const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => {
|
||||||
const last = R.messages.lastInConversation(g.id);
|
const last = R.messages.lastInConversation(g.id);
|
||||||
const since = R.conversations.lastReadAt(g.id, u.id);
|
const since = R.conversations.lastReadAt(g.id, u.id);
|
||||||
|
const members = R.conversations.members(g.id);
|
||||||
|
// Group read tick for MY last message: read = every other member has read it, delivered = some
|
||||||
|
// have, else sent. Same three states as DMs, so the sidebar renders them identically.
|
||||||
|
let gStatus = null;
|
||||||
|
if (last && last.sender_id === u.id) {
|
||||||
|
const others = members.filter((id) => id !== u.id).length;
|
||||||
|
const seen = R.conversations.memberReads(g.id).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
|
||||||
|
gStatus = (others > 0 && seen >= others) ? 'read' : (seen > 0 ? 'delivered' : 'sent');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: 'group', id: g.id, name: g.name || 'Group', members: R.conversations.members(g.id).length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
|
kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
|
||||||
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
|
callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
|
||||||
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
|
last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
|
||||||
last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
|
last_from_me: last ? last.sender_id === u.id : false, unread: last ? R.messages.unreadInConversation(g.id, u.id, since) : 0,
|
||||||
last_status: (last && last.sender_id === u.id) ? 'sent' : null, // groups: simple sent tick (per-member receipts aren't tracked here)
|
last_status: gStatus,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
|
json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
|
||||||
|
|||||||
Reference in New Issue
Block a user