diff --git a/server/public/home.html b/server/public/home.html index 501954e..6a15531 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -405,6 +405,13 @@ .meet-panel .mp-scroll{flex:1;overflow:auto;} .meet-panel .mp-invite-sticky{position:sticky;bottom:.4rem;width:calc(100% - 1rem);margin:.5rem;background:var(--blue);color:#fff;box-shadow:0 -6px 14px rgba(255,255,255,.06);} /* #4: keep Invite reachable while the list scrolls */ .meet-panel .mp-list{padding:.4rem;} + .meet-panel .mp-invite-link{display:flex;align-items:center;gap:.55rem;margin:.5rem;padding:.6rem .7rem;background:var(--blue-soft);border:1px solid var(--line);border-radius:10px;} + .meet-panel .mp-invite-link svg{flex:0 0 auto;color:var(--blue);} + .meet-panel .mp-invite-link .mil-txt{flex:1;min-width:0;} + .meet-panel .mp-invite-link .mil-txt b{display:block;font-size:.82rem;color:var(--ink);} + .meet-panel .mp-invite-link .mil-txt span{font-size:.7rem;color:var(--muted);line-height:1.3;display:block;} + .meet-panel .mp-copylink{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;background:var(--blue);color:#fff;border:none;border-radius:8px;padding:.4rem .7rem;font-size:.78rem;font-weight:600;cursor:pointer;} + .meet-panel .mp-copylink:hover{filter:brightness(1.05);} .meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;} .meet-panel .chk:hover{background:#f6f8fb;} .meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;} @@ -553,6 +560,14 @@ .bubble .sender .snd-av{position:relative;width:20px;height:20px;flex:0 0 20px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:800;font-size:.58rem;overflow:hidden;} .bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .avatar.grp{border-radius:12px;font-size:1.15rem;} + /* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */ + body.guest-mode .rail, body.guest-mode #chatcol, body.guest-mode #hdrRight, body.guest-mode #navToggle, body.guest-mode .demo-note{display:none!important;} + body.guest-mode .content{width:100%;flex:1 1 auto;} + .guest-left{position:fixed;inset:0;background:#1F3B73;display:flex;align-items:center;justify-content:center;z-index:9900;padding:1rem;} + .guest-left .gl-card{background:#fff;border-radius:16px;padding:2rem 1.6rem;text-align:center;max-width:360px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);} + .guest-left .gl-card h2{margin:.2rem 0 .3rem;font-size:1.15rem;color:var(--ink);} + .guest-left .gl-card p{margin:0 0 1.1rem;color:var(--muted);font-size:.9rem;} + .guest-left .gl-card .bzc-ok{width:100%;justify-content:center;} .modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;} .modal{background:#fff;border-radius:16px;padding:1.4rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow-y:auto;} .modal h3{margin:0 0 .8rem;color:var(--blue);} @@ -860,7 +875,7 @@ - @@ -1945,6 +1960,16 @@ function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeet // invite is re-enabled for them. When they actually join (matched by user id), they're cleared. function meetInviteJoined(uid){ const e=meetInvited.get(uid); if(e){ if(e.timer) clearTimeout(e.timer); meetInvited.delete(uid); refreshMeetPanel(); } } function meetAddInvited(ids){ (ids||[]).forEach(uid=>{ const c=(CONTACTS||[]).find(x=>x.id===uid); const name=(c&&c.name)||'Someone'; const prev=meetInvited.get(uid); if(prev&&prev.timer) clearTimeout(prev.timer); const timer=setTimeout(()=>{ meetInvited.delete(uid); refreshMeetPanel(); }, 30000); meetInvited.set(uid,{name,timer}); }); refreshMeetPanel(); } +// Guest invite link for the current meeting — anyone with it can join (guest token), even without a +// Connect account. Shared from the meeting's Add-people panel and emailed to invitees (#4). +function meetingLink(){ return location.origin+'/home?meet='+encodeURIComponent(meetRoom||''); } +function copyMeetingLink(){ + if(!meetRoom){ toast('No active meeting'); return; } + const url=meetingLink(), done=()=>toast('Invite link copied'); + try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(url).then(done).catch(()=>fallbackCopy(url,done)); }catch(_){} + fallbackCopy(url,done); +} +function fallbackCopy(text,cb){ try{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); cb&&cb(); }catch(_){ prompt('Copy this link:', text); } } let meetPanelTab='people'; // 'people' | 'add' let _addPool=null; // cached candidates for "Add people" (group members for a group call) function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); } @@ -1965,7 +1990,8 @@ function renderMeetPanel(){ const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase())); 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?'':''); + body='' + +'
'+(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('')+'
':'') @@ -1977,6 +2003,7 @@ function renderMeetPanel(){ const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); }; 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 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). @@ -2948,7 +2975,10 @@ function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) return pid; } return null; } async function sfuConnect(){ const LK=await sfuLoadLib(); SFU.lib=LK; - const tk=await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential + // Guests (external link joiners, not signed in) get an unauthenticated guest token for this room. + const tk=(ME&&ME.guest) + ? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name }) + : await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential // adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so // LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a // camera track was also flowing → the screen showed blank / only when the camera was on, and was @@ -3545,9 +3575,22 @@ function leaveMeeting(forced){ if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; } meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); const ret=meetReturn; meetReturn=null; meetLeaving=false; + if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat else renderMeetingLobby(); } +// Guest post-call screen: they can't drop back into a chat/sidebar (there is none), so show a simple +// "you've left" card with a one-click Rejoin. +let _guestRoom=null; +function renderGuestLeft(){ + document.querySelectorAll('.guest-left').forEach(x=>x.remove()); + const el=document.createElement('div'); el.className='guest-left'; + el.innerHTML='
' + +'

You’ve left the meeting

Thanks for joining.

' + +(_guestRoom?'':'')+'
'; + document.body.appendChild(el); + const rj=el.querySelector('#glRejoin'); if(rj) rj.onclick=()=>{ location.href='/home?meet='+encodeURIComponent(_guestRoom); }; +} // ---------- Tabs (icon rail) ---------- // Chat and Meeting are in-shell panels; Share and Connect load in the center panel via @@ -3695,6 +3738,11 @@ async function doRegister(){ (async function(){ let me=null; try{ const r=await fetch('/api/me'); if(r.ok) me=await r.json(); }catch(_){} + // Guest meeting join: someone opened a shared meeting link (…/home?meet=CODE or /j/CODE). If they + // aren't signed in, run in GUEST mode — no chat/sidebar, just a name prompt then straight into the + // call. Signed-in users fall through and auto-join after their normal boot (handled below). + let _meet=null; try{ _meet=(new URLSearchParams(location.search)).get('meet'); }catch(_){} + if(_meet && /^\d{6}$/.test(_meet) && !me){ document.getElementById('loading').style.display='none'; return startGuestMeeting(_meet); } if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; } ME=me; document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me); @@ -3719,7 +3767,37 @@ async function doRegister(){ await loadSidebar(); renderChatPanel(); document.getElementById('loading').style.display='none'; // keep the branded splash up until the list is ready (no blank/stuck gap) } + // Signed-in user opened a meeting link → jump straight into that meeting. + if(_meet && /^\d{6}$/.test(_meet)){ try{ history.replaceState(null,'','/home'); }catch(_){} switchTab('meeting'); enterMeeting(_meet); } })(); + +// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity — +// reuses the full meeting client (LiveKit/mesh, tiles, mic/cam) with the chat/sidebar chrome hidden. +async function startGuestMeeting(code){ + document.body.classList.add('guest-mode'); + try{ history.replaceState(null,'','/home?meet='+code); }catch(_){} + const savedName=(()=>{ try{ return localStorage.getItem('bzc_guest_name')||''; }catch(_){ return ''; } })(); + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='guestJoin'; ov.style.zIndex='9900'; + ov.innerHTML=''; + document.body.appendChild(ov); + const inp=ov.querySelector('#guestName'), go=ov.querySelector('#guestGo'); + setTimeout(()=>{ try{ inp.focus(); }catch(_){} },50); + const join=async()=>{ + const nm=(inp.value||'').trim()||'Guest'; + try{ localStorage.setItem('bzc_guest_name', nm); }catch(_){} + ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null }; + _guestRoom=code; ov.remove(); + try{ await sfuInit(); }catch(_){} + switchTab('meeting'); enterMeeting(code); + }; + go.onclick=join; + inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); join(); } }); +} diff --git a/server/routes.js b/server/routes.js index a09aa24..9e15729 100644 --- a/server/routes.js +++ b/server/routes.js @@ -929,6 +929,23 @@ route('POST', '/api/meetings/token', async (req, res) => { json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email }); }); +// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a +// Connect account. Only minted for a room that is currently LIVE or a valid (not-ended) scheduled +// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id. +route('POST', '/api/meetings/guest-token', async (req, res) => { + if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' }); + const { room, name } = await readBody(req); + const rm = String(room || '').trim(); + if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' }); + const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })(); + const sched = (() => { try { const s = R.scheduledMeetings.byCode(rm); return !!(s && !s.ended_at); } catch (_) { return false; } })(); + if (!live && !sched) return json(res, 404, { error: 'meeting not found or not active' }); + const gid = 'guest-' + crypto.randomBytes(8).toString('hex'); + const gname = String(name || 'Guest').slice(0, 60); + const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true })); + json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname }); +}); + // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. route('POST', '/api/calls/decline', async (req, res) => { const u = currentUser(req);