fix(mobile): pull-refresh loader below the Dynamic Island; in-chat video player

Bug 1: .ptr-ind used top:8px so the pull-to-refresh spinner sat under the notch/Dynamic
Island. Now top:calc(var(--sat)+8px) clears the safe-area inset.

Bug 2: video attachments rendered as a plain download link that re-downloaded on every
tap. Server now sends isVideo/isAudio on message attachments; videos render as an in-chat
player — masked poster with a DOWNLOAD button that loads the file ONCE (preload=none ->
load on tap), then becomes a PLAY button; playing hands off to native inline controls, so
no repeat downloads. build batch155.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 21:48:09 +05:30
parent 3cc6ff4e54
commit 85a1b9d2ca
2 changed files with 35 additions and 6 deletions
+34 -5
View File
@@ -173,7 +173,7 @@
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;} .chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */ /* Pull-to-refresh indicator (mobile) */
/* Lives on the list's NON-scrolling parent, so it stays pinned at the top and is actually seen (#6). */ /* Lives on the list's NON-scrolling parent, so it stays pinned at the top and is actually seen (#6). */
.ptr-ind{position:absolute;top:8px;left:50%;width:34px;height:34px;margin-left:-17px;border-radius:50%;background:var(--card);box-shadow:0 3px 12px rgba(20,30,60,.22);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-44px);transition:opacity .12s;pointer-events:none;} .ptr-ind{position:absolute;top:calc(var(--sat) + 8px);left:50%;width:34px;height:34px;margin-left:-17px;border-radius:50%;background:var(--card);box-shadow:0 3px 12px rgba(20,30,60,.22);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-44px);transition:opacity .12s;pointer-events:none;} /* top offset clears the notch/Dynamic Island (var(--sat)) so the spinner isn't hidden behind it */
.ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;} .ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;}
.ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;} .ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;}
.ptr-ind img{display:block;} .ptr-ind.spin img{animation:ptrspin .8s linear infinite;} .ptr-ind img{display:block;} .ptr-ind.spin img{animation:ptrspin .8s linear infinite;}
@@ -661,6 +661,16 @@
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;} .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{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;} .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;}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);} .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;} .att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */ /* groups */
@@ -1158,7 +1168,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-22-batch154';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-22-batch155';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. // 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 // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -2003,9 +2013,11 @@ function bubbleHTML(m){
let quote=''; let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); const rb=isGifUrl(m.reply.body)?'🎞️ GIF':m.reply.body; quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(rb)+'</div>'; } if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); const rb=isGifUrl(m.reply.body)?'🎞️ GIF':m.reply.body; quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(rb)+'</div>'; }
const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':''; const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':'';
const att = m.attachment ? (m.attachment.isImage const A = m.attachment;
? '<img class="att-img" src="/files/'+pEsc(m.attachment.id)+'" data-img="/files/'+pEsc(m.attachment.id)+'" alt="'+pEsc(m.attachment.name)+'" title="Click to view">' const att = A ? (
: '<a class="att-file" href="/files/'+pEsc(m.attachment.id)+'" download="'+pEsc(m.attachment.name)+'">'+ic('file',15)+' <span>'+pEsc(m.attachment.name)+'</span> <span class="att-sz">'+fmtSize(m.attachment.size)+'</span></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 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')); 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). // DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
let rcpt=''; let rcpt='';
@@ -2940,6 +2952,23 @@ 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(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(_){} } 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; } 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 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 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; } const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
+1 -1
View File
@@ -101,7 +101,7 @@ function buildMsgDTO(m, names, userId){
} }
if (m.attachment_id) { if (m.attachment_id) {
const a = R.attachments.byId(m.attachment_id); const a = R.attachments.byId(m.attachment_id);
if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || '') }; if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') };
} }
if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); }
if (m.msg_type) d.byName = (names && names[m.sender_id]) || ''; if (m.msg_type) d.byName = (names && names[m.sender_id]) || '';