fix(mobile): clamp reply-quote (balloon), clip swipe-reply overflow, animated image pager

- Reply quote: was showing the FULL quoted body untruncated -> DIV.quote rendered 1571px, ballooning the
  bubble + leaving the chat not-at-bottom. Clamp to 2 lines (-webkit-line-clamp) + collapse newlines.
- Swipe-to-reply: translateX(+72px) on a right-edge bubble pushed past the viewport -> a horizontal scroll
  bar. .convo-msgs overflow-x:clip stops it.
- Image pager (#8): replace instant src swap with a real slide — current image slides out, next slides in
  from the opposite side; clamp at first/last (no wrap -> no blank slide past the end).
Build marker -> 2026-07-18-batch118.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 22:32:10 +05:30
parent 1f5b4bd42c
commit da3d44cbb7
+22 -6
View File
@@ -309,7 +309,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-x:clip;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);}
/* word-break:normal + overflow-wrap:break-word: still emergency-breaks an over-long word/URL to fit,
but keeps min-content = longest WORD (not 1 glyph) so a bubble can never collapse to ~0 width and
wrap text into hundreds of lines during a transient 0-width layout frame (the "stretch" balloon). */
@@ -525,7 +525,9 @@
/* chat: reply + emoji */
.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;}
/* Clamp the reply preview to 2 lines — a long quoted message must not stretch the bubble to 1000s of px
(device log: DIV.quote rendered 1571px). white-space:normal collapses the quoted text's newlines. */
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-height:3.2em;white-space:normal;word-break:normal;overflow-wrap:break-word;}
.upd-banner{position:fixed;left:50%;bottom:16px;transform:translateX(-50%) translateY(70px);z-index:9500;display:none;align-items:center;gap:.5rem;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .5rem .5rem .9rem;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.85rem;font-weight:600;transition:transform .25s ease;}
.upd-banner.show{display:flex;transform:translateX(-50%) translateY(0);}
.upd-banner button{border:none;background:var(--brand);color:var(--blue-d);border-radius:999px;padding:.35rem .8rem;font-weight:700;cursor:pointer;}
@@ -1132,7 +1134,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-18-batch117';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-18-batch118';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2632,8 +2634,22 @@ function openLightbox(src){
},{passive:false});
img.addEventListener('touchend',()=>{ pinchD=0; });
const show=(i)=>{ if(!multi) return; idx=((i%gallery.length)+gallery.length)%gallery.length; const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); };
const show=(i)=>{ if(!multi) return; idx=Math.max(0, Math.min(gallery.length-1, i)); const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); }; // clamp (no wrap → no blank slide past the last)
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
// #8: animated pager — the current image slides fully out in the swipe direction, then the next/prev
// image slides in from the OPPOSITE side (instead of an instant src swap). Stops at the first/last.
const pagerGo=(dir)=>{ // dir:+1 next (out to the left), -1 prev (out to the right)
const target = idx + (dir>0?1:-1);
if(!multi || target<0 || target>=gallery.length){ img.style.transition='transform .18s'; img.style.transform=''; return; } // at an edge → spring back
const W=(window.innerWidth||400); let done=false;
const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish);
show(target); // load the target image; resetZoom clears its transform
img.style.transition='none'; img.style.transform='translateX('+(dir>0?W:-W)+'px)'; // stage it off the opposite edge
requestAnimationFrame(()=>{ img.style.transition='transform .2s cubic-bezier(.3,.72,.3,1)'; img.style.transform='translateX(0)'; setTimeout(()=>{ if(img){ img.style.transition=''; img.style.transform=''; } ov.style.background=''; }, 220); });
};
img.style.transition='transform .16s ease-out'; img.style.transform='translateX('+(dir>0?-W:W)+'px)'; // current slides out
img.addEventListener('transitionend', finish); setTimeout(finish, 190); // fallback if transitionend misses
};
// #8: swipe DOWN (when not zoomed) to dismiss, like native photo viewers — drag follows the finger and
// fades the backdrop; release past a threshold closes, otherwise it springs back.
let dsy=0, dsx=0, dDrag=false;
@@ -2644,8 +2660,8 @@ function openLightbox(src){
},{passive:true});
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0, dx=t?(t.clientX-dsx):0;
if(z<=1 && dy>100 && dy>Math.abs(dx)){ close(); return; } // #8: swipe down = dismiss
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ show(dx<0?idx+1:idx-1); } // #8: swipe left/right = prev/next image
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200);
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ pagerGo(dx<0?1:-1); return; } // #8: swipe left/right = animated next/prev
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); // no nav → spring back
},{passive:true});
const onKey=(e)=>{
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }