feat(chat): delivery ticks on the last message in the chat list (#3)

Conversations DTO now returns last_status (sent/delivered/read for DMs; sent for groups) for
my last message. The list row shows the matching tick (single/double, blue when read) and a
red 'Draft:' indicator when there's unsent text. Live 'sent' on send/receive; upgrades to
delivered/read on refresh. e2e 119.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:59:53 +05:30
parent a33ed27ffe
commit 15350c691b
2 changed files with 14 additions and 4 deletions
+12 -4
View File
@@ -181,6 +181,8 @@
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;} .chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;} .chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;} .chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.prev-tick{display:inline-flex;vertical-align:middle;color:var(--muted);} .prev-tick.read{color:#2563eb;} .prev-tick svg{width:14px;height:14px;}
.draft-lbl{color:#dc2626;font-weight:600;}
.chat-row.unread .chat-name{font-weight:700;} .chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;} .badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;} .no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
@@ -1058,11 +1060,17 @@ function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id; const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread'); const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread');
const isG=it.kind==='group'; const isG=it.kind==='group';
const preview=it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet'); const draft=getDraft(it.kind,it.id);
let inner;
if(draft && !active){ inner='<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
else if(it.callActive){ inner='<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
else if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
inner='<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(it.last_body); }
else { inner=pEsc(it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet')); }
return '<div class="'+cls.join(' ')+'" data-kind="'+it.kind+'" data-id="'+pEsc(it.id)+'">' return '<div class="'+cls.join(' ')+'" data-kind="'+it.kind+'" data-id="'+pEsc(it.id)+'">'
+ avatarHTML(it,false) + avatarHTML(it,false)
+ '<div class="chat-main"><div class="chat-top"><span class="chat-name">'+pEsc(it.name)+'</span><span class="chat-time">'+pEsc(fmtTime(it.last_at))+'</span></div>' + '<div class="chat-main"><div class="chat-top"><span class="chat-name">'+pEsc(it.name)+'</span><span class="chat-time">'+pEsc(fmtTime(it.last_at))+'</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">'+(it.callActive?'<span class="call-on">'+ic('phone',12)+' Ongoing call</span>':pEsc(preview))+'</span>'+(it.unread>0?'<span class="badge">'+(it.unread>99?'99+':it.unread)+'</span>':'')+'</div></div></div>'; + '<div class="chat-bottom"><span class="chat-prev">'+inner+'</span>'+(it.unread>0?'<span class="badge">'+(it.unread>99?'99+':it.unread)+'</span>':'')+'</div></div></div>';
} }
function renderChats(filter){ function renderChats(filter){
const q=(filter||'').trim().toLowerCase(); const q=(filter||'').trim().toLowerCase();
@@ -1794,7 +1802,7 @@ async function sendMessage(){
clearReply(); pendingAttach=null; hideAttach(); clearReply(); pendingAttach=null; hideAttach();
{ const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } } { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
const it=rowFor(selected.kind,selected.id); const it=rowFor(selected.kind,selected.id);
if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.unread=0; } if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.unread=0; }
renderChats(searchVal()); renderChats(searchVal());
}catch(e){ inp.value=text; toast(e.message||'Could not send'); } }catch(e){ inp.value=text; toast(e.message||'Could not send'); }
} }
@@ -2000,7 +2008,7 @@ function onChatMessage(m){
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}}); else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
} }
if(it){ if(it){
it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id); it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id); it.last_status=(m.from===ME.id)?'sent':null;
if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1; if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1;
} }
if(isOpen){ if(isOpen){
+2
View File
@@ -684,6 +684,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null,
callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'), callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread, last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
}; }); }; });
// Groups // Groups
const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => { const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => {
@@ -694,6 +695,7 @@ route('GET', '/api/messages/conversations', async (req, res) => {
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)
}; };
}); });
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));