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){
? ''
+ 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));