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
+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: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 @@
</style>
</head>
<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>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
// (emojis stay as plain Unicode). (#5)
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
// 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 '<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">'
+ '<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>':'')
@@ -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='<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="">'
+(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>';
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
+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"/>',
chevronDown: '<path d="m6 9 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"/>',
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"/>',