diff --git a/server/calls.js b/server/calls.js index 9b2e5d8..bc19556 100644 --- a/server/calls.js +++ b/server/calls.js @@ -235,4 +235,40 @@ async function declineDmCall(room, byUser) { return { ok: true }; } -module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey }; +// #15: adding a 3rd person to a 1:1 (DM) call turns it into a real, PERSISTENT group call. Two wins: +// (1) the call now survives anyone leaving (a group call only ends when the room empties), and +// (2) an added person who drops can rejoin — they're a group member, so the group's active-call banner +// (replayActiveCalls / group-call) reappears for them. +// The SAME room/uuid/startedAt are kept, so live media + the transcript continue uninterrupted; we just move +// the room's bookkeeping from dmCalls → groupCalls and create the backing group conversation. Returns the new +// group id, or null when `room` isn't a DM call (a group/ad-hoc call needs no promotion — caller invites as usual). +async function promoteDmToGroup(room, inviter, inviteeIds) { + const key = roomToDmCall.get(room); if (!key) return null; + const call = dmCalls.get(key); if (!call) return null; + const teamId = call.teamId || (inviter && inviter.team_id); + const memberIds = [...new Set([...(call.users || []), ...(inviteeIds || [])])].filter(Boolean); + if (memberIds.length < 3) return null; // nothing new actually added → stay a 1:1 + // Friendly name from participant first-names (e.g. "Ravi, Sara, Alex"), capped so it doesn't run long. + const names = []; + for (const uid of memberIds) { let usr = null; try { usr = await R.users.byId(uid); } catch (_) {} if (usr) names.push(((usr.name || usr.email || '').trim().split(/\s+/)[0]) || usr.email || 'Someone'); } + const groupName = (names.slice(0, 4).join(', ') + (names.length > 4 ? ' +' + (names.length - 4) : '')) || 'Group call'; + const owner = call.startedBy || (inviter && inviter.id); + const gid = A.id(); + await R.conversations.create({ id: gid, teamId, name: groupName, createdBy: owner }); + for (const uid of memberIds) { try { await R.conversations.addMember(gid, uid, uid === owner); } catch (_) {} } + // Migrate the LIVE call: DM → group (same room/uuid/history so media, transcript and Past-meetings all continue). + if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } + dmCalls.delete(key); roomToDmCall.delete(room); + groupCalls.set(gid, { room, uuid: call.uuid, startedAt: call.startedAt, startedBy: owner, startedByName: call.startedByName, teamId, historyId: call.historyId }); + roomToGroupCall.set(room, gid); + postSystem(gid, teamId, '📞 ' + (call.startedByName || 'Someone') + ' turned this into a group call').catch(() => {}); + // Tell every member's client: refresh the sidebar (the new group appears) and mark the call active (banner + // + ring the people not already in the room). Those already in the call ignore the ring (same room). + for (const uid of memberIds) { + try { CHAT.pushToUser(uid, { type: 'group-update', group: gid }); } catch (_) {} + try { CHAT.pushToUser(uid, { type: 'group-call', group: gid, active: true, room, uuid: call.uuid, by: owner, startedByName: call.startedByName, groupName }); } catch (_) {} + } + return gid; +} + +module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, promoteDmToGroup, finalizeTranscript, meetingContext, fmtDur, pairKey }; diff --git a/server/routes.js b/server/routes.js index 9949b9e..b435b42 100644 --- a/server/routes.js +++ b/server/routes.js @@ -963,8 +963,13 @@ route('POST', '/api/calls/invite', async (req, res) => { const { room, userIds } = await readBody(req); if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)); - for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } - json(res, 200, { ok: true, invited: ids.length }); + // #15: adding people to a 1:1 call promotes it to a persistent GROUP call (survives leaves + lets an added + // person rejoin after dropping). When that happens promoteDmToGroup's own group-call broadcast already rings + // the invitees in, so we only send the plain call-invite for a call that WASN'T promoted (group/ad-hoc). + let groupId = null; + try { groupId = await CALLS.promoteDmToGroup(String(room), u, ids); } catch (_) {} + if (!groupId) { for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } } + json(res, 200, { ok: true, invited: ids.length, group: groupId || undefined }); }); // Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is