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:
+51
-3
@@ -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,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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -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
|
||||
|
||||
@@ -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]));}
|
||||
</script>
|
||||
|
||||
+59
-6
@@ -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">You’ll 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 can’t 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 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
|
||||
|
||||
+6
-6
@@ -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
@@ -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 {
|
||||
|
||||
+66
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user