From 5a138f5bc4269680664823615035b746a16f9fba Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 14 Jul 2026 12:35:01 +0530 Subject: [PATCH] =?UTF-8?q?fix(meetings/mobile):=20PTR,=20ringing=20tile,?= =?UTF-8?q?=20responsive=20bar,=20=E2=8B=AE=20toggle,=20add-people=20searc?= =?UTF-8?q?h=20(batch77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- server/public/home.html | 83 ++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index ac4a049..3bffc64 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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 @@ - @@ -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=''; 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=''; + 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='' - +'
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'
')+'
'+(avail.length?'':''); + +(avail.length>5?'':'') // #5: find people fast + +'
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'
')+'
'+(avail.length?'':''); } else { body='
'+list.map(pp=>'
'+pEsc(initials(pp.name))+''+pEsc(pp.name)+''+(isHostRow(pp)?''+ic('crown',11)+' Host':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?''+ic('monitor',13)+'':'')+(meetMuted.get(pp.id)?''+ic('micOff',13)+'':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'':'')+'
').join('')+'
' +(meetInvited.size?'
Not joined yet
'+[...meetInvited.entries()].map(([uid,e])=>'
'+pEsc(initials(e.name))+''+pEsc(e.name)+''+ic('calendarClock',12)+' waiting…
').join('')+'
':'') @@ -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(){ + '' + ((ME&&ME.guest)?'':'') // #12: transcript is a signed-in feature (guests can't download it) + '' - + '' + + (canRouteAudio()?(''):'') // #1: only where output switching actually works + '' + '' + ''; @@ -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)=>'').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='
'+pEsc(initials(name||'?'))+(avatar?'':'')+'
'+pEsc(name||'')+'
'+ic('phone',13)+' Ringing…
'; + tile.innerHTML='
'+pEsc(initials(name||'?'))+(avatar?'':'')+'
'+pEsc(name||'')+'
'+ic('phone',22)+'Ringing…
'; grid.appendChild(tile); } function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); }