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
+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