#13: pin a message

Any participant can pin/unpin a message from its ⋮ menu. Adds pinned_at/pinned_by
columns, /api/messages/pin (toggle, broadcasts chat-pinned) and
/api/messages/pinned (list, newest first, excludes deleted + delete-for-me).
The conversation shows a pinned strip under the header (latest pin + count);
tap it to jump to the message, × to unpin. Live-updates across participants and
devices. Added pin/pinOff Lucide icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:00:06 +05:30
parent 9017d2ff25
commit d0863351d2
5 changed files with 85 additions and 2 deletions
+35 -1
View File
@@ -211,6 +211,14 @@
.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;}
/* #13: pinned-message strip under the conversation header */
.pinned-bar{display:none;align-items:center;gap:.55rem;padding:.5rem .8rem;background:var(--blue-soft);border-bottom:1px solid var(--line);flex:0 0 auto;}
.pinned-bar .pb-ic{color:var(--blue);flex:0 0 auto;display:grid;place-items:center;}
.pinned-bar .pb-body{flex:1;min-width:0;cursor:pointer;}
.pinned-bar .pb-h{font-size:.66rem;font-weight:700;color:var(--blue);text-transform:uppercase;letter-spacing:.03em;}
.pinned-bar .pb-txt{font-size:.85rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.pinned-bar .pb-unpin{flex:0 0 auto;border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
.pinned-bar .pb-unpin:hover{background:rgba(0,0,0,.06);color:var(--ink);}
.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;}
@@ -2098,6 +2106,7 @@ function convoShellHTML(it){
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">'+ic('chevronDown',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<div class="pinned-bar" id="pinnedBar" style="display:none"></div>' // #13: pinned-message strip below the header
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
@@ -2249,6 +2258,7 @@ 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(!m.deleted && !m.poll) items.push({ic:(m.pinned?'pinOff':'pin'), label:(m.pinned?'Unpin':'Pin'), fn:()=>pinMessage(m.id, !m.pinned)}); // #13
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';
@@ -2600,6 +2610,29 @@ function removeMsgLocally(id){
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
// #13 Pin a message for the whole conversation (any participant). Live-broadcast → everyone's pinned strip.
let PINNED=[]; // the open conversation's pinned messages (newest pin first)
async function pinMessage(id, on){ try{ await postJSON('/api/messages/pin',{id, on:!!on}); const t=THREAD.find(x=>x.id===id); if(t) t.pinned=!!on; updateBubble&&t&&updateBubble(t); loadPinned(); toast(on?'Pinned':'Unpinned'); }catch(e){ toast(e.message||'Could not pin'); } }
async function loadPinned(kind, id){
const el=document.getElementById('pinnedBar'); if(!el) return;
kind=kind||(selected&&selected.kind); id=id||(selected&&selected.id); if(!kind||!id||kind==='dm'&&selected&&selected.self){ el.style.display='none'; return; }
const url = kind==='group' ? ('/api/messages/pinned?group='+encodeURIComponent(id)) : ('/api/messages/pinned?with='+encodeURIComponent(id));
let list=[]; try{ const r=await fetch(url); list = r.ok ? ((await r.json())||[]) : []; }catch(_){ list=[]; }
if(selected && selected.kind===kind && selected.id===id){ PINNED=list; renderPinnedBar(); }
}
function renderPinnedBar(){
const el=document.getElementById('pinnedBar'); if(!el) return;
if(!PINNED.length){ el.style.display='none'; el.innerHTML=''; return; }
const m=PINNED[0]; // show the most recent pin; the count hints at the rest
const prev = m.deleted ? 'Message deleted' : (isGifUrl(m.body)?'🎞️ GIF':(m.body ? (m.body.length>90?m.body.slice(0,90)+'…':m.body) : (m.attachment?('📎 '+(m.attachment.name||'Attachment')):'Message')));
el.innerHTML='<span class="pb-ic">'+ic('pin',15)+'</span>'
+'<div class="pb-body"><div class="pb-h">Pinned'+(PINNED.length>1?' · '+PINNED.length:'')+(m.fromName?(' · '+pEsc(m.fromName)):'')+'</div><div class="pb-txt">'+pEsc(prev)+'</div></div>'
+'<button class="pb-unpin" title="Unpin">'+ic('x',15)+'</button>';
el.style.display='flex';
const body=el.querySelector('.pb-body'); if(body) body.onclick=()=>{ try{ jumpToMessage(m.id, m.created_at||0); }catch(_){} };
const up=el.querySelector('.pb-unpin'); if(up) up.onclick=(e)=>{ e.stopPropagation(); pinMessage(m.id, false); };
}
function onChatPinned(d){ if(!d) return; if(d.id){ const t=THREAD.find(x=>x.id===d.id); if(t){ t.pinned=!!d.on; try{ updateBubble(t); }catch(_){} } } if(selected) loadPinned(); } // a pin changed → refresh the open conversation's strip
// 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); }
@@ -3289,6 +3322,7 @@ async function openConvo(kind,id){
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
PINNED=[]; try{ loadPinned(kind, id); }catch(_){} // #13: load the pinned strip for this conversation
// (The open slide/parallax is driven by the body.chat-opening class added in selectChat — animating the
// whole .content pane over the list, not the inner panel, so it reads as a native push.)
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
@@ -4347,7 +4381,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-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.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-pinned') onChatPinned(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(_){}
}
+2
View File
@@ -24,6 +24,8 @@
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
edit: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
pinOff: '<path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',