#10 deleted-last-message preview + #18 delete for me / for everyone

#10: deleting the last message showed "No messages yet" in the chat list.
The sidebar sent an empty last_body for a deleted (content-cleared) row; now
it sends a last_deleted flag and the row renders "This message was deleted"
(or "You deleted this message"), matching the in-thread placeholder.

#18: added "Delete for me" alongside "Delete for everyone". A new message_hidden
table records a per-user hide; the thread + sidebar (last message, unread) filter
out the requesting user's hidden messages, and the hide is echoed to their other
devices (chat-hidden). "Delete for me" is offered on any message; "Delete for
everyone" stays sender-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:31:17 +05:30
parent 9e80aee1c6
commit e359271157
4 changed files with 47 additions and 5 deletions
+15 -2
View File
@@ -210,6 +210,7 @@
.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;}
.prev-del{display:inline-flex;align-items:center;gap:.3rem;color:var(--muted);font-style:italic;opacity:.85;}
.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;}
@@ -1914,6 +1915,7 @@ function rowPreviewHTML(it){
const draft=getDraft(it.kind,it.id);
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
if(it.callActive){ return '<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
if(it.last_deleted){ return '<span class="prev-del">'+ic('trash',12)+' '+(it.last_from_me?'You deleted this message':'This message was deleted')+'</span>'; } // #10
const prevBody=isGifUrl(it.last_body)?'🎞️ GIF':it.last_body; // #5: don't show the raw GIF URL
if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(prevBody); }
@@ -2234,7 +2236,8 @@ function openMsgMore(msgId, anchor){
items.push({ic:'reply', label:'Reply', fn:()=>setReply(m)});
if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)});
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
if(mine) items.push({ic:'trash', label:'Delete', danger:true, fn:()=>deleteMessage(m.id)});
items.push({ic:'trash', label:'Delete for me', danger:true, fn:()=>deleteForMe(m.id)}); // #18: hide from my view only
if(mine && !m.deleted) items.push({ic:'trash', label:'Delete for everyone', danger:true, fn:()=>deleteMessage(m.id)}); // #18: unsend for all
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
document.body.appendChild(menu);
@@ -2573,6 +2576,16 @@ function markMsgDeleted(id){
if(changed) renderThread();
}
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
// #18 Delete-for-me: hide only from MY view (echoed to all my devices) — the message stays for everyone else.
async function deleteForMe(id){ if(!(await bzConfirm('This will remove the message from your chat on all your devices. Others will still see it.', {title:'Delete for me?', okText:'Delete', danger:true}))) return; try{ await postJSON('/api/messages/hide',{id}); removeMsgLocally(id); }catch(e){ toast(e.message||'Could not delete'); } }
function removeMsgLocally(id){
let changed=false;
const i=THREAD.findIndex(x=>x.id===id); if(i>=0){ THREAD.splice(i,1); changed=true; }
THREAD_CACHE.forEach(arr=>{ const j=arr.findIndex(x=>x.id===id); if(j>=0) arr.splice(j,1); });
if(changed) renderThread();
try{ loadSidebar(); }catch(_){} // last-message preview may roll back to the previous message
}
function onChatHidden(d){ if(!d||!d.id) return; removeMsgLocally(d.id); } // another of my devices hid it
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered(d){ if(!d||!d.id) return;
const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); }
@@ -4291,7 +4304,7 @@ function connectChatWs(){
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-hidden') onChatHidden(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){}
}