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)+'' + +'