From 30e354d58f655baacd98f3dce5e5796e59902cf8 Mon Sep 17 00:00:00 2001 From: sravan Date: Mon, 6 Jul 2026 10:57:08 +0530 Subject: [PATCH] feat(chat): per-message group read receipts (seen-by + read-by-all ticks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/public/home.html | 29 +++++++++++++++++++++++------ server/routes.js | 13 +++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index dd3ac6a..02e4081 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -1294,12 +1295,20 @@ function bubbleHTML(m){ ? ''+pEsc(m.attachment.name)+'' : ''+ic('file',15)+' '+pEsc(m.attachment.name)+' '+fmtSize(m.attachment.size)+'') : ''; 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=''+ic(st==='sent'?'check':'checkCheck',13)+''; } - // 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=''+ic(rc>0?'checkCheck':'check',13)+''; + } 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=''; } return '
' + sender + quote + att + renderMsgBody(m) + pollHTML(m) + (m.deleted?'':'
' @@ -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. diff --git a/server/routes.js b/server/routes.js index 59affcb..ee3d479 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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 last = R.messages.lastInConversation(g.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 { - 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, 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_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));