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