feat: close-to-tray, meeting lobby, speaker select, link expiry, mobile RC touch (0.1.14/batch72)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:44:58 +05:30
parent bd488b3286
commit d0f9355553
9 changed files with 208 additions and 32 deletions
+51 -3
View File
@@ -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(); });
+1 -1
View File
@@ -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",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+3
View File
@@ -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
+5
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
</script>
+59 -6
View File
@@ -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 @@
<body>
<script src="/icons.js?v=5"></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-11-batch71';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-11-batch72';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>
@@ -3176,13 +3186,14 @@ function openScheduleModal(gid, editMtg){
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
+'<div class="email-chips" id="schEmailChips"></div>'
+'<label class="chk2 switch-row" style="margin-top:.6rem"><span>'+ic('users',15)+' Guests must be admitted by the host</span><span class="switch"><input type="checkbox" id="schLobby" checked><span class="slider"></span></span></label>'
+'<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">'+(editing?'Save changes':'Schedule & invite')+'</button>'
+'<div class="hint" id="schErr"></div></div>';
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='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
}
// #4 Lobby — guest side: waiting for the host to admit them.
function renderLobbyWait(){
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Waiting for the host to let you in…</div><div style="color:var(--muted);font-size:.85rem;margin-top:.3rem">Youll join automatically once they admit you.</div></div>';
}
// #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='<span class="ci-ico">'+ic('users',18)+'</span><span class="ci-txt"><b>'+pEsc(name||'A guest')+'</b><br>wants to join the meeting</span>'
+'<button class="ci-join">'+ic('check',16)+' Admit</button>'
+'<button class="ci-decline" title="Deny">'+ic('x',16)+' Deny</button>';
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='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
@@ -3264,6 +3294,7 @@ function renderCall(){
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
+ ((ME&&ME.guest)?'':'<button class="meet-ic" 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" id="meetSpkBtn" title="Speaker / headphones">'+ic('headphones',20)+'</button>'
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
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='<div class="spk-empty">Your browser cant switch audio output here. Set it in the OS sound settings.</div>'; }
else menu.innerHTML='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>'+devs.map((d,i)=>'<button class="spk-opt'+((d.deviceId===meetSinkId||(!meetSinkId&&d.deviceId==='default'))?' on':'')+'" data-id="'+pEsc(d.deviceId)+'">'+pEsc(d.label||('Output '+(i+1)))+'</button>').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='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; 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 isnt 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 didnt 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
+6 -6
View File
@@ -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),
};
+17 -7
View File
@@ -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 isnt 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 {
+66 -9
View File
@@ -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