fix(chat): forward polish + scrollbar + image preview + media tabs

- #4: forward modal buttons now branded (bzc-* un-scoped from .bz-confirm); target
  rows get real checkboxes + border highlight; selected MESSAGES highlight in green
  with a filled tick (distinct from blue bubbles), checkbox on every message.
- #5: forwarded messages show an italic 'Forwarded from <origin>' label; new fwd_from
  column preserves the true origin across re-forwards.
- #6: image-preview arrows are smaller (32px) chevron icons, clear of the image
  (image max 82vw); icons.js?v=5.
- #10: image preview now opens ABOVE the media modal (z 9900) and Esc/close returns
  to the media view instead of closing everything.
- #11: Media/Links/Docs active tab underline is brand-blue, not green.
- #2: chat-list + conversation scrollbars 6px, stepper arrows hidden; pagination adds
  a cooldown + overflow-anchor:none so scrolling up no longer sticks/jumps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:11:02 +05:30
parent 346361da8a
commit f52d54a093
5 changed files with 55 additions and 36 deletions
+1
View File
@@ -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 */ } 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). // 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 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.) // 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 */ } 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 // BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
+45 -30
View File
@@ -149,8 +149,9 @@
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;} .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;}
.chatlist:hover{scrollbar-color:#c7d0dd transparent;} .chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:7px;} .chatlist::-webkit-scrollbar{width:6px;}
.chatlist::-webkit-scrollbar-button{display:none;height:0;width:0;} .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::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;} .chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */ /* 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-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;} .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;} .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{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{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: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;} .mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;}
/* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */ /* 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;} .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{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;} .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
/* message thread */ /* 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{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.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;} .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 .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 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);} .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 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,.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-close{right:18px;}
.lightbox .lb-dl{right:74px;} .lightbox .lb-dl{right:74px;}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);} .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 img{max-width:82vw;}
.lightbox .lb-nav:hover{background:rgba(255,255,255,.3);} .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-prev{left:18px;} .lightbox .lb-next{right:18px;} .lightbox .lb-nav:hover{background:rgba(255,255,255,.34);}
@media(max-width:560px){ .lightbox .lb-nav{width:42px;height:42px;} .lightbox .lb-prev{left:6px;} .lightbox .lb-next{right:6px;} } .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{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{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);} .composer-box:focus-within{border-color:var(--blue);}
@@ -440,6 +441,9 @@
.convo{position:relative;} .convo{position:relative;}
.bubble{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 .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 /* 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 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. */ 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 button:hover{background:var(--blue-soft);}
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;} .msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
/* #1 Forward: message selection mode + target picker */ /* #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{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{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-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-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{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);} .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-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{display:flex;align-items:center;gap:.6rem;padding:.45rem .5rem;border-radius:10px;cursor:pointer;border:1px solid transparent;}
.fwd-item:hover,.fwd-item.sel{background:var(--blue-soft);} .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-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{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;} .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);} .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);} .modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */ /* 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-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;} .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;} .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);} .bzc-cancel{background:var(--blue-soft);color:var(--blue);}
.bz-confirm .bzc-cancel:hover{background:#dbe6fa;} .bzc-cancel:hover{background:#dbe6fa;}
.bz-confirm .bzc-ok{background:var(--blue);color:#fff;} .bzc-ok{background:var(--blue);color:#fff;}
.bz-confirm .bzc-ok:hover{background:var(--blue-d);} .bzc-ok:hover{background:var(--blue-d);}
.bz-confirm .bzc-ok.danger{background:var(--red);} .bzc-ok:disabled{opacity:.5;cursor:not-allowed;}
.bz-confirm .bzc-ok.danger:hover{background:#991b1b;} .bzc-ok.danger{background:var(--red);}
.bzc-ok.danger:hover{background:#991b1b;}
/* "typing…" indicator inside the conversation, just above the composer. */ /* "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{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;} .typing-row.show{display:flex;}
@@ -807,9 +821,9 @@
</style> </style>
</head> </head>
<body> <body>
<script src="/icons.js?v=4"></script> <script src="/icons.js?v=5"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-07-batch52';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-07-batch53';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load // Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5) // (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script> function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -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 // ---- 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. ---- // previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ----
const PAGE=500; 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):''); } 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(){ async function loadOlder(){
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return; if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
@@ -1050,7 +1064,7 @@ async function loadOlder(){
renderThread(true); // keep scroll (don't jump to bottom) renderThread(true); // keep scroll (don't jump to bottom)
if(box) box.scrollTop = box.scrollHeight - prevH + prevTop; // anchor on the same message if(box) box.scrollTop = box.scrollHeight - prevH + prevTop; // anchor on the same message
} else { _hasMoreOlder=false; } } 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). // 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++; } } 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=''; let seen='';
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">' return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
+ sender + quote + att + renderMsgBody(m) + pollHTML(m) + sender + (m.fwd_from?'<div class="fwd-label">'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from <b>'+pEsc(m.fwd_from)+'</b>'):'')+'</div>':'') + quote + att + renderMsgBody(m) + pollHTML(m)
+ (m.deleted?'':'<div class="msg-actions">' + (m.deleted?'':'<div class="msg-actions">'
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>' + '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ ((m.body||m.attachment)&&!m.poll?'<button class="fwd-btn" data-fwd="'+pEsc(m.id)+'" title="Forward">'+ic('arrowRight',14)+'</button>':'') + ((m.body||m.attachment)&&!m.poll?'<button class="fwd-btn" data-fwd="'+pEsc(m.id)+'" title="Forward">'+ic('arrowRight',14)+'</button>':'')
@@ -1820,9 +1834,9 @@ function openLightbox(src){
let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0; 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'; const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>' ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>'
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('arrowLeft',26)+'</button>':'') +(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>':'')
+'<img src="'+pEsc(src)+'" alt="">' +'<img src="'+pEsc(src)+'" alt="">'
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('arrowRight',26)+'</button>':'') +(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>':'')
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>'; +'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>';
document.body.appendChild(ov); document.body.appendChild(ov);
const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl'); 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. // Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
function onMsgsScroll(){ function onMsgsScroll(){
const box=document.getElementById('msgs'); if(!box) return; 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 nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid'; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
const fd=document.getElementById('floatDate'); if(!fd) return; 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). // Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document.addEventListener('keydown',(e)=>{ document.addEventListener('keydown',(e)=>{
if(e.key!=='Escape') return; 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'); const ovs=document.querySelectorAll('.modal-ov');
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; } 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 if(_selMode){ exitSelect(); e.preventDefault(); e.stopPropagation(); return; } // #1: leave forward-selection
+2
View File
@@ -40,6 +40,8 @@
pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>', pencil: '<path d="M21.17 6.83a2.83 2.83 0 0 0-4-4L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/><path d="m15 5 4 4"/>',
chevronDown: '<path d="m6 9 6 6 6-6"/>', chevronDown: '<path d="m6 9 6 6 6-6"/>',
chevronUp: '<path d="m18 15-6-6-6 6"/>', chevronUp: '<path d="m18 15-6-6-6 6"/>',
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
chevronRight: '<path d="m9 18 6-6-6-6"/>',
star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>', star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>', layoutDashboard:'<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
+3 -3
View File
@@ -189,9 +189,9 @@ const webhooks = {
}; };
const messages = { const messages = {
send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) => 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) VALUES (?,?,?,?,?,?,?,?,?,?,?)') 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), .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), 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), 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), allByAttachment: (attachmentId) => db.prepare('SELECT sender_id, recipient_id, conversation_id FROM messages WHERE attachment_id=? AND deleted=0').all(attachmentId),
+4 -3
View File
@@ -11,7 +11,7 @@ const PUSH = require('./push');
const MSG_MAX = 4000; const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } }; const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__'; 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; } 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. // 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; } 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; } else { tid = R.users.resolve(tid); if (!tid || !R.users.inTenant(tid, u.team_id)) continue; }
for (const m of srcs) { for (const m of srcs) {
const nid = A.token(16); 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 }); const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards
else R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id }); 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 dto = buildMsgDTO(R.messages.byId(nid), names, u.id);
const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; 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 (_) {} } } if (isGroup) { for (const mid of R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }