From d0f93555537203c3acfb45bca0db71903797ccc0 Mon Sep 17 00:00:00 2001 From: sravan Date: Sat, 11 Jul 2026 14:44:58 +0530 Subject: [PATCH] feat: close-to-tray, meeting lobby, speaker select, link expiry, mobile RC touch (0.1.14/batch72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit General #1/#2 (closed-app notifications): the desktop app now CLOSES TO TRAY instead of quitting, keeping its chat WebSocket alive so calls/messages still notify. Tray icon + menu (Open / Quit), single-instance lock, first-close hint. Guest #4 (lobby/admit): meetings can require the host to admit guests joining by link. Setting on the schedule form ("Guests must be admitted by the host", default on) + ad-hoc default. Guests wait on a "waiting to be let in" screen; the host gets an Admit/Deny prompt; auto-cleanup on leave. Logged-in members always join directly. Guest #5 (speaker): headphones/speaker output picker in the meeting (setSinkId), remembered and applied to every tile. Guest #3 (link expiry): guest link/token dies ~2h after a scheduled meeting's end (HTTP 410) with a clear message; live-room links expire when the room empties. RC #4 (mobile): touch→mouse mapping so a phone/tablet viewer can control (tap=click, drag=move). Uses the same letterbox-correct coordinate mapping. Co-Authored-By: Claude Opus 4.8 --- desktop/main.js | 54 ++++++++++++++++++++++++-- desktop/package.json | 2 +- desktop/tray.ico | Bin 0 -> 25843 bytes server/db.js | 3 ++ server/public/connect.html | 5 +++ server/public/home.html | 65 +++++++++++++++++++++++++++++--- server/repos.js | 12 +++--- server/routes.js | 24 ++++++++---- server/signaling.js | 75 ++++++++++++++++++++++++++++++++----- 9 files changed, 208 insertions(+), 32 deletions(-) create mode 100644 desktop/tray.ico diff --git a/desktop/main.js b/desktop/main.js index 72b4f4e..394c635 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -8,7 +8,7 @@ // - external links open in the user's browser, not inside the app // // Server origin is configurable so the same build works against prod or a dev server. -const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, ipcMain, nativeImage, Notification } = require('electron'); +const { app, BrowserWindow, session, desktopCapturer, shell, Menu, MenuItem, Tray, ipcMain, nativeImage, Notification } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -190,6 +190,30 @@ const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote. let win; let splash; +let tray = null; +let isQuitting = false; // true only during a real Quit (tray menu / before-quit) — otherwise close = hide to tray + +// Close-to-tray: closing the window HIDES it instead of quitting, so the app keeps running in the +// background with its chat WebSocket alive. That's what lets call/message notifications still fire when +// the window is "closed" (General #1/#2) — a fully-quit Electron app gets no push. The tray icon + menu +// bring it back or quit for real. +function createTray() { + if (tray) return; + try { + let img = nativeImage.createFromPath(path.join(__dirname, 'tray.ico')); + if (img.isEmpty()) img = nativeImage.createFromPath(path.join(process.resourcesPath || __dirname, 'tray.ico')); + tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img); + tray.setToolTip('Biz Connect'); + const showApp = () => { if (!win) return createWindow(); if (win.isMinimized()) win.restore(); win.show(); win.focus(); }; + tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'Open Biz Connect', click: showApp }, + { type: 'separator' }, + { label: 'Quit', click: () => { isQuitting = true; app.quit(); } }, + ])); + tray.on('click', showApp); // single-click (Windows) + tray.on('double-click', showApp); + } catch (_) { tray = null; } +} // A tiny brand-blue splash (splash.html) shown while the web UI loads, so launch feels instant // and on-brand instead of a blank window. Closed as soon as the main window is ready to show. @@ -233,6 +257,18 @@ function createWindow() { win.once('ready-to-show', reveal); setTimeout(reveal, 12000); + // Close = hide to tray (keep running for notifications). First time, tell the user where it went. + let toldTray = false; + win.on('close', (e) => { + if (isQuitting) return; // real quit → let it close + e.preventDefault(); + win.hide(); + if (!toldTray && Notification.isSupported()) { + toldTray = true; + try { const n = new Notification({ title: 'Biz Connect is still running', body: 'It stays in the system tray so you keep getting calls & messages. Quit from the tray icon.' }); n.show(); } catch (_) {} + } + }); + // Open the landing page (same entry as the website): the "before login" screen with the // no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home. win.loadURL(SERVER_URL + '/'); @@ -376,12 +412,22 @@ function configureSession() { } catch (_) {} } +// Single-instance: a tray app must not spawn a second copy. If another launch happens, focus the +// existing window (restoring it from the tray) instead. +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on('second-instance', () => { if (win) { if (win.isMinimized()) win.restore(); win.show(); win.focus(); } }); +} +app.on('before-quit', () => { isQuitting = true; }); + app.whenReady().then(() => { configureSession(); createSplash(); createWindow(); + createTray(); // keep the app reachable while its window is hidden to tray Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); else if (win) { win.show(); win.focus(); } }); // Check for shell updates on launch, then every 6 hours. Only in packaged builds. if (app.isPackaged && autoUpdater) { // #3: surface update progress to the web UI so the user can SEE an update is downloading / @@ -405,4 +451,6 @@ app.whenReady().then(() => { } }); -app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +// With close-to-tray the window is HIDDEN, not destroyed, so this normally won't fire while the app is +// meant to keep running. Only quit here if we're actually quitting (belt-and-braces). +app.on('window-all-closed', () => { if (isQuitting && process.platform !== 'darwin') app.quit(); }); diff --git a/desktop/package.json b/desktop/package.json index 8791d25..f04de2b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "biz-connect-desktop", - "version": "0.1.13", + "version": "0.1.14", "description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture", "author": { "name": "BizGaze", diff --git a/desktop/tray.ico b/desktop/tray.ico new file mode 100644 index 0000000000000000000000000000000000000000..61ab853def7a8a0a823d7713881fb1bfc9f1fab7 GIT binary patch literal 25843 zcmagF1ymi)wk_Pa!^SPRTY?kZEx0?uU4y&3OK^g_yK8WF*FbQ0=kc9$-~Yci#=YW5uy>(ABc-JdTZP1#DAsVIGEe~w)K zVh7e=^Oo1XbyorPu?)_i!YoTkVKXF%ceV$h54e=H?{spuB8*J*&09#ejLS8tSGk?v znw=kRb4D44*RbYm28O2?AlCF2SmVH=(Ccd60?G$8fz+=FZogvpcb3gz4y%b3`DI& zze2F5A41^D8Dt2noRpxj6iw)Mxt<-o>_#QqG6SOb!2lxira}2pwN%nR^Vbld(4lHD zwfX&I_T*)F10rtk6c`v#BIC%CmL0b!O=syS>;V}$XY>@&o)>jF76kK#BM8*I%<9)Y zcG%EqvJn-3*5Wd?N&cOuzTE>YLqXcnO2|&<>CVf3TBWkScKd|%XAPy8%mHlhAB~a} zl@+NJ()0gsH1R|C0i1tnqAg}=)raukX(B|*zcsN!Mc+zE1%vN?MCx zilu-f*t9YYtTe20so!t#4z8(jtxR88-cy`+tT}89b8geM}=Y)fk1mm z%mcWF(j91EXeHurLM;Y}ko!rAVH&fw`6IJ|qa~I?5+XylL1MA%s9^huVYxLTHBEX> z4L*`VF@Vil%oG))R*~Ic%hNQ@O@B&9yE&JA&EEFWfD1t+t`|%nU zK<^+ejg?3&vd(Dc2IK4M>PD$f2Tubh9}^T{OtH~DbH&RYFj-ZBeEPy=&s}0knox|m zfCEB9ZS+})FP(8aS`;4i!TUrA1Nf+D9L$??*toV^5L zV_@r9Xi|Bk|hN{)2mnL*#ztd@jwH)ha<8ql2+ zO|&Kplbd*D)8!C<2(E<}{9GwUb{WF+IXIM4-B2LO{4>oxjboltzqF*RM@5zg!xJWS^J2Rew zbh7)aiZO?+%vX4AG!+aA46XziS=Dzo#Tk!zUaUo)N~nYaCLyEGMH@cGW_ot4TUChG z-e-fqB6MSv>;&+YbQneMjMe%}k4hD7jkuV~4oP7w1;|+mGgB=1y`Oe;#(M@@Ba*9{ zyyp*{Igx~6d{gt?Or4!wMx~zlmYfUsW47+R(te4H&*U0ZaeL-lzP743S*o6`ufNQV z7;kzn@;+>)pzY@UUDdUOWnWk=1Onri8UHG>gVd4)WzIc++(g_TFYusRm7}mP#E4T_ zZ-pyL^55&ipFU{A+OWhz-SJMvcIQNh{J9&|;&;zG45~=S2OlL&{lauH8TFfBeLGL* zXo`zf#P|z>Mw^<{w~$JV8RzQji<+~+WPVi@4QSwZUfdg( z5SkI5nj%$ggfb^*52Sz!hw%@iB9exsM#Yy>&IJd{UqA_>rS%gCB%ThGkDM{60g3j3 zHr;VFL@T?ze<0g!z>619oPjZDO^w7Enwn%iHFGY~6{O7esvj}d6O z&OsGPbiHu}b7Gj{FbN^gg_fT*YR%uxSS*<8#2#`2%z12�xAIoBK+8;1}rAmxNFPbFa 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 F{{R~>c!U4| literal 0 HcmV?d00001 diff --git a/server/db.js b/server/db.js index 48961c5..96a1ac5 100644 --- a/server/db.js +++ b/server/db.js @@ -228,6 +228,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) // External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed // guest join link instead of an in-app invite. try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ } +// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is +// treated as "require approval" (safe default) by the signaling layer. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } catch (e) { /* exists */ } try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ } // When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so // any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor diff --git a/server/public/connect.html b/server/public/connect.html index 34805d1..a69a3d4 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -409,6 +409,11 @@ video.addEventListener('contextmenu',e=>e.preventDefault()); function rcTyping(){ const a=document.activeElement; return a && (a.tagName==='INPUT'||a.tagName==='TEXTAREA'); } document.addEventListener('keydown',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keydown',key:e.key,code:e.code}); }); document.addEventListener('keyup',e=>{ if(!video||video.style.display!=='block'||rcTyping()) return; e.preventDefault(); send({kind:'keyup',key:e.key,code:e.code}); }); +// Mobile viewer (#4): map touch → mouse so a phone/tablet can control too. Tap = move+click; drag = move. +const relT=(t)=>{ const c=contentRect(); return {x:Math.max(0,Math.min(1,(t.clientX-c.left)/c.width)), y:Math.max(0,Math.min(1,(t.clientY-c.top)/c.height))}; }; +video.addEventListener('touchstart',e=>{ if(!e.touches.length) return; e.preventDefault(); const p=relT(e.touches[0]); send({kind:'mousemove',...p}); send({kind:'mousedown',button:0,...p}); },{passive:false}); +video.addEventListener('touchmove',e=>{ if(!e.touches.length) return; e.preventDefault(); const t=performance.now(); if(t-lm<16) return; lm=t; send({kind:'mousemove',...relT(e.touches[0])}); },{passive:false}); +video.addEventListener('touchend',e=>{ e.preventDefault(); const t=e.changedTouches&&e.changedTouches[0]; const p=t?relT(t):null; if(p) send({kind:'mousemove',...p}); send({kind:'mouseup',button:0,...(p||{})}); },{passive:false}); document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}; function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} diff --git a/server/public/home.html b/server/public/home.html index 46903ae..f85529d 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -627,6 +627,16 @@ .convo-call span{font-size:.84rem;} .call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;} .call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;} + /* #4 lobby request stacks above call invites, tinted blue; multiple stack upward */ + .call-invite.lobby-req{border-left-color:var(--blue);bottom:auto;top:18px;} + .call-invite.lobby-req .ci-ico{background:var(--blue-soft);color:var(--blue);} + /* #5 speaker output menu */ + .spk-menu{position:fixed;z-index:9700;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 34px rgba(20,30,60,.28);padding:.35rem;min-width:220px;max-width:300px;} + .spk-menu .spk-h{display:flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);padding:.35rem .5rem;} + .spk-menu .spk-opt{display:block;width:100%;text-align:left;border:none;background:transparent;font:inherit;font-size:.86rem;color:var(--ink);padding:.5rem .6rem;border-radius:8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .spk-menu .spk-opt:hover{background:var(--blue-soft);} + .spk-menu .spk-opt.on{color:var(--blue);font-weight:600;} + .spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;} .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;} .call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;} @@ -884,7 +894,7 @@ - @@ -3176,13 +3186,14 @@ function openScheduleModal(gid, editMtg){ +'' +'' +'' + +'' +'' +'
'; document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; document.getElementById('schClose').onclick=()=>ov.remove(); const $=id=>document.getElementById(id); - if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); } + if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); { const lb=$('schLobby'); if(lb) lb.checked=(editMtg.lobby!==false); } } else $('schDur').value='30'; const err=$('schErr'); const dateBtn=$('schDateBtn'), timeBtn=$('schTimeBtn'), cal=$('schCal'), timePop=$('schTimePop'); @@ -3243,8 +3254,9 @@ function openScheduleModal(gid, editMtg){ let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; } const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); try{ - if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence }); toast('Meeting updated'); } - else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); } + const lobby=!!($('schLobby')&&$('schLobby').checked); + if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence, lobby }); toast('Meeting updated'); } + else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence, lobby }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); } ov.remove(); switchTab('meeting'); loadScheduledMeetings(); }catch(e){ err.textContent=e.message||'Could not save'; } }; @@ -3255,6 +3267,24 @@ function renderCallConnecting(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='
Connecting to call…
'; } +// #4 Lobby — guest side: waiting for the host to admit them. +function renderLobbyWait(){ + const el=document.getElementById('meetingPanel'); if(!el) return; + el.innerHTML='
Waiting for the host to let you in…
You’ll join automatically once they admit you.
'; +} +// #4 Lobby — host side: a guest is asking to join. Stacks like the call-invite banners. +function showLobbyRequest(peerId, name){ + if(!peerId || document.getElementById('lob-'+peerId)) return; + try{ playPing(); }catch(_){} + const el=document.createElement('div'); el.className='call-invite lobby-req'; el.id='lob-'+peerId; + el.innerHTML=''+ic('users',18)+''+pEsc(name||'A guest')+'
wants to join the meeting
' + +'' + +''; + document.body.appendChild(el); + el.querySelector('.ci-join').onclick=()=>{ meetSend({type:'meeting-admit', peerId}); el.remove(); }; + el.querySelector('.ci-decline').onclick=()=>{ meetSend({type:'meeting-reject', peerId}); el.remove(); }; +} +function dismissLobbyRequest(peerId){ const el=document.getElementById('lob-'+peerId); if(el){ try{ el.remove(); }catch(_){} } } function renderCall(){ const el=document.getElementById('meetingPanel'); if(!el) return; el.innerHTML='
' @@ -3264,6 +3294,7 @@ function renderCall(){ + '' + '' + ((ME&&ME.guest)?'':'') // #12: transcript is a signed-in feature (guests can't download it) + + '' + '' + '
'; document.getElementById('meetMicBtn').onclick=toggleMic; @@ -3272,6 +3303,7 @@ function renderCall(){ document.getElementById('meetRecBtn').onclick=toggleRecord; { const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; } document.getElementById('meetPplBtn').onclick=toggleMeetPanel; + { const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=(e)=>{ e.stopPropagation(); openSpeakerMenu(sb); }; } document.getElementById('meetLeaveBtn').onclick=leaveMeeting; updateHostControls(); // Click another shared screen (in the side column) to bring it onto the stage. @@ -3279,13 +3311,30 @@ function renderCall(){ addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); } +// #5 Speaker / headphone output selection. Applies the chosen audiooutput device (setSinkId) to every +// meeting media element, and remembers it for new tiles. No-op where setSinkId isn't supported. +let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })(); +function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} } +function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); } +async function openSpeakerMenu(anchor){ + document.querySelectorAll('.spk-menu').forEach(x=>x.remove()); + let devs=[]; try{ devs=(await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='audiooutput'); }catch(_){} + const menu=document.createElement('div'); menu.className='spk-menu'; + if(!devs.length){ menu.innerHTML='
Your browser can’t switch audio output here. Set it in the OS sound settings.
'; } + else menu.innerHTML='
'+ic('headphones',13)+' Speaker
'+devs.map((d,i)=>'').join(''); + document.body.appendChild(menu); + const r=anchor.getBoundingClientRect(); menu.style.left=Math.max(8,Math.min(r.left, window.innerWidth-menu.offsetWidth-8))+'px'; menu.style.top=(r.top-menu.offsetHeight-8)+'px'; + menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=()=>{ meetSinkId=b.dataset.id; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){} applySinkAll(); menu.remove(); toast('Speaker set'); }); + const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor){ menu.remove(); document.removeEventListener('mousedown',close); } }; + setTimeout(()=>document.addEventListener('mousedown',close),0); +} function addTile(id, stream, label, muted){ const grid=document.getElementById('meetGrid'); if(!grid) return; let tile=document.getElementById('meet-tile-'+id); if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id; const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile tile.innerHTML='
'+pEsc(initials(label||'?'))+(av?'':'')+'
'+pEsc(label||'')+''; grid.appendChild(tile); } - const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream; + const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream){ v.srcObject=stream; applySink(v); } // #5: route audio to the chosen speaker const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended')); tile.classList.toggle('novid', !hasVid || (meetCamOff.get(id)===true && !meetSharers.has(id))); // camOff → avatar, UNLESS they're sharing a screen (#9: screen must show even with camera off) if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state @@ -3501,12 +3550,16 @@ async function onMeetMsg(e){ if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings // SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are // off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand. - if(SFU.on){ try{ await sfuConnect(); }catch(err){ const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } } + if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } } meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know refreshMeetPanel(); updateHostControls(); return; } + if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit + if(m.type==='meeting-rejected'){ toast('The host didn’t let you in.'); leaveMeeting(true); return; } + if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in + if(m.type==='meeting-lobby-cancel'){ dismissLobbyRequest(m.peerId); return; } // that guest left the lobby if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered if(m.type==='meeting-peer-joined'){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile diff --git a/server/repos.js b/server/repos.js index ec819c0..e650641 100644 --- a/server/repos.js +++ b/server/repos.js @@ -297,9 +297,9 @@ const attachments = { }; const scheduledMeetings = { - create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails }) => - db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)') - .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null), + create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails, lobby }) => + db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails,lobby) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, (lobby === false ? 0 : 1)), byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id), byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code), // Meetings a user can see: created by them, a member of the group, or an invited participant. @@ -315,9 +315,9 @@ const scheduledMeetings = { end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence - update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails }) => - db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, reminded=0 WHERE id=? AND team_id=?') - .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, id, teamId), + update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails, lobby }) => + db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, lobby=?, reminded=0 WHERE id=? AND team_id=?') + .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, (lobby === false ? 0 : 1), id, teamId), remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), }; diff --git a/server/routes.js b/server/routes.js index 35a3a12..4517fa4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -941,8 +941,18 @@ route('POST', '/api/meetings/guest-token', async (req, res) => { 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' }); + // #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end — + // after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while + // anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry. + const sched = (() => { + try { + const s = R.scheduledMeetings.byCode(rm); + if (!s || s.ended_at) return false; + const endBy = s.scheduled_at + ((s.duration_mins || 60) * 60000) + (2 * 3600000); + return Date.now() <= endBy; + } catch (_) { return false; } + })(); + if (!live && !sched) return json(res, 410, { error: 'This meeting link has expired or the meeting isn’t active.' }); // Reuse the guest's client id as the LiveKit identity so it matches the id they announced over // signaling (meeting-join guestId) — that mapping is how their media attaches to their tile. const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex')); @@ -1067,7 +1077,7 @@ route('POST', '/api/groups/remove', async (req, res) => { route('POST', '/api/meetings/schedule', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence } = await readBody(req); + const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence, lobby } = await readBody(req); const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); const when = Number(scheduledAt); @@ -1087,7 +1097,7 @@ route('POST', '/api/meetings/schedule', async (req, res) => { const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); const id = A.id(); - R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails }); + R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails, lobby: lobby !== false }); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t }); const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString(); if (groupId) { @@ -1145,7 +1155,7 @@ route('GET', '/api/meetings', async (req, res) => { scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code, groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null, createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id, - invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, + invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, lobby: s.lobby !== 0, durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), status, inCall: running ? live.size : 0, recordings: [], }; @@ -1251,7 +1261,7 @@ route('POST', '/api/meetings/cancel', async (req, res) => { route('POST', '/api/meetings/update', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); - const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence } = await readBody(req); + const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence, lobby } = await readBody(req); const s = id && R.scheduledMeetings.byId(id); if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' }); @@ -1262,7 +1272,7 @@ route('POST', '/api/meetings/update', async (req, res) => { const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100); - R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails }); + R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails, lobby: lobby !== false }); const label = new Date(when).toLocaleString(); // Email the updated details to external invitees (new + existing) so their link/time stays current. try { diff --git a/server/signaling.js b/server/signaling.js index df1aec3..d46a6a6 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -9,6 +9,38 @@ const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, r const W = require('./webhooks'); const CHAT = require('./chat'); +// ---- Meeting lobby (#4): guests joining by link can be held until the host admits them ---- +// roomLobby: ad-hoc room code -> whether guests need approval (set on meeting-create). +// lobbyPending: room code -> Map(peerId -> guest ws) awaiting admission. +const roomLobby = new Map(); +const lobbyPending = new Map(); +// A room requires host approval for GUESTS when explicitly set (ad-hoc) or by the scheduled meeting's +// `lobby` flag. Default: require approval (the "people with the link join directly" concern) unless the +// organizer chose "join directly". Logged-in tenant users are never held — only guests. +function meetingRoomRequiresApproval(room) { + if (roomLobby.has(room)) return !!roomLobby.get(room); + try { const s = R.scheduledMeetings.byCode(room); if (s) return s.lobby !== 0; } catch (_) {} + return true; +} +// Actually add a newcomer to the room: tell them who's here, tell others they arrived, and (if they're +// the host) hand them any guests already waiting in the lobby. Shared by direct joins and admissions. +function finishMeetingJoin(ws, room, peers) { + const peerId = ws._peerId, name = ws._peerName; + const hostUserId = roomHost.get(room); + const avatar = ws._meetingAvatar || null; + const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId); + ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) })); + for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); } + peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null }); + if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); } + const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); + if (isHost) { const pend = lobbyPending.get(room); if (pend) for (const [ppid, pws] of pend) { if (pws.readyState === 1) ws.send(JSON.stringify({ type: 'meeting-lobby-request', peerId: ppid, name: pws._peerName || 'Guest' })); } } +} +// Send a lobby request to whoever is hosting the room right now (if anyone). +function notifyHostsLobby(room, peers, hostUserId, peerId, name) { + for (const [, p] of peers) { if (p.uid && hostUserId && p.uid === hostUserId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-lobby-request', peerId, name })); } +} + function onConnection(ws, req) { const hb = setInterval(() => { if (ws.readyState === 1) { try { ws.ping(); } catch {} } else { clearInterval(hb); } @@ -60,6 +92,8 @@ function handle(ws, m, req) { let code; do { code = A.numericCode(6); } while (meetingRooms.has(code)); meetingRooms.set(code, new Map()); const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host + // Lobby preference for guests joining this ad-hoc room by link (default: require approval). + roomLobby.set(code, m.lobby === false ? false : true); ws.send(JSON.stringify({ type: 'meeting-created', room: code })); break; } @@ -86,15 +120,30 @@ function handle(ws, m, req) { let mUid = ju ? ju.id : null; if (!mUid && typeof m.guestId === 'string' && /^guest-[a-z0-9]+$/i.test(m.guestId)) mUid = m.guestId.slice(0, 64); ws._meetingUserId = mUid; // for per-user transcript ownership + SFU media mapping - const avatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics - const isHost = !!(ju && hostUserId && ju.id === hostUserId); - // Tell the newcomer who's already here (they initiate offers to existing peers)… - ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) })); - // …and tell existing peers a newcomer arrived. - for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); } - peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null }); - if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); } // #9: callee joined → mark 1:1 answered - const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing + ws._meetingAvatar = (ju && ju.avatar_url) ? ju.avatar_url : null; // for participant-tile profile pics + // LOBBY (#4): a GUEST (no session) waits for the host to admit them when the room requires approval. + // Logged-in tenant members always join directly. + if (!ju && meetingRoomRequiresApproval(room)) { + let pend = lobbyPending.get(room); if (!pend) { pend = new Map(); lobbyPending.set(room, pend); } + pend.set(peerId, ws); ws._lobbyRoom = room; + ws.send(JSON.stringify({ type: 'meeting-lobby-wait' })); + notifyHostsLobby(room, peers, hostUserId, peerId, name); + break; + } + finishMeetingJoin(ws, room, peers); + break; + } + // Host admits / rejects a guest waiting in the lobby. + case 'meeting-admit': + case 'meeting-reject': { + const room = ws._meetingRoom; const peers = room && meetingRooms.get(room); if (!peers) return; + const hostUserId = roomHost.get(room); + if (!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId)) return; // host only + const pend = lobbyPending.get(room); const gws = pend && pend.get(m.peerId); if (!gws) return; + pend.delete(m.peerId); if (gws) gws._lobbyRoom = null; + if (gws.readyState !== 1) return; + if (m.type === 'meeting-admit') finishMeetingJoin(gws, room, peers); + else gws.send(JSON.stringify({ type: 'meeting-rejected' })); break; } case 'meeting-signal': { @@ -327,6 +376,7 @@ function leaveMeeting(ws) { for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); } if (peers.size === 0) { meetingRooms.delete(room); + lobbyPending.delete(room); roomLobby.delete(room); // room gone → drop its lobby state try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps roomHost.delete(room); try { require('./calls').endCallByRoom(room); } catch (_) {} @@ -336,6 +386,13 @@ function leaveMeeting(ws) { function cleanup(ws) { const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change + // A guest waiting in the lobby dropped → remove their pending request and tell the host to clear it. + if (ws._lobbyRoom) { + const room = ws._lobbyRoom; const pend = lobbyPending.get(room); if (pend) pend.delete(ws._peerId); + const peers = meetingRooms.get(room); const hostUserId = roomHost.get(room); + if (peers) for (const [, p] of peers) { if (p.uid && hostUserId && p.uid === hostUserId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-lobby-cancel', peerId: ws._peerId })); } + ws._lobbyRoom = null; + } CHAT.unregister(ws); leaveMeeting(ws); if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call