From d0863351d24eb5d108e3e1d24e7ca3ebdc467916 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 11 Aug 2026 23:00:06 +0530 Subject: [PATCH] #13: pin a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db.js | 4 ++++ server/public/home.html | 36 +++++++++++++++++++++++++++++++++++- server/public/icons.js | 2 ++ server/repos.js | 5 +++++ server/routes.js | 40 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/server/db.js b/server/db.js index f36e932..06fb3df 100644 --- a/server/db.js +++ b/server/db.js @@ -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 diff --git a/server/public/home.html b/server/public/home.html index b3cefb9..77105c1 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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){ + '' + '' + '' + + '' // #13: pinned-message strip below the header + '
' + '' + '' @@ -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=''+ic('pin',15)+'' + +'
Pinned'+(PINNED.length>1?' ยท '+PINNED.length:'')+(m.fromName?(' ยท '+pEsc(m.fromName)):'')+'
'+pEsc(prev)+'
' + +''; + 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(_){} } diff --git a/server/public/icons.js b/server/public/icons.js index 499be8c..ad05a80 100644 --- a/server/public/icons.js +++ b/server/public/icons.js @@ -24,6 +24,8 @@ search: '', edit: '', trash: '', + pin: '', + pinOff: '', logOut: '', plus: '', check: '', diff --git a/server/repos.js b/server/repos.js index 53b30f1..b01e2b9 100644 --- a/server/repos.js +++ b/server/repos.js @@ -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), diff --git a/server/routes.js b/server/routes.js index 2796788..af08e25 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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) => {