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:
+12
-4
@@ -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){
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user