#15: adding a 3rd person to a 1:1 call promotes it to a persistent group call
When someone is added to a 1:1 (DM) call, promoteDmToGroup() now creates a real group conversation (named after the participants) and migrates the live call from dmCalls -> groupCalls, keeping the same room/uuid/history so media and the transcript continue uninterrupted. Two wins: - the call survives anyone leaving (group calls only end when the room empties) - an added person who drops can rejoin from the group's active-call banner (they're now a member, so replayActiveCalls / group-call resurface it) /api/calls/invite promotes on a DM call and lets the group-call broadcast ring the invitees in; it only sends the plain call-invite when NOT promoted. Guarded so inviting an existing pair-member (memberIds < 3) stays a 1:1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+37
-1
@@ -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 };
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user