revert swipe-back to instant release; simplify in-chat video to native <video controls>

Swipe-back: the finger-following version is dropped (parked for the future) per request —
back to the reliable release-triggered swipe (rightward edge release runs bzcBack's slide).

Video: the custom download->play overlay caused layout 'dancing' on load and flaky
multi-tap playback. Replaced with a plain native <video controls playsinline preload=
metadata> (poster via #t=0.1) at a fixed box size — poster + OS play button, plays inline
on one tap, streams once and is cached (no re-downloads). build batch156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 21:57:19 +05:30
parent 85a1b9d2ca
commit 92d41e6324
+16 -76
View File
@@ -661,16 +661,10 @@
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;}
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* In-chat video: a masked poster with a centre button that is DOWNLOAD until loaded, then PLAY (loads once,
then plays inline). Avoids the old "download link re-downloads on every tap" behaviour. */
.att-vid{position:relative;width:min(240px,72vw);aspect-ratio:16/11;border-radius:10px;overflow:hidden;background:#12182a;margin:.15rem 0;display:block;cursor:pointer;}
.att-vid video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#000;display:block;}
.att-vid.playing video{object-fit:contain;}
.att-vid-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:54px;height:54px;border:none;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;display:grid;place-items:center;cursor:pointer;z-index:2;box-shadow:0 2px 12px rgba(0,0,0,.45);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);}
.att-vid.loading .att-vid-play{animation:ptrspin .8s linear infinite;}
.att-vid-meta{position:absolute;left:0;right:0;bottom:0;padding:16px 8px 6px;color:#fff;font-size:.72rem;line-height:1.1;background:linear-gradient(transparent,rgba(0,0,0,.6));display:flex;align-items:center;gap:5px;z-index:2;pointer-events:none;}
.att-vid-meta span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.att-vid.playing .att-vid-play,.att-vid.playing .att-vid-meta{display:none;}
/* In-chat video: a plain native <video controls> — shows a poster frame + the OS play button, plays inline
on one tap (playsinline), streams once and is cached by the browser (no re-download on replay). Fixed box
size so loading the poster can't reflow / "dance" the thread. */
.att-vid{width:min(260px,74vw);max-height:340px;border-radius:10px;display:block;margin:.15rem 0;background:#000;object-fit:contain;}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */
@@ -1168,7 +1162,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-22-batch155';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-22-batch156';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
@@ -2016,7 +2010,7 @@ function bubbleHTML(m){
const A = m.attachment;
const att = A ? (
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
: A.isVideo ? '<div class="att-vid" data-id="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'"><video playsinline preload="none"></video><button class="att-vid-play" data-state="dl" title="Download to play" aria-label="Download to play">'+ic('download',22)+'</button><div class="att-vid-meta">'+ic('video',13)+'<span>'+pEsc(A.name)+' · '+fmtSize(A.size)+'</span></div></div>'
: A.isVideo ? '<video class="att-vid" src="/files/'+pEsc(A.id)+'#t=0.1" controls playsinline preload="metadata" title="'+pEsc(A.name)+'"></video>'
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
@@ -2952,23 +2946,6 @@ async function openConvo(kind,id){
if(ml && ml.href){ try{ const u=new URL(ml.href, location.origin); const code=u.searchParams.get('meet');
if(u.origin===location.origin && code && /^\d{6}$/.test(code)){ e.preventDefault(); switchTab('meeting'); enterMeeting(code); return; } }catch(_){} }
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
// In-chat video: 1st tap DOWNLOADS (loads) the file once → button becomes PLAY; 2nd tap plays inline. Once
// playing, native controls take over (this handler ignores it via the .playing guard), so no re-downloads.
const vbox=e.target.closest('.att-vid');
if(vbox && !vbox.classList.contains('playing')){
const v=vbox.querySelector('video'), btn=vbox.querySelector('.att-vid-play'), st=btn&&btn.dataset.state;
if(st==='dl'){
if(vbox.classList.contains('loading')) return;
vbox.classList.add('loading'); if(btn) btn.innerHTML=ic('video',22);
v.src='/files/'+vbox.dataset.id; v.preload='auto';
v.addEventListener('loadeddata', ()=>{ vbox.classList.remove('loading'); if(btn){ btn.dataset.state='ready'; btn.innerHTML=ic('play',26); btn.title='Play'; } }, {once:true});
v.addEventListener('error', ()=>{ vbox.classList.remove('loading'); if(btn){ btn.dataset.state='dl'; btn.innerHTML=ic('download',22); } toast('Could not load video'); }, {once:true});
try{ v.load(); }catch(_){}
} else if(st==='ready'){
vbox.classList.add('playing'); v.setAttribute('controls',''); v.play().catch(()=>{});
}
return;
}
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
@@ -5045,57 +5022,20 @@ function bzcBack(){
}
try{ history.pushState(null,'',location.href); }catch(_){}
window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } });
// #4: left-edge SWIPE-back. In an open conversation the pane FOLLOWS your finger and reveals the chat list
// behind it (iOS-style); past ~35% of the width it completes the back, otherwise it springs back. It reuses
// the SAME layout showWelcome()'s close slide uses (body.chat-dragging: content z-index:2 at translateX(0),
// the list .chatcol absolute behind at z-index:0), so the pane tracks the finger from the correct on-screen
// origin — the earlier version's "pane one screen-width off" bug came from a different setup. When a popup /
// in-conversation search is open instead, a rightward edge release just closes that (release-based bzcBack).
// #4: left-edge SWIPE-back — INSTANT (release-triggered). A drag that starts at the very left edge and
// releases clearly rightward runs the back (bzcBack → showWelcome()'s push-slide close). The finger-following
// version is PARKED (dropped for now) — this reliable release version is what ships. Only arms at the edge so
// it can't fight message scrolling.
(function(){
let sx=0, sy=0, edge=false, mode='', dragging=false, content=null, W=0;
const mobile=()=>window.matchMedia('(max-width:760px)').matches;
function reset(){ edge=false; mode=''; dragging=false; content=null; }
let sx=0, sy=0, edge=false;
window.addEventListener('touchstart',(e)=>{
reset();
if(e.touches.length!==1) return;
const t=e.touches[0];
if(t.clientX>24 || !mobile()) return; // must start at the very left edge, on mobile
const overlay=document.querySelector('.modal-ov');
const sh=document.getElementById('convoSearchHead'); const searchOpen=sh && sh.style.display!=='none';
if(overlay || searchOpen){ edge=true; mode='back'; sx=t.clientX; sy=t.clientY; return; } // release-based close
if(!selected) return; // only drag when a conversation is open
edge=true; mode='drag'; sx=t.clientX; sy=t.clientY; W=window.innerWidth||360;
if(e.touches.length!==1){ edge=false; return; }
const t=e.touches[0]; edge = t.clientX<=24; sx=t.clientX; sy=t.clientY;
}, {passive:true});
window.addEventListener('touchmove',(e)=>{
if(!edge || mode!=='drag' || e.touches.length!==1) return;
const t=e.touches[0], dx=t.clientX-sx, dy=t.clientY-sy;
if(!dragging){
if(Math.abs(dy)>Math.abs(dx) && Math.abs(dy)>10){ reset(); return; } // vertical → let the messages scroll
if(dx<8) return; // wait for a clear rightward intent
dragging=true;
renderChats(searchVal()); // populate the list underneath before revealing it
document.body.classList.add('chat-dragging'); // content on top (z:2), list absolute behind (z:0)
content=document.querySelector('.shell > .content');
if(content) content.style.transition='none';
}
if(content) content.style.transform='translateX('+Math.max(0,dx)+'px)';
if(e.cancelable) e.preventDefault(); // stop the message list scrolling under the drag
}, {passive:false});
window.addEventListener('touchend',(e)=>{
if(!edge) return;
const m=mode, wasDragging=dragging, c=content;
const t=e.changedTouches&&e.changedTouches[0];
const dx=t?(t.clientX-sx):0, dy=t?(t.clientY-sy):0;
reset();
if(m==='back'){ if(dx>60 && Math.abs(dy)<50) bzcBack(); return; }
if(!wasDragging) return; // never became a horizontal drag
if(dx > Math.min(140, W*0.35)){ // far enough → complete the back
if(c){ c.style.transition='transform .18s cubic-bezier(.4,0,.7,.4)'; requestAnimationFrame(()=>{ c.style.transform='translateX(100%)'; }); }
setTimeout(()=>{ if(c){ c.style.transition=''; c.style.transform=''; } showWelcome(true); }, 185);
} else { // not far enough → spring back to the conversation
if(c){ c.style.transition='transform .18s ease-out'; requestAnimationFrame(()=>{ c.style.transform='translateX(0)'; }); }
setTimeout(()=>{ if(c){ c.style.transition=''; c.style.transform=''; } document.body.classList.remove('chat-dragging'); }, 185);
}
if(!edge) return; edge=false;
const t=e.changedTouches&&e.changedTouches[0]; if(!t) return;
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
}, {passive:true});
})();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble