fix(meetings/mobile): PTR, ringing tile, responsive bar, ⋮ toggle, add-people search (batch77)

#6 Pull-to-refresh genuinely broken: the indicator lived INSIDE the scroller (so it
   scrolled away unseen) and only became visible after ~96px while the trigger fired at
   70 — and preventDefault was deferred until the pull passed 10px, by which point
   iOS/Chrome had already claimed the gesture. Indicator now mounts on the list's
   non-scrolling parent with the branded loader, the gesture is claimed immediately, and
   the trigger is reachable. Works on Android + iOS.
#4 ⋮ More now TOGGLES (it used to remove then instantly rebuild, so it never closed).
#3 "Ringing…" is centred over the tile with the avatar/name dimmed behind it.
#2 Meeting bar fits the screen on mobile: one row of controls sized to the viewport,
   room code on its own line, safe-area padding.
#5 Search box in the in-call Add-people tab.
#1 Speaker control: setSinkId (the only web API for audio output) is NOT implemented in
   Android Chrome or iOS Safari — the OS owns the route there, and iOS forces loudspeaker
   whenever a mic track is live. Rather than ship a button that silently does nothing, it
   now only renders where output switching actually works. Real speaker/earpiece/Bluetooth
   switching on phones needs the native (Capacitor) audio plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:35:01 +05:30
parent e8c0b1889f
commit 5a138f5bc4
+70 -13
View File
@@ -168,9 +168,11 @@
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */
.ptr-ind{position:absolute;top:6px;left:50%;width:32px;height:32px;margin-left:-16px;border-radius:50%;background:var(--card);box-shadow:0 2px 10px rgba(20,30,60,.18);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-48px);transition:opacity .12s;pointer-events:none;}
/* 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 .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 img{display:block;} .ptr-ind.spin img{animation:ptrspin .8s linear infinite;}
@keyframes ptrspin{to{transform:rotate(360deg);}}
/* Enable-notifications prompt (esp. iOS PWA) */
.notif-prompt{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0px));z-index:2000;display:flex;align-items:center;gap:.7rem;width:max-content;max-width:min(460px,94vw);background:var(--blue);color:#fff;padding:.6rem .7rem .6rem 1rem;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.86rem;}
@@ -364,8 +366,14 @@
.meet-grid{flex:1;min-height:0;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:center;overflow:hidden;background:#0b1220;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */
.meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;}
.meet-tile .meet-sharing-self{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.72rem;color:#fff;background:rgba(37,99,235,.9);padding:.25rem;}
/* #3: "Ringing…" sits in the MIDDLE of the tile over a dimmed avatar/name, so it reads as the tile's
state instead of a caption tucked under the name. */
.meet-tile.waiting{border-color:#334155;}
.meet-tile.waiting .ringing{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.8rem;color:#cbd5e1;background:rgba(0,0,0,.35);padding:.35rem;animation:ringPulse 1.4s ease-in-out infinite;}
.meet-tile.waiting .meet-av,.meet-tile.waiting .nm{opacity:.32;filter:saturate(.6);}
.meet-tile.waiting .ringing{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;
font-size:.95rem;font-weight:700;letter-spacing:.02em;color:#fff;background:rgba(8,12,22,.45);z-index:3;
animation:ringPulse 1.4s ease-in-out infinite;}
.meet-tile.waiting .ringing svg{width:22px;height:22px;color:var(--brand,#FFC708);}
@keyframes ringPulse{0%,100%{opacity:.6;}50%{opacity:1;}}
.meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);}
.meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;}
@@ -723,6 +731,11 @@
.meet-bar .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */
.meet-bar .more-btn{display:inline-flex;}
.meet-bar .spk-btn{display:inline-flex;} /* speaker/earpiece/bluetooth toggle is MOBILE-only */
/* #2: fit the bar to the screen — controls on ONE row, sized to the width, no overflow/cramping. */
.meet-bar{flex-wrap:wrap;justify-content:center;gap:.45rem;padding:.5rem .5rem calc(.5rem + env(safe-area-inset-bottom,0px));}
.meet-bar .code{flex:1 1 100%;order:-1;margin-bottom:.15rem;}
.meet-bar .meet-ic{width:clamp(40px,13vw,52px);height:clamp(40px,13vw,52px);}
.meet-bar .meet-ic svg{width:20px;height:20px;}
}
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
@@ -989,7 +1002,7 @@
<body>
<script src="/icons.js?v=6"></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-13-batch76';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-14-batch77';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>
@@ -1250,12 +1263,45 @@ const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notificati
// a no-op on desktop. onRefresh is an async function.
function enablePullRefresh(el, onRefresh){
if(!el || el.__ptr) return; el.__ptr=true;
if(getComputedStyle(el).position==='static') el.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML='<span class="ptr-g"></span>'; el.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70;
el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>2||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true}); // >2 (not >0): iOS momentum leaves a sub-pixel scrollTop
el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false});
el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; });
// Mount the indicator on the list's NON-scrolling parent — inside the scroller it scrolled away with
// the content and was effectively invisible, which is why the pull looked like it did nothing (#6).
const host=el.parentElement||el;
if(getComputedStyle(host).position==='static') host.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind';
ind.innerHTML='<img src="/loaders/loader-ring.svg" width="20" height="20" alt="">';
host.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false;
const TRIGGER=58; // pull this far → refresh
const hide=()=>{ ind.style.opacity='0'; ind.style.transform='translateY(-44px)'; };
const draw=(d)=>{ ind.style.opacity=String(Math.min(d/26,1)); ind.style.transform='translateY('+(Math.min(d,72)-40)+'px)'; };
hide();
el.addEventListener('touchstart',(e)=>{
if(busy || e.touches.length!==1) return;
if(el.scrollTop>2){ pulling=false; return; } // only from the very top
startY=e.touches[0].clientY; pulling=true; dist=0;
},{passive:true});
el.addEventListener('touchmove',(e)=>{
if(!pulling||busy) return;
dist=e.touches[0].clientY-startY;
if(dist<=0){ hide(); return; } // moving up → let the list scroll normally
// Claim the gesture IMMEDIATELY. Deferring preventDefault until the pull was already ~10px let
// iOS/Chrome start their own overscroll first, and our handler then never got the gesture (#6).
if(e.cancelable) e.preventDefault();
draw(dist*0.6);
},{passive:false});
const finish=async()=>{
if(!pulling||busy){ hide(); return; }
pulling=false;
if(dist*0.6>=TRIGGER){
busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(12px)';
try{ await onRefresh(); }catch(_){}
await new Promise(r=>setTimeout(r,400)); // let the spinner be seen even on a fast refresh
ind.classList.remove('spin'); busy=false;
}
hide(); dist=0;
};
el.addEventListener('touchend', finish);
el.addEventListener('touchcancel', ()=>{ if(!busy){ pulling=false; hide(); dist=0; } });
}
async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} }
// ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the
@@ -2159,7 +2205,8 @@ function renderMeetPanel(){
const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase();
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && !hereUids.has(c.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id));
body='<div class="mp-invite-link">'+ic('link',14)+'<div class="mil-txt"><b>Invite by link</b><span>Anyone with the link can join — even without a Connect account.</span></div><button class="mp-copylink" id="mpCopyLink">'+ic('copy',13)+' Copy</button></div>'
+'<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'</div>')+'</div>'+(avail.length?'<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>':'');
+(avail.length>5?'<div class="gi-search" style="margin:.15rem .5rem .35rem"><input id="mpSearch" placeholder="Search people…" autocomplete="off"></div>':'') // #5: find people fast
+'<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'</div>')+'</div>'+(avail.length?'<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>':'');
} else {
body='<div class="mp-list">'+list.map(pp=>'<div class="mp-row"><span class="mini-av" style="background:'+avColor(pp.name)+'">'+pEsc(initials(pp.name))+'</span><span class="mn">'+pEsc(pp.name)+'</span>'+(isHostRow(pp)?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?'<span class="pp-screen" title="Sharing screen">'+ic('monitor',13)+'</span>':'')+(meetMuted.get(pp.id)?'<span class="pp-mute">'+ic('micOff',13)+'</span>':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'<button class="mp-makehost" data-id="'+pEsc(pp.id)+'" title="Make host">'+ic('crown',14)+'</button>':'')+'</div>').join('')+'</div>'
+(meetInvited.size?'<div class="mp-sec">Not joined yet</div><div class="mp-list">'+[...meetInvited.entries()].map(([uid,e])=>'<div class="mp-row waiting"><span class="mini-av" style="background:'+avColor(e.name)+'">'+pEsc(initials(e.name))+'</span><span class="mn">'+pEsc(e.name)+'</span><span class="pp-wait">'+ic('calendarClock',12)+' waiting…</span></div>').join('')+'</div>':'')
@@ -2172,6 +2219,7 @@ function renderMeetPanel(){
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
const cpl=p.querySelector('#mpCopyLink'); if(cpl) cpl.onclick=()=>copyMeetingLink();
{ const ms=p.querySelector('#mpSearch'); if(ms) ms.addEventListener('input',()=>{ const q=ms.value.trim().toLowerCase(); p.querySelectorAll('.mp-list .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); } // #5
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); meetAddInvited(ids); meetPanelTab='people'; toast(ids.length===1?'Waiting for them to join…':'Waiting for '+ids.length+' people to join…'); renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Add people to the current call (from the in-call bar).
@@ -3462,7 +3510,7 @@ function renderCall(){
+ '<button class="meet-ic host-only sec-btn" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
+ ((ME&&ME.guest)?'':'<button class="meet-ic sec-btn" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
+ '<button class="meet-ic sec-btn" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
+ '<button class="meet-ic spk-btn'+(meetSpeakerOn?'':' off')+'" id="meetSpkBtn" title="Speakerphone">'+ic(spkIcon(),20)+'</button>'
+ (canRouteAudio()?('<button class="meet-ic spk-btn'+(meetRoute==='speaker'?'':' off')+'" id="meetSpkBtn" title="'+pEsc(routeLabel())+'">'+ic(spkIcon(),20)+'</button>'):'') // #1: only where output switching actually works
+ '<button class="meet-ic sec-btn" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
+ '<button class="meet-ic more-btn" id="meetMoreBtn" title="More">'+ic('moreVertical',20)+'</button>'
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
@@ -3489,6 +3537,11 @@ function renderCall(){
// instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
// element; choosing a mic switches the live input device.
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
// #1: choosing the audio OUTPUT from the web needs setSinkId, which Android Chrome and iOS Safari do NOT
// implement — the OS owns the route there (and iOS forces loudspeaker while a mic track is live). Rather
// than ship a button that silently does nothing, we only show the speaker control where it really works.
// Real speaker/earpiece/Bluetooth switching on phones needs the native (Capacitor) build's audio plugin.
function canRouteAudio(){ try{ return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){
@@ -3550,7 +3603,11 @@ async function detectBtOutput(){
try{ if(navigator.mediaDevices && navigator.mediaDevices.addEventListener) navigator.mediaDevices.addEventListener('devicechange', ()=>{ detectBtOutput(); }); }catch(_){}
// #8e: on mobile the bar keeps only Mic/Camera/Speaker/End — the rest live behind this ⋮ menu.
function openMeetMore(anchor){
// #4: tapping ⋮ again must CLOSE it. Previously we removed the menu then rebuilt it immediately, so it
// looked like nothing happened.
const open=document.querySelector('.spk-menu.mm-menu');
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
if(open) return;
const items=[];
items.push({ic:'monitor', label:'Share screen', fn:toggleScreen});
if(meetIsHost) items.push({ic:'record', label:(meetRec?'Stop recording':'Record meeting'), fn:toggleRecord});
@@ -3558,7 +3615,7 @@ function openMeetMore(anchor){
items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat});
items.push({ic:'users', label:'Participants', fn:toggleMeetPanel});
// No "Audio devices" here (#8e): on mobile the speaker button itself cycles Speaker/Earpiece/Bluetooth.
const menu=document.createElement('div'); menu.className='spk-menu';
const menu=document.createElement('div'); menu.className='spk-menu mm-menu';
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
document.body.appendChild(menu);
const r=anchor.getBoundingClientRect();
@@ -3667,7 +3724,7 @@ let _dmCallWaiting=null;
function addWaitingTile(name, avatar){
const grid=document.getElementById('meetGrid'); if(!grid || document.getElementById('meet-tile-__waiting')) return;
const tile=document.createElement('div'); tile.className='meet-tile novid waiting'; tile.id='meet-tile-__waiting';
tile.innerHTML='<div class="meet-av" style="background:'+avColor(name||'?')+'">'+pEsc(initials(name||'?'))+(avatar?'<img src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div><span class="nm">'+pEsc(name||'')+'</span><div class="ringing">'+ic('phone',13)+' Ringing…</div>';
tile.innerHTML='<div class="meet-av" style="background:'+avColor(name||'?')+'">'+pEsc(initials(name||'?'))+(avatar?'<img src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div><span class="nm">'+pEsc(name||'')+'</span><div class="ringing">'+ic('phone',22)+'<span>Ringing…</span></div>';
grid.appendChild(tile);
}
function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); }