#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:
@@ -222,6 +222,10 @@ try { db.exec('CREATE TABLE IF NOT EXISTS message_hidden (message_id TEXT NOT NU
|
||||
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ }
|
||||
// #13 Pin a message: pinned_at (when it was pinned, NULL = not pinned) + pinned_by (who pinned it). A
|
||||
// conversation's pinned strip lists its messages with a non-NULL pinned_at.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN pinned_at INTEGER'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN pinned_by TEXT'); } catch (e) { /* exists */ }
|
||||
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
|
||||
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
|
||||
|
||||
+35
-1
@@ -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(_){}
|
||||
}
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -211,6 +211,11 @@ const messages = {
|
||||
// Last message in a group that THIS user hasn't hidden (so a "delete for me" on the last message
|
||||
// rolls the sidebar preview back to the previous one, instead of showing what they just removed).
|
||||
lastInConversationForUser: (conversationId, userId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND id NOT IN (SELECT message_id FROM message_hidden WHERE user_id=?) ORDER BY created_at DESC LIMIT 1').get(conversationId, userId),
|
||||
// #13 Pin a message: set/clear pinned_at + pinned_by.
|
||||
setPinned: (id, pinnedAt, pinnedBy) => db.prepare('UPDATE messages SET pinned_at=?, pinned_by=? WHERE id=?').run(pinnedAt, pinnedBy, id),
|
||||
// Pinned messages in a group / DM (newest pin first, deleted excluded).
|
||||
pinnedInConversation: (conversationId) => db.prepare('SELECT * FROM messages WHERE conversation_id=? AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(conversationId),
|
||||
pinnedInDm: (teamId, a, b) => db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) AND pinned_at IS NOT NULL AND deleted=0 ORDER BY pinned_at DESC').all(teamId, a, b, b, a),
|
||||
// Shared media/files in a conversation (group) or DM — newest first.
|
||||
attachmentsForConversation: (teamId, conversationId) => db.prepare(`SELECT a.id, a.name, a.mime, a.size, m.created_at FROM messages m JOIN attachments a ON a.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id=? AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, conversationId),
|
||||
attachmentsForDm: (teamId, a, b) => db.prepare(`SELECT at.id, at.name, at.mime, at.size, m.created_at FROM messages m JOIN attachments at ON at.id=m.attachment_id WHERE m.team_id=? AND m.conversation_id IS NULL AND ((m.sender_id=? AND m.recipient_id=?) OR (m.sender_id=? AND m.recipient_id=?)) AND m.deleted=0 ORDER BY m.created_at DESC`).all(teamId, a, b, b, a),
|
||||
|
||||
+39
-1
@@ -11,7 +11,7 @@ const PUSH = require('./push');
|
||||
const MSG_MAX = 4000;
|
||||
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const SYSTEM_SENDER = '__system__';
|
||||
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
||||
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, pinned: !!m.pinned_at, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
|
||||
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
|
||||
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
|
||||
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
|
||||
@@ -1640,6 +1640,44 @@ route('POST', '/api/messages/hide', async (req, res) => {
|
||||
try { CHAT.pushToUser(u.id, { type: 'chat-hidden', id, conversation_id: m.conversation_id || null }); } catch (_) {} // my other devices
|
||||
json(res, 200, { ok: true });
|
||||
});
|
||||
// #13 Pin / unpin a message for the whole conversation (any participant may pin/unpin). Broadcast so every
|
||||
// participant's pinned strip updates live.
|
||||
route('POST', '/api/messages/pin', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const { id, on } = await readBody(req);
|
||||
if (!id) return json(res, 400, { error: 'id required' });
|
||||
const m = await R.messages.byId(id);
|
||||
if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
|
||||
if (m.deleted) return json(res, 400, { error: 'cannot pin a deleted message' });
|
||||
const canSee = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
|
||||
if (!canSee) return json(res, 403, { error: 'not allowed' });
|
||||
const pin = on !== false; // default true
|
||||
await R.messages.setPinned(id, pin ? now() : null, pin ? u.id : null);
|
||||
const evt = { type: 'chat-pinned', id, on: pin, by: u.name || u.email, conversation_id: m.conversation_id || null };
|
||||
if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
|
||||
else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(m.sender_id, evt); } catch (_) {} }
|
||||
json(res, 200, { ok: true, pinned: pin });
|
||||
});
|
||||
// #13 The pinned messages for a conversation (?with=userId) or group (?group=id), newest pin first.
|
||||
route('GET', '/api/messages/pinned', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
const q = new URLSearchParams(req.url.split('?')[1] || '');
|
||||
const names = await namesFor(u.team_id);
|
||||
const group = q.get('group');
|
||||
let rows;
|
||||
if (group) {
|
||||
if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
|
||||
rows = await R.messages.pinnedInConversation(group);
|
||||
} else {
|
||||
const other = await R.users.resolve(q.get('with'));
|
||||
if (!other) return json(res, 400, { error: 'with or group required' });
|
||||
rows = await R.messages.pinnedInDm(u.team_id, u.id, other);
|
||||
}
|
||||
const hidden = new Set(await R.messages.hiddenForUser(u.id)); // #18: don't surface a message you deleted-for-me
|
||||
json(res, 200, await Promise.all(rows.filter((m) => !hidden.has(m.id)).map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; return d; })));
|
||||
});
|
||||
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
|
||||
// change live to the other side / other tabs (mirrors the delete broadcast).
|
||||
route('POST', '/api/messages/edit', async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user