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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user