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:
+34
-5
@@ -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
@@ -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]) || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user