From 15350c691bf35d0c3f3e285da3b6aad79008c0ab Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 2 Jul 2026 11:59:53 +0530 Subject: [PATCH] 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 --- server/public/home.html | 16 ++++++++++++---- server/routes.js | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index a280d08..497e4e5 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -181,6 +181,8 @@ .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-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;} .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;} @@ -1058,11 +1060,17 @@ function rowHTML(it){ 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 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='Draft: '+pEsc(draft); } // unsent draft (#1) + else if(it.callActive){ inner=''+ic('phone',12)+' Ongoing call'; } + else if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3) + inner=''+ic(it.last_status==='sent'?'check':'checkCheck',13)+' '+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 '
' + avatarHTML(it,false) + '
'+pEsc(it.name)+''+pEsc(fmtTime(it.last_at))+'
' - + '
'+(it.callActive?''+ic('phone',12)+' Ongoing call':pEsc(preview))+''+(it.unread>0?''+(it.unread>99?'99+':it.unread)+'':'')+'
'; + + '
'+inner+''+(it.unread>0?''+(it.unread>99?'99+':it.unread)+'':'')+'
'; } function renderChats(filter){ const q=(filter||'').trim().toLowerCase(); @@ -1794,7 +1802,7 @@ async function sendMessage(){ 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 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()); }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}}); } 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(isOpen){ diff --git a/server/routes.js b/server/routes.js index 3998812..3097ac7 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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, 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_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 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, 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) }; }); json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));