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:
2026-07-06 10:57:08 +05:30
parent 3f791cb120
commit 30e354d58f
2 changed files with 34 additions and 8 deletions
+23 -6
View File
@@ -307,6 +307,7 @@
.bubble.mine .rcpt{opacity:.85;}
/* 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.grp{cursor:pointer;} /* tap a group tick to see who's read it */
.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 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>
<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-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
// (emojis stay as plain Unicode). (#5)
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">'
: '<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'));
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 blue).
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
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>'; }
// 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='';
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)+'">'
+ sender + quote + att + renderMsgBody(m) + pollHTML(m)
+ (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()); }
}
// 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).
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); } }
@@ -1717,7 +1734,7 @@ async function openConvo(kind,id){
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 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
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
// until revealed). Tapping elsewhere hides them.