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));