diff --git a/server/db.js b/server/db.js index 861fb93..12ebf90 100644 --- a/server/db.js +++ b/server/db.js @@ -218,6 +218,7 @@ try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } // 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) */ } // 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 3cd752f..29c47c8 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -149,8 +149,9 @@ .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;} .chatlist:hover{scrollbar-color:#c7d0dd transparent;} - .chatlist::-webkit-scrollbar{width:7px;} - .chatlist::-webkit-scrollbar-button{display:none;height:0;width:0;} + .chatlist::-webkit-scrollbar{width:6px;} + .chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;} + .convo-msgs::-webkit-scrollbar{width:6px;} .convo-msgs::-webkit-scrollbar-button{display:none!important;height:0!important;width:0!important;} .chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;} .chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;} /* Pull-to-refresh indicator (mobile) */ @@ -261,11 +262,11 @@ .gi-media-row .gmr-r{color:var(--muted);font-size:.85rem;font-weight:600;} .gi-media-strip{display:flex;gap:.3rem;margin:.4rem 0 .2rem;overflow:hidden;} .gms-thumb{width:60px;height:60px;object-fit:cover;border-radius:8px;background:#f1f5f9;cursor:pointer;flex:0 0 auto;} - /* Media / Docs / Links — clean underline tabs (active = green underline, no chip borders) */ + /* Media / Docs / Links — clean underline tabs (active = brand-blue underline, no chip borders) */ .mv-tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin:.5rem 0 0;} .mv-tabs .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.95rem;font-weight:600;padding:.6rem .3rem .65rem;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;text-align:center;} .mv-tabs .mp-tab:hover{color:var(--ink);} - .mv-tabs .mp-tab.on{color:var(--ink);font-weight:700;border-bottom-color:#16a34a;} + .mv-tabs .mp-tab.on{color:var(--blue);font-weight:700;border-bottom-color:var(--blue);} .mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;} /* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */ .sh-cell{margin:0;display:flex;flex-direction:column;gap:.28rem;min-width:0;} @@ -290,7 +291,7 @@ .convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;} .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;} /* message thread */ - .convo-msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} + .convo-msgs{flex:1;overflow-y:auto;overflow-anchor:none;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} /* overflow-anchor:none so prepending older history doesn't fight our manual scroll anchor */ .bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);} .bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;} .bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;} @@ -319,16 +320,16 @@ .bubble .msg-list li{margin:.05rem 0;} .bubble code{background:rgba(0,0,0,.08);padding:.05rem .3rem;border-radius:5px;font-size:.86em;font-family:ui-monospace,Consolas,monospace;} .bubble.mine code{background:rgba(255,255,255,.2);} - .lightbox{position:fixed;inset:0;z-index:6000;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} + .lightbox{position:fixed;inset:0;z-index:9900;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} /* above modals (9800) so the media→image preview opens on top */ .lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);} .lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:18px;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;} .lightbox .lb-close{right:18px;} .lightbox .lb-dl{right:74px;} .lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);} - .lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.14);color:#fff;width:50px;height:50px;border-radius:50%;display:grid;place-items:center;cursor:pointer;} - .lightbox .lb-nav:hover{background:rgba(255,255,255,.3);} - .lightbox .lb-prev{left:18px;} .lightbox .lb-next{right:18px;} - @media(max-width:560px){ .lightbox .lb-nav{width:42px;height:42px;} .lightbox .lb-prev{left:6px;} .lightbox .lb-next{right:6px;} } + .lightbox img{max-width:82vw;} + .lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;} + .lightbox .lb-nav:hover{background:rgba(255,255,255,.34);} + .lightbox .lb-prev{left:14px;} .lightbox .lb-next{right:14px;} .composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);} .composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;} .composer-box:focus-within{border-color:var(--blue);} @@ -440,6 +441,9 @@ .convo{position:relative;} .bubble{position:relative;} .bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;} + .bubble .fwd-label{display:flex;align-items:center;gap:.2rem;font-size:.72rem;font-style:italic;opacity:.7;margin-bottom:.2rem;} + .bubble .fwd-label svg{transform:scaleX(-1) rotate(0deg);} + .bubble.mine .fwd-label{color:#e6edfb;} .bubble.them .fwd-label{color:var(--muted);} /* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float off into empty space where the hover (and the buttons) would vanish before you can click. */ @@ -449,18 +453,27 @@ .msg-actions button:hover{background:var(--blue-soft);} .msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;} /* #1 Forward: message selection mode + target picker */ - .bubble.selected{outline:2px solid var(--blue);outline-offset:2px;} + /* Selection mode: a checkbox appears on every message; selected ones highlight in green (distinct + from the blue message bubbles) with a filled tick. */ body.sel-mode .bubble{cursor:pointer;} body.sel-mode .msg-actions{display:none!important;} + body.sel-mode .bubble::after{content:"";position:absolute;top:-7px;width:20px;height:20px;border-radius:50%;border:2px solid var(--muted);background:var(--card);z-index:7;} + body.sel-mode .bubble.them::after{left:-8px;} body.sel-mode .bubble.mine::after{right:-8px;} + body.sel-mode .bubble.selected::after{border-color:#16a34a;background:#16a34a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/12px no-repeat;} + .bubble.selected{outline:2px solid #16a34a;outline-offset:2px;} + body.sel-mode .bubble.selected{background:#eafaf0;} + body.sel-mode .bubble.mine.selected{background:#16a34a;} .sel-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:9000;display:flex;align-items:center;gap:.7rem;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:.4rem .5rem .4rem .4rem;box-shadow:0 10px 30px rgba(20,30,60,.25);} .sel-bar .sb-x{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:50%;display:grid;place-items:center;cursor:pointer;} .sel-bar .sb-n{font-size:.9rem;font-weight:600;color:var(--ink);} .sel-bar .sb-fwd{border:none;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .9rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;} .sel-bar .sb-fwd:hover{background:var(--blue-d);} .fwd-list{max-height:44vh;overflow-y:auto;display:flex;flex-direction:column;gap:2px;} - .fwd-item{display:flex;align-items:center;gap:.6rem;padding:.4rem .5rem;border-radius:10px;cursor:pointer;} - .fwd-item:hover,.fwd-item.sel{background:var(--blue-soft);} + .fwd-item{display:flex;align-items:center;gap:.6rem;padding:.45rem .5rem;border-radius:10px;cursor:pointer;border:1px solid transparent;} + .fwd-item:hover{background:#f2f6fc;} + .fwd-item.sel{background:var(--blue-soft);border-color:var(--blue);} .fwd-item .fi-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.92rem;} - .fwd-item .fi-check{opacity:0;color:var(--blue);} .fwd-item.sel .fi-check{opacity:1;} + .fwd-item .fi-check{width:20px;height:20px;flex:0 0 auto;border:2px solid var(--line);border-radius:6px;display:grid;place-items:center;color:transparent;} + .fwd-item.sel .fi-check{background:var(--blue);border-color:var(--blue);color:#fff;} .fwd-item .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.8rem;overflow:hidden;} .fwd-item .mini-av img{width:100%;height:100%;object-fit:cover;} .reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);} @@ -527,14 +540,15 @@ .modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);} /* Branded confirm dialog (replaces the OS/Electron window.confirm). */ .bz-confirm .bzc-msg{margin:.2rem 0 1.1rem;color:var(--ink);font-size:.92rem;line-height:1.5;} - .bz-confirm .bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;} - .bz-confirm .bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;} - .bz-confirm .bzc-cancel{background:var(--blue-soft);color:var(--blue);} - .bz-confirm .bzc-cancel:hover{background:#dbe6fa;} - .bz-confirm .bzc-ok{background:var(--blue);color:#fff;} - .bz-confirm .bzc-ok:hover{background:var(--blue-d);} - .bz-confirm .bzc-ok.danger{background:var(--red);} - .bz-confirm .bzc-ok.danger:hover{background:#991b1b;} + .bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;} + .bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;} + .bzc-cancel{background:var(--blue-soft);color:var(--blue);} + .bzc-cancel:hover{background:#dbe6fa;} + .bzc-ok{background:var(--blue);color:#fff;} + .bzc-ok:hover{background:var(--blue-d);} + .bzc-ok:disabled{opacity:.5;cursor:not-allowed;} + .bzc-ok.danger{background:var(--red);} + .bzc-ok.danger:hover{background:#991b1b;} /* "typing…" indicator inside the conversation, just above the composer. */ .typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;} .typing-row.show{display:flex;} @@ -807,9 +821,9 @@ - + - @@ -1034,7 +1048,7 @@ async function reloadThread(){ if(!selected) return; const kind=selected.kind, i // ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the // previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ---- const PAGE=500; -let _hasMoreOlder=false, _loadingOlder=false; +let _hasMoreOlder=false, _loadingOlder=false, _olderCd=0; function threadUrl(kind,id,before){ return (kind==='group'?'/api/messages/thread?group='+encodeURIComponent(id):'/api/messages/thread?with='+encodeURIComponent(id))+(before?('&before='+before):''); } async function loadOlder(){ if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return; @@ -1050,7 +1064,7 @@ async function loadOlder(){ renderThread(true); // keep scroll (don't jump to bottom) if(box) box.scrollTop = box.scrollHeight - prevH + prevTop; // anchor on the same message } else { _hasMoreOlder=false; } - _loadingOlder=false; + _loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll } // Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to). async function ensureLoadedBack(at){ let guard=0; while(_hasMoreOlder && THREAD.length && THREAD[0].created_at > at && guard<60){ await loadOlder(); guard++; } } @@ -1465,7 +1479,7 @@ function bubbleHTML(m){ } let seen=''; return '
' - + sender + quote + att + renderMsgBody(m) + pollHTML(m) + + sender + (m.fwd_from?'
'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from '+pEsc(m.fwd_from)+''):'')+'
':'') + quote + att + renderMsgBody(m) + pollHTML(m) + (m.deleted?'':'
' + '' + ((m.body||m.attachment)&&!m.poll?'':'') @@ -1820,9 +1834,9 @@ function openLightbox(src){ let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0; const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox'; ov.innerHTML='' - +(multi?'':'') + +(multi?'':'') +'' - +(multi?'':'') + +(multi?'':'') +''+ic('download',20)+''; document.body.appendChild(ov); const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl'); @@ -1840,7 +1854,7 @@ function updateBubble(m){ // Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button. function onMsgsScroll(){ const box=document.getElementById('msgs'); if(!box) return; - if(box.scrollTop < 120 && _hasMoreOlder && !_loadingOlder) loadOlder(); // near the top → pull in older history + if(box.scrollTop < 120 && _hasMoreOlder && !_loadingOlder && Date.now()>_olderCd) loadOlder(); // near the top → pull in older history const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid'; const fd=document.getElementById('floatDate'); if(!fd) return; @@ -3292,6 +3306,7 @@ railBtns.forEach(btn=>{ btn.onclick=()=>{ // Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below). document.addEventListener('keydown',(e)=>{ if(e.key!=='Escape') return; + const lb=document.getElementById('lightbox'); if(lb){ lb.remove(); e.preventDefault(); e.stopPropagation(); return; } // #10: close the image preview first (leave the media view underneath) const ovs=document.querySelectorAll('.modal-ov'); if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; } if(_selMode){ exitSelect(); e.preventDefault(); e.stopPropagation(); return; } // #1: leave forward-selection diff --git a/server/public/icons.js b/server/public/icons.js index 16426d5..22a77ff 100644 --- a/server/public/icons.js +++ b/server/public/icons.js @@ -40,6 +40,8 @@ pencil: '', chevronDown: '', chevronUp: '', + chevronLeft: '', + chevronRight: '', star: '', link: '', layoutDashboard:'', diff --git a/server/repos.js b/server/repos.js index f0f01e1..8e00e3f 100644 --- a/server/repos.js +++ b/server/repos.js @@ -189,9 +189,9 @@ const webhooks = { }; const messages = { - send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) => - db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)') - .run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null), + send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType, fwdFrom }) => + db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type,fwd_from) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null, fwdFrom || null), byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id), byAttachment: (attachmentId) => db.prepare('SELECT * FROM messages WHERE attachment_id=? LIMIT 1').get(attachmentId), allByAttachment: (attachmentId) => db.prepare('SELECT sender_id, recipient_id, conversation_id FROM messages WHERE attachment_id=? AND deleted=0').all(attachmentId), diff --git a/server/routes.js b/server/routes.js index 40ec640..e8d43e3 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, 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, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type }); function namesFor(teamId){ const o = {}; for (const x of R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; } // Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead. function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; } @@ -1367,8 +1367,9 @@ route('POST', '/api/messages/forward', async (req, res) => { else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; } for (const m of srcs) { const nid = A.token(16); - if (isGroup) R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid }); - else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id }); + const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards + if (isGroup) R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin }); + else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin }); const dto = buildMsgDTO(R.messages.byId(nid), names, u.id); const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }