From aaf5512479ff1fbe870bd71c0148521aabe35739 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 10:09:30 -0300 Subject: [PATCH 01/31] feat(video-conf): conference data model, server service & API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend foundation for persistent chat: membership tracking with joined/declined/left lifecycle, presence leases with heartbeat renewal and cron-based sweep, ringing with ring-again support, chat access resolution, joinable-calls listing, and 8 new REST endpoints (leave, heartbeat, ring, cancel, decline, add-participants, rename, share-chat). All changes are additive — the existing call UI (Jitsi popup, etc.) keeps working. Embedded-provider paths are guarded and no-op until a provider registers as embedded. Includes shared isomorphic lib (chatAccess, conferenceName, memberStatus, presence, constants), model layer (addMemberById, setUserJoinedById, presence renewal, embedded participants), REST validation schemas (CallIdProps replacing CancelProps, plus 5 new), DDP stream types, feature docs with flow diagrams, and unit tests for every new server method. Co-Authored-By: Claude Opus 4.6 --- .changeset/videoconf-persistent-chat.md | 27 + .../VideoConfList/useVideoConfList.ts | 28 +- .../server/configuration/videoConference.ts | 8 +- apps/meteor/lib/videoConference/chatAccess.ts | 53 + .../lib/videoConference/conferenceName.ts | 53 + apps/meteor/lib/videoConference/constants.ts | 20 + .../lib/videoConference/memberStatus.ts | 46 + apps/meteor/lib/videoConference/presence.ts | 74 ++ apps/meteor/server/api/v1/videoConference.ts | 323 ++++- apps/meteor/server/cron/videoConferences.ts | 27 +- apps/meteor/server/lib/videoConfAccess.ts | 38 + apps/meteor/server/lib/videoConfPresence.ts | 36 + apps/meteor/server/lib/videoConfProviders.ts | 6 + .../modules/listeners/listeners.module.ts | 4 + .../notifications/notifications.module.ts | 37 +- .../services/video-conference/service.ts | 1060 ++++++++++++++++- .../tests/e2e/video-conference-ring.spec.ts | 13 +- .../VideoConfShareChatProps.spec.ts | 24 + .../lib/videoConference/chatAccess.spec.ts | 46 + .../videoConference/conferenceName.spec.ts | 72 ++ .../lib/videoConference/memberStatus.spec.ts | 118 ++ .../unit/lib/videoConference/presence.spec.ts | 88 ++ .../unit/server/lib/videoConfAccess.spec.ts | 54 + .../video-conference/busyStatus.spec.ts | 149 +++ .../video-conference/declineCall.spec.ts | 141 +++ .../expirePresenceLeases.spec.ts | 181 +++ .../video-conference/getChatAccess.spec.ts | 141 +++ .../video-conference/leaveCall.spec.ts | 238 ++++ .../listJoinableCalls.spec.ts | 278 +++++ .../video-conference/renameCall.spec.ts | 76 ++ .../services/video-conference/ringing.spec.ts | 363 ++++++ .../services/video-conference/testHarness.ts | 195 +++ .../README.md | 997 ++++++++++++++++ .../adding-people-and-chat-access.svg | 59 + .../being-called.svg | 56 + .../ending-a-call.svg | 47 + .../matrix-comparison.md | 186 +++ .../starting-a-call.svg | 55 + packages/core-services/src/events/Events.ts | 8 + .../src/types/IVideoConfService.ts | 21 + packages/core-typings/src/INotification.ts | 16 +- packages/core-typings/src/IVideoConference.ts | 160 +++ .../src/VideoConferenceCapabilities.ts | 7 + packages/ddp-client/src/types/streams.ts | 3 + packages/desktop-api/src/index.ts | 7 + packages/i18n/src/locales/en.i18n.json | 4 + packages/jwt/src/index.ts | 40 + .../src/models/IVideoConferenceModel.ts | 39 +- .../models/src/models/VideoConference.spec.ts | 220 ++++ packages/models/src/models/VideoConference.ts | 199 +++- .../VideoConfAddParticipantsProps.ts | 41 + .../videoConference/VideoConfCallIdProps.ts | 31 + .../videoConference/VideoConfCancelProps.ts | 21 - .../videoConference/VideoConfRenameProps.ts | 29 + .../v1/videoConference/VideoConfRingProps.ts | 29 + .../VideoConfShareChatProps.ts | 29 + .../src/v1/videoConference/index.ts | 73 +- 57 files changed, 6287 insertions(+), 107 deletions(-) create mode 100644 .changeset/videoconf-persistent-chat.md create mode 100644 apps/meteor/lib/videoConference/chatAccess.ts create mode 100644 apps/meteor/lib/videoConference/conferenceName.ts create mode 100644 apps/meteor/lib/videoConference/memberStatus.ts create mode 100644 apps/meteor/lib/videoConference/presence.ts create mode 100644 apps/meteor/server/lib/videoConfAccess.ts create mode 100644 apps/meteor/server/lib/videoConfPresence.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts create mode 100644 apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts create mode 100644 apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts create mode 100644 apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts create mode 100644 apps/meteor/tests/unit/lib/videoConference/presence.spec.ts create mode 100644 apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts create mode 100644 apps/meteor/tests/unit/server/services/video-conference/testHarness.ts create mode 100644 docs/features/video-conference-persistent-chat/README.md create mode 100644 docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg create mode 100644 docs/features/video-conference-persistent-chat/being-called.svg create mode 100644 docs/features/video-conference-persistent-chat/ending-a-call.svg create mode 100644 docs/features/video-conference-persistent-chat/matrix-comparison.md create mode 100644 docs/features/video-conference-persistent-chat/starting-a-call.svg create mode 100644 packages/models/src/models/VideoConference.spec.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts delete mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts diff --git a/.changeset/videoconf-persistent-chat.md b/.changeset/videoconf-persistent-chat.md new file mode 100644 index 0000000000000..28d2122fb2e3f --- /dev/null +++ b/.changeset/videoconf-persistent-chat.md @@ -0,0 +1,27 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ddp-client': minor +'@rocket.chat/fuselage-ui-kit': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/ui-kit': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/i18n': minor +'@rocket.chat/mock-providers': minor +'@rocket.chat/meteor': minor +--- + +Gives a video conference a chat that outlives it, and a window of its own to hold both. + +Joining a conference now opens a dedicated call window at `/conference/:id` — the provider's call beside the conference's chat, with the people on the call in a panel of their own — instead of handing the user off to the provider's page. A preflight screen opens first: it is where the camera and microphone are chosen, where whoever started a group call can name it, and where confirming is what actually creates the call, so a call nobody confirmed leaves no message, no ring and no history behind. Closing the window reports leaving, and a call nobody is left in ends by itself. + +Adding someone to a conference makes them a member of the **conference** rather than putting them in a room. Membership authorizes joining the call alongside room access, so a person from outside the conference's room can join without being handed the room's history — and whether they can read the chat becomes a separate question, surfaced once it matters with a choice of how to resolve it: bring them into the room, or move the chat to a discussion. `video-conference.info` reports the members who can't read it and `POST /v1/video-conference.share-chat` applies the remedy; `video-conference.add-participants` takes just the users and returns the ids it added. + +An incoming call is no longer a popup demanding an answer. It is the first item of a list of the calls running now — docked in the sidebar — where it can be accepted, turned down, or silenced and left ringing while the user finishes what they were doing. That list is also how a call is reached when its ring was missed entirely, which a one-shot ring in a room of more than ten people always is (`GET /v1/video-conference.joinable`). + +Conferences appear in the personal Call History from the moment they start, as `ongoing`, settling per member into `ended` or `not-answered` when the call stops — so a call that was declined or never answered is still in the log, and still joinable from it. Conference discussions carry a banner back into the ongoing call, and the room's own call list stops counting members who were added but never joined. + +New endpoints: `video-conference.decline` (recorded against the caller's own membership, never ending the call for anyone else), `.leave`, `.ring` (to try someone again — a ring is one-shot, so there was previously no second attempt), `.rename` and `.share-chat`. A single `video-conference.updated` stream event tells an open call window that the conference it is showing has changed. diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts index 0f499432d2c2b..1a9cf1d44b75b 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts @@ -20,15 +20,27 @@ export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { return { items: data.map( - (videoConf): VideoConference => ({ - ...videoConf, - _updatedAt: new Date(videoConf._updatedAt), - createdAt: new Date(videoConf.createdAt), - endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined, - users: videoConf.users.map((user) => ({ - ...user, - ts: new Date(user.ts), + ({ _updatedAt, createdAt, endedAt, users, participants, ...rest }): VideoConference => ({ + ...rest, + _updatedAt: new Date(_updatedAt), + createdAt: new Date(createdAt), + endedAt: endedAt ? new Date(endedAt) : undefined, + users: users.map(({ ts, joinedAt, declinedAt, leftAt, lastSeenAt, ringingAt, ...userRest }) => ({ + ...userRest, + ts: new Date(ts), + joinedAt: joinedAt ? new Date(joinedAt) : undefined, + declinedAt: declinedAt ? new Date(declinedAt) : undefined, + leftAt: leftAt ? new Date(leftAt) : undefined, + lastSeenAt: lastSeenAt ? new Date(lastSeenAt) : undefined, + ringingAt: ringingAt ? new Date(ringingAt) : undefined, })), + ...(participants && { + participants: participants.map(({ joinedAt, leftAt, ...pRest }) => ({ + ...pRest, + joinedAt: joinedAt ? new Date(joinedAt) : undefined, + leftAt: leftAt ? new Date(leftAt) : undefined, + })), + }), }), ), itemCount: total, diff --git a/apps/meteor/ee/server/configuration/videoConference.ts b/apps/meteor/ee/server/configuration/videoConference.ts index 5d6599e8234aa..351b8b81bccb2 100644 --- a/apps/meteor/ee/server/configuration/videoConference.ts +++ b/apps/meteor/ee/server/configuration/videoConference.ts @@ -5,6 +5,7 @@ import { License } from '@rocket.chat/license'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { shouldRingVideoConference } from '../../../lib/videoConference/constants'; import { callbacks } from '../../../server/lib/callbacks'; import { videoConfTypes } from '../../../server/lib/videoConfTypes'; import { addSettings } from '../settings/video-conference'; @@ -38,11 +39,8 @@ Meteor.startup(async () => { } } - if ((await Subscriptions.countByRoomId(_id)) > 10) { - return false; - } - - return true; + // Starting a call rings the whole room, so the room's size is the list being rung. + return shouldRingVideoConference(await Subscriptions.countByRoomId(_id)); }); callbacks.add('onJoinVideoConference', async (callId: VideoConference['_id'], userId?: IUser['_id']) => diff --git a/apps/meteor/lib/videoConference/chatAccess.ts b/apps/meteor/lib/videoConference/chatAccess.ts new file mode 100644 index 0000000000000..a1ae4c424c6b2 --- /dev/null +++ b/apps/meteor/lib/videoConference/chatAccess.ts @@ -0,0 +1,53 @@ +import type { IRoom, IUser, VideoConferenceChatAccess, VideoConferenceChatAccessMode } from '@rocket.chat/core-typings'; + +/** + * Whether this user can read the conference's chat. Membership of a call grants no room access, so being in a + * call and being able to follow what is said in it are separate questions. + * + * Asked in three places — whether to render the chat at all, whether to offer to share it, and how to mark a + * member in the list — and each had worked it out for itself, from either an array or a `Set`. + */ +export const hasConferenceChatAccess = ( + access: Pick | undefined, + uid: IUser['_id'] | null | undefined, +): boolean => !uid || !access?.membersWithoutAccess.includes(uid); + +/** + * Which way of giving the missing members access should lead — be the primary action, and the one taken when + * no choice is made. + * + * Both give something away: inviting exposes the room's whole history to someone outside it, while moving the + * chat to a discussion leaves the earlier history behind for everyone already there. Exposing a *private* + * room's history is the bigger step, so private rooms and DMs lead with the discussion and public rooms — + * whose history is already open — lead with the invite. A room that can't take new members at all leaves the + * discussion as the only option. + * + * Shared so the modal's primary button and the server's default can't drift apart. + */ +export const chatAccessLeadsWithDiscussion = ({ canInvite, type }: { canInvite: boolean; type: IRoom['t'] }): boolean => + !canInvite || type === 'p' || type === 'd'; + +/** + * The mode to act on, given what the caller asked for and what the room allows. `undefined` means "you + * decide". Returns `null` when the caller asked for something the room can't do, which is a refusal rather + * than a reason to silently do the other thing — it would give away history nobody agreed to give away. + */ +export const resolveChatAccessMode = ({ + mode, + canInvite, + type, +}: { + mode: VideoConferenceChatAccessMode | undefined; + canInvite: boolean; + type: IRoom['t']; +}): VideoConferenceChatAccessMode | null => { + if (mode === 'invite' && !canInvite) { + return null; + } + + if (mode) { + return mode; + } + + return chatAccessLeadsWithDiscussion({ canInvite, type }) ? 'discussion' : 'invite'; +}; diff --git a/apps/meteor/lib/videoConference/conferenceName.ts b/apps/meteor/lib/videoConference/conferenceName.ts new file mode 100644 index 0000000000000..96d02d83c9fdb --- /dev/null +++ b/apps/meteor/lib/videoConference/conferenceName.ts @@ -0,0 +1,53 @@ +import type { IRoom, IUser, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; + +type NameableConference = { + type: VideoConference['type']; + title?: string; + createdBy: Pick & Partial>; + users: (Pick & Partial>)[]; +}; + +const displayName = (person?: Partial>): string => person?.name || person?.username || ''; + +/** + * Who a direct call is *with*, from this viewer's side. Whoever started it, unless that is the viewer themselves, + * in which case it is whoever else is on the call. + * + * The creator rather than "the other member" because a direct call can hold more than two people: someone added + * to a call in a DM has two others to choose from, and the one who matters to them is the one who brought them in. + */ +const otherParty = (call: NameableConference, viewerId: IUser['_id'] | undefined) => + call.createdBy._id !== viewerId ? call.createdBy : call.users.find(({ _id }) => _id !== viewerId); + +/** + * What to call a conference, for the person looking at it. Returns `''` when only the room can answer, leaving + * that to the caller — which is also what keeps the room lookup off the path that doesn't need it. + * + * A direct call has no name of its own, so it is named after a person. Ordinarily that name comes from the + * viewer's own subscription, since a DM is named per side and the name lives there rather than on the room. But + * conference membership grants no room access, so a member added from outside a DM has no subscription to read — + * and the room can't help either: a DM room carries neither `name` nor `fname`, so falling back to it ended in + * `getRoomName`'s last resort, the raw room id. That is what put a hash in front of the person who was invited. + * Naming the call after whoever brought them in answers the question they actually have, which is who is calling. + */ +export const conferenceNameFor = ( + call: NameableConference, + viewerId: IUser['_id'] | undefined, + subscriptionName?: string, + roomType?: IRoom['t'], +): string => { + const isDM = call.type === 'direct' || roomType === 'd'; + + // A group conference has a name of its own, and it wins — except in a DM, where the "title" is + // `room.fname` from the creator's perspective and is wrong for every other viewer. There the + // per-viewer subscription name is the right answer. + if (!isDM && call.type === 'videoconference' && call.title) { + return call.title; + } + + if (subscriptionName) { + return subscriptionName; + } + + return isDM ? displayName(otherParty(call, viewerId)) : ''; +}; diff --git a/apps/meteor/lib/videoConference/constants.ts b/apps/meteor/lib/videoConference/constants.ts index 69e243115be98..56ce12e7c421e 100644 --- a/apps/meteor/lib/videoConference/constants.ts +++ b/apps/meteor/lib/videoConference/constants.ts @@ -1,5 +1,25 @@ +import { VIDEO_CONF_RINGING_LIMIT } from '@rocket.chat/core-typings'; + export const availabilityErrors = { NOT_CONFIGURED: 'video-conf-provider-not-configured', NOT_ACTIVE: 'no-active-video-conf-provider', NO_APP: 'no-videoconf-provider-app', }; + +/** + * How many of the people in a call are shown as faces in the sidebar list before the rest become a "+N". + * + * Shared with the server, which slices the joinable payload to it: sending more would be sending a roster nobody + * draws. Two is what fits beside a call's name in a sidebar row without pushing it out — the rest are a count. + */ +export const CALL_FACES_SHOWN = 2; + +/** + * The same, on the preflight — a screen rather than a row, so it has room for more of them before the count takes + * over. The people come from the call window's own copy of the members, so nothing has to travel for these. + */ +export const PREFLIGHT_FACES_SHOWN = 10; + +/** Whether this many recipients is a set worth ringing. See `VIDEO_CONF_RINGING_LIMIT` for why there is a cap. */ +export const shouldRingVideoConference = (recipientCount: number): boolean => + recipientCount > 0 && recipientCount <= VIDEO_CONF_RINGING_LIMIT; diff --git a/apps/meteor/lib/videoConference/memberStatus.ts b/apps/meteor/lib/videoConference/memberStatus.ts new file mode 100644 index 0000000000000..a56fafe0cfa20 --- /dev/null +++ b/apps/meteor/lib/videoConference/memberStatus.ts @@ -0,0 +1,46 @@ +import type { IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { hasJoinedVideoConference, isInVideoConference, isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; + +/** Where a member stands with the call, as one thing the UI can label them with. */ +export type ConferenceMemberStatus = 'joined' | 'left' | 'declined' | 'invited'; + +type MemberState = Pick; + +/** + * Reduces a membership entry to the one thing worth showing. + * + * The entry accumulates rather than replaces — `joined` never goes back to false, and a decline stays recorded + * after the person changes their mind — so the fields have to be read in order of what happened *last*. Being + * in the call beats everything; having left beats an earlier decline, since they did answer; a decline beats + * simply having been invited. + */ +export const getConferenceMemberStatus = (member: MemberState): ConferenceMemberStatus => { + if (isInVideoConference(member)) { + return 'joined'; + } + + if (member.leftAt) { + return 'left'; + } + + return member.declined ? 'declined' : 'invited'; +}; + +/** + * Whether it makes sense to ring this member *now*. Not while their phone is already ringing — there is nothing + * to ask for — and not while they are in the call. Once they have declined, ignored it or left, ringing them + * back is exactly the point. + */ +export const canRingConferenceMember = (member: MemberState, now?: number): boolean => + getConferenceMemberStatus(member) !== 'joined' && !isRingingVideoConferenceMember(member, now); + +/** + * Whether this member has not been asked to answer *yet* — never rung, never in the call, never declined. + * + * Different from `canRingConferenceMember`, which is about whether ringing them again would make sense: this is + * about a call nobody has been asked about at all. Both sides of the ring need it — the server, to ring the callee + * when the caller finally walks in, and the caller's own preflight, to say so instead of implying a phone is + * already ringing. + */ +export const isUnaskedConferenceMember = (member: Pick): boolean => + !member.ringingAt && !hasJoinedVideoConference(member) && !member.declined; diff --git a/apps/meteor/lib/videoConference/presence.ts b/apps/meteor/lib/videoConference/presence.ts new file mode 100644 index 0000000000000..b3f1a1699ee94 --- /dev/null +++ b/apps/meteor/lib/videoConference/presence.ts @@ -0,0 +1,74 @@ +import type { IUser, IVideoConferenceUser, VideoConferenceLeaveReason } from '@rocket.chat/core-typings'; +import { isInVideoConference } from '@rocket.chat/core-typings'; + +/** + * Presence in a call as a lease the call window keeps renewing, rather than a departure it promises to report. + * + * A report is the fast, accurate path and it usually works — but it can only be sent by a live client to a live + * server, and neither is guaranteed. The case that started this: the workspace goes down while the call carries + * on in the provider (LiveKit, Pexip and friends are separate services), people leave during the outage, and + * their leave never reaches anyone. The same hole swallows a crashed tab, a killed browser, a dead battery and a + * `keepalive` fetch that didn't make it. What all of those have in common is that *renewals stop*, which is the + * signal this infers a departure from. + * + * Deliberately provider-agnostic: the renewal comes from our own conference window, which exists whoever runs the + * media — an iframe provider renders inside our page, so our code is alive there too. Where a provider *can* be + * asked who is in the room, its answer renews leases as well (see `videoConfPresence`), which keeps someone in + * the call whose browser has throttled their heartbeat. Nothing here requires that integration to exist. + */ + +/** + * How often a call window renews its lease. + * + * Well under the lease it renews, because a hidden tab — a call you are listening to while working in another + * window — has its timers throttled to roughly one a minute by every current browser. + */ +export const PRESENCE_HEARTBEAT_MS = 30_000; + +/** + * How long one renewal is good for. + * + * Long enough to survive throttling (two missed ticks at a browser's throttled rate) and a brief network drop, + * short enough that a ghost in the members list is a curiosity rather than a lie. It doubles as the grace period + * a departing member gets before their absence is written, which is why this is also what a restart waits out. + */ +export const PRESENCE_LEASE_MS = 180_000; + +/** The reasons a departure was inferred rather than reported, so a renewal can undo them and a report cannot. */ +export const INFERRED_LEAVE_REASONS: VideoConferenceLeaveReason[] = ['timeout']; + +/** A member whose lease has run out, and the last moment we know they were still in the call. */ +export type ExpiredPresenceLease = { uid: IUser['_id']; leftAt: Date }; + +/** + * The last moment there was evidence this member was in the call. Members who joined before leases existed have + * no renewal to read, so their join stands as the last thing we know — and failing even that, their membership. + */ +const lastEvidence = (user: IVideoConferenceUser): Date => user.lastSeenAt ?? user.joinedAt ?? user.ts; + +/** + * Which members are to be treated as gone, and when they left. + * + * `leftAt` is the last evidence rather than the moment of the sweep, which is the whole point of keeping a + * watermark: stamping "now" on a call recovered after a 20-minute outage would add 20 minutes to everyone's call + * history. The honest answer is "we last saw you before the lights went out", and that is what this returns. + */ +export const expiredPresenceLeases = (users: IVideoConferenceUser[], now: Date, leaseMs = PRESENCE_LEASE_MS): ExpiredPresenceLease[] => + users + .filter((user) => isInVideoConference(user)) + .map((user) => ({ uid: user._id, leftAt: lastEvidence(user) })) + .filter(({ leftAt }) => now.getTime() - leftAt.getTime() >= leaseMs); + +/** + * Whether leases may be acted on yet, given how long this process has been up. + * + * The guard that makes leases correct across a restart. From the database, "everyone left" and "we were not here + * to be told" are the same picture: every lease is expired either way. So a freshly started process waits out a + * full lease before evicting anyone — whoever is still in the call renews within it (their window heartbeats + * every `PRESENCE_HEARTBEAT_MS`, throttled to a minute at worst), and whoever is genuinely gone is still gone + * afterwards, with the departure timestamp they had all along. + * + * In a multi-instance workspace this costs nothing: the instances that stayed up were never absent and keep + * sweeping throughout. + */ +export const isPresenceSweepDue = (uptimeMs: number, leaseMs = PRESENCE_LEASE_MS): boolean => uptimeMs >= leaseMs; diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index b13ee206f474c..e4afeaa1462f7 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -4,9 +4,13 @@ import { ajv, isVideoConfStartProps, isVideoConfJoinProps, - isVideoConfCancelProps, + isVideoConfRingProps, + isVideoConfCallIdProps, isVideoConfInfoProps, isVideoConfListProps, + isVideoConfAddParticipantsProps, + isVideoConfRenameProps, + isVideoConfShareChatProps, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, @@ -16,6 +20,7 @@ import { availabilityErrors } from '../../../lib/videoConference/constants'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; +import { canAccessConference } from '../../lib/videoConfAccess'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { API } from '../api'; import { getPaginationItems } from '../lib/getPaginationItems'; @@ -41,11 +46,13 @@ const startResponseSchema = ajv.compile<{ data: VideoConferenceInstructions & { additionalProperties: false, }); -const joinResponseSchema = ajv.compile<{ url: string; providerName: string }>({ +const joinResponseSchema = ajv.compile<{ url: string; providerName: string; callId?: string; rid?: string }>({ type: 'object', properties: { url: { type: 'string' }, providerName: { type: 'string' }, + callId: { type: 'string' }, + rid: { type: 'string' }, success: { type: 'boolean', enum: [true] }, }, required: ['url', 'providerName', 'success'], @@ -59,6 +66,63 @@ const cancelResponseSchema = ajv.compile({ additionalProperties: false, }); +/** + * How every conference endpoint below starts: the call has to exist, and the caller has to be allowed near it. + * + * Both failures are answered the same way — `invalid-params`, deliberately vague about which of the two it was, + * so a stranger can't use the endpoint to learn that a call id is real. Returning the caller's id alongside the + * call is what lets the handlers use it without re-checking that they are signed in. + */ +const loadAccessibleConference = async ( + callId: VideoConference['_id'], + userId: string | undefined, +): Promise<{ call: Omit; userId: string } | undefined> => { + if (!userId) { + return undefined; + } + + const call = await VideoConf.get(callId); + if (!call || !(await canAccessConference(call, userId))) { + return undefined; + } + + return { call, userId }; +}; + +/** + * The conference endpoints answer with one value beside `success`, so their schemas differ in a single property. + */ +const oneValueResponseSchema = (name: string, value: Record) => + ajv.compile({ + type: 'object', + properties: { [name]: value, success: { type: 'boolean', enum: [true] } }, + required: [name, 'success'], + additionalProperties: false, + }); + +const addParticipantsResponseSchema = oneValueResponseSchema<{ added: string[] }>('added', { + type: 'array', + items: { type: 'string' }, + description: 'Ids of the users newly added as members.', +}); + +const joinableResponseSchema = oneValueResponseSchema<{ calls: unknown[] }>('calls', { + type: 'array', + items: { type: 'object' }, + description: 'Calls running now that the caller may join.', +}); + +const ringResponseSchema = oneValueResponseSchema<{ rang: string[] }>('rang', { + type: 'array', + items: { type: 'string' }, + description: 'Ids of the members who were rung.', +}); + +const shareChatResponseSchema = oneValueResponseSchema<{ rid: string }>('rid', { + type: 'string', + description: 'The room the conference chat now lives in.', +}); + const infoResponseSchema = ajv.compile({ type: 'object', properties: { @@ -189,7 +253,7 @@ API.v1.post( return API.v1.failure('invalid-params'); } - if (!(await canAccessRoomIdAsync(call.rid, userId))) { + if (!(await canAccessConference(call, userId))) { return API.v1.failure('invalid-params'); } @@ -206,13 +270,19 @@ API.v1.post( } } - if (!url) { + // Embedded providers (LiveKit) intentionally return an empty url — + // they're rendered inline rather than opened as an external popup. + // Include rid so the client can route the join into its embedded + // provider context without an extra round-trip to look it up. + if (!url && !call.providerName) { return API.v1.failure('failed-to-get-url'); } return API.v1.success({ - url, + url: url ?? '', providerName: call.providerName, + callId: call._id, + rid: call.rid, }); }, ); @@ -221,7 +291,7 @@ API.v1.post( 'video-conference.cancel', { authRequired: true, - body: isVideoConfCancelProps, + body: isVideoConfCallIdProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 }, response: { 200: cancelResponseSchema, @@ -247,6 +317,207 @@ API.v1.post( }, ); +API.v1.post( + 'video-conference.decline', + { + authRequired: true, + body: isVideoConfCallIdProps, + rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Records the decline against the caller's own membership only. Declining is deliberately not a way to + // end someone else's conference, so this takes no target user and never touches the call's status. + await VideoConf.declineCall(conference.userId, callId); + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.leave', + { + authRequired: true, + body: isVideoConfCallIdProps, + // Sent when the call window closes, which a user can do repeatedly across rejoins. + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Only ever marks the caller as gone. The conference ends as a consequence of nobody being left in it, + // not because one participant asked for it — the same rule declining follows. + await VideoConf.leaveCall(conference.userId, callId); + + return API.v1.success(); + }, +); + +/** + * Renews the caller's presence lease on a call — the conference window saying it is still in it. + * + * The counterpart of `video-conference.leave`, and the reason a lost leave is survivable: leaving is inferred from + * renewals stopping, so nothing has to reach us at the moment someone goes. Provider-agnostic, because the window + * doing the renewing is ours whatever runs the media. + */ +API.v1.post( + 'video-conference.heartbeat', + { + authRequired: true, + body: isVideoConfCallIdProps, + // Renewals are every `PRESENCE_HEARTBEAT_MS`, so twice a minute, plus one whenever the window is brought + // back to the front. The allowance is for that: bursts of attention, not a higher steady rate. + rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + await VideoConf.renewPresence(conference.userId, callId); + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.ring', + { + authRequired: true, + body: isVideoConfRingProps, + // Ringing again is a deliberate, repeatable act, but not one worth hammering someone with. + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: ringResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, users } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + const rang = await VideoConf.ringMembers(conference.userId, callId, users); + + return API.v1.success({ rang }); + }, +); + +API.v1.post( + 'video-conference.add-participants', + { + authRequired: true, + body: isVideoConfAddParticipantsProps, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: addParticipantsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, users, ring } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Registers the users as conference members — it deliberately does not put them in any room. Being a + // member authorizes joining the call; whether they can read the chat is surfaced separately. + const added = await VideoConf.addMembers(conference.userId, callId, users, { ring: ring ?? true }); + + return API.v1.success({ added }); + }, +); + +API.v1.post( + 'video-conference.rename', + { + authRequired: true, + body: isVideoConfRenameProps, + rateLimiterOptions: { numRequestsAllowed: 10, intervalTimeInMS: 60000 }, + response: { + 200: cancelResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, title } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + // Whether this particular user may *name* the call is the service's call to make — access is only the + // question of whether they may be here at all. + await VideoConf.renameCall(conference.userId, callId, title); + + return API.v1.success(); + }, +); + +API.v1.post( + 'video-conference.share-chat', + { + authRequired: true, + body: isVideoConfShareChatProps, + rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 60000 }, + response: { + 200: shareChatResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { callId, mode } = this.bodyParams; + + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { + return API.v1.failure('invalid-params'); + } + + const rid = await VideoConf.shareChatWithMembers(conference.userId, callId, mode); + + return API.v1.success({ rid }); + }, +); + API.v1.get( 'video-conference.info', { @@ -261,26 +532,52 @@ API.v1.get( }, async function action() { const { callId } = this.queryParams; - const { userId } = this; - const call = await VideoConf.get(callId); - if (!call) { + const conference = await loadAccessibleConference(callId, this.userId); + if (!conference) { return API.v1.failure('invalid-params'); } - if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { - return API.v1.failure('invalid-params'); - } + const { call, userId } = conference; - const capabilities = await VideoConf.listProviderCapabilities(call.providerName); + // Membership grants no room access, so some members may not be able to read the chat. The conference UI + // surfaces them and offers the remedy, which is why this ships with the conference rather than needing + // its own round trip. + const [capabilities, chatAccess] = await Promise.all([ + VideoConf.listProviderCapabilities(call.providerName), + VideoConf.getChatAccess(userId, callId), + ]); return API.v1.success({ ...(call as VideoConference), capabilities, + chatAccess, }); }, ); +API.v1.get( + 'video-conference.joinable', + { + authRequired: true, + // Polled by the sidebar, so it has to tolerate a steady trickle. + rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 }, + response: { + 200: joinableResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { userId } = this; + if (!userId) { + return API.v1.failure('invalid-params'); + } + + return API.v1.success({ calls: await VideoConf.listJoinableCalls(userId) }); + }, +); + API.v1.get( 'video-conference.list', { diff --git a/apps/meteor/server/cron/videoConferences.ts b/apps/meteor/server/cron/videoConferences.ts index 69a588cfdde3e..50d4accb0dc64 100644 --- a/apps/meteor/server/cron/videoConferences.ts +++ b/apps/meteor/server/cron/videoConferences.ts @@ -4,6 +4,8 @@ import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { cronJobs } from '@rocket.chat/cron'; import { VideoConference as VideoConferenceModel } from '@rocket.chat/models'; +import { isPresenceSweepDue } from '../../lib/videoConference/presence'; + // 24 hours const VIDEO_CONFERENCE_TTL = 24 * 60 * 60 * 1000; @@ -17,8 +19,31 @@ async function runVideoConferences(): Promise { await Promise.all(calls.map((callId) => VideoConf.setStatus(callId, VideoConferenceStatus.EXPIRED))); } +/** + * Sweeps presence leases: everyone whose call window has stopped renewing is treated as having left. + * + * Frequent because it is what recovers a call after an outage, and cheap because the work is proportional to the + * number of *open* calls, which is normally none. + */ +async function runPresenceSweep(): Promise { + // A restart cannot tell "everyone left" from "we were not here to be told" — both leave every lease expired. + // So a fresh process waits out one full lease, by which time anyone still in a call has renewed theirs. + if (!isPresenceSweepDue(process.uptime() * 1000)) { + return; + } + + await VideoConf.expirePresenceLeases(); +} + export async function videoConferencesCron(): Promise { void runVideoConferences(); - return cronJobs.add('VideoConferences', '0 */3 * * *', async () => runVideoConferences()); + await cronJobs.add('VideoConferences', '0 */3 * * *', async () => runVideoConferences()); + + // A cron expression, not an interval phrase: the scheduler parses these, and an unparseable schedule leaves + // `nextRunAt` on the moment it just ran — which is a job that re-runs on every scheduler tick forever. + // + // Not run here on the way past, unlike the expiry above: at startup the guard inside it would reject it + // anyway, and that is exactly the point. + return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep()); } diff --git a/apps/meteor/server/lib/videoConfAccess.ts b/apps/meteor/server/lib/videoConfAccess.ts new file mode 100644 index 0000000000000..6e8f577eebffb --- /dev/null +++ b/apps/meteor/server/lib/videoConfAccess.ts @@ -0,0 +1,38 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; + +import { canAccessRoomIdAsync } from './authorization/canAccessRoom'; + +/** + * Whether someone is allowed near a conference at all — the one rule, for every endpoint that answers about one. + * + * Being in the call and being able to read its chat are separate things, so this accepts either: membership of + * the conference, or access to a room the conference lives in. + * + * Membership covers people added from outside the room — they were added to the *call*, not to a room, so there + * is no subscription to check. This is the case that makes the distinction load-bearing rather than theoretical: + * a conference started in a DM and joined by a third person gives that person no access to the DM, by design, and + * checking the room instead of the membership refuses them their own call. The room checks cover everyone who can + * already see the conversation: `rid` is the room the call started in, and `discussionRid` the discussion its + * chat may have moved to, whose members may have no access to the parent room. + * + * It lives here rather than beside one set of endpoints because more than one set needs it, and two versions of + * "may this person be here" drift into two different answers for the same person. + */ +export const canAccessConference = async ( + call: Pick, + userId: string | undefined, +): Promise => { + if (!userId) { + return false; + } + + if (call.users.some(({ _id }) => _id === userId)) { + return true; + } + + if (await canAccessRoomIdAsync(call.rid, userId)) { + return true; + } + + return !!call.discussionRid && canAccessRoomIdAsync(call.discussionRid, userId); +}; diff --git a/apps/meteor/server/lib/videoConfPresence.ts b/apps/meteor/server/lib/videoConfPresence.ts new file mode 100644 index 0000000000000..c0eacc0031b73 --- /dev/null +++ b/apps/meteor/server/lib/videoConfPresence.ts @@ -0,0 +1,36 @@ +import type { IUser, VideoConference } from '@rocket.chat/core-typings'; + +/** + * Asks a provider who is in a call's room right now. + * + * Returns the ids of the members it can see, or `undefined` for "no answer" — which is both what a provider with + * no such API says and what a reachable one says when the request fails. The distinction matters: an empty array + * is the provider stating that the room is empty, while `undefined` is silence, and silence must never be read as + * absence. + */ +export type VideoConfPresenceProbe = (call: Pick) => Promise; + +const probes = new Map(); + +/** + * Where a provider can offer to say who is in its rooms. + * + * Optional on purpose. Presence is held by leases the conference window renews, which works for every provider + * because that window is ours whoever runs the media. A probe is an upgrade on top of that, not a dependency: it + * renews leases from the server side, so a call window whose timers the browser has throttled — or which is + * behind a network that drops our heartbeat — is still recognised as being in the call. Providers reached by URL + * (Jitsi, Meet, Pexip as we drive it) register nothing and lose nothing but that. + */ +export const videoConfPresence = { + registerProbe(providerName: string, probe: VideoConfPresenceProbe): void { + probes.set(providerName.toLowerCase(), probe); + }, + + unregisterProbe(providerName: string): void { + probes.delete(providerName.toLowerCase()); + }, + + getProbe(providerName: string): VideoConfPresenceProbe | undefined { + return probes.get(providerName.toLowerCase()); + }, +}; diff --git a/apps/meteor/server/lib/videoConfProviders.ts b/apps/meteor/server/lib/videoConfProviders.ts index 4c753c15fe2bf..df09fd27c8c77 100644 --- a/apps/meteor/server/lib/videoConfProviders.ts +++ b/apps/meteor/server/lib/videoConfProviders.ts @@ -2,6 +2,12 @@ import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings'; import { settings } from '../settings'; +// `appId === 'core'` marks a built-in provider (e.g. LiveKit) — registered +// directly from server bootstrap rather than via an apps-engine app. The +// only behavioural impact is when callers look up the owning app to dispatch +// provider hooks; built-ins have no app to dispatch to. +export const CORE_PROVIDER_APP_ID = 'core'; + const providers = new Map(); export const videoConfProviders = { diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index 332aea56d5eac..7d472d428a1d4 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -181,6 +181,10 @@ export class ListenersModule { .catch((err) => logger.error({ msg: 'Failed to refresh status visibility', err, targets })); }); + service.onEvent('video-conference.updated', ({ callId }) => { + notifications.notifyVideoConferenceUpdated(callId); + }); + service.onEvent('presence.status', ({ user }) => { const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user; if (!status || !username) { diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 65be495c191d9..54277ab4c6f76 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -1,7 +1,7 @@ import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services'; import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource, PresenceStatusCode } from '@rocket.chat/core-typings'; import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client'; -import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users, VideoConference } from '@rocket.chat/models'; import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress'; import { SystemLogger } from '../../lib/logger/system'; @@ -47,6 +47,8 @@ export class NotificationsModule { public readonly streamPresence: IStreamer<'user-presence'>; + public readonly streamVideoConference: IStreamer<'video-conference'>; + constructor(private Streamer: IStreamerConstructor) { this.streamAll = new this.Streamer('notify-all'); this.streamLogged = new this.Streamer('notify-logged'); @@ -91,6 +93,7 @@ export class NotificationsModule { this.streamUser = new this.Streamer('notify-user'); this.streamLocal = new this.Streamer('local'); + this.streamVideoConference = new this.Streamer('video-conference'); } configure(): void { @@ -459,6 +462,33 @@ export class NotificationsModule { } }); + this.streamVideoConference.allowWrite('none'); + // Conference membership authorizes following the call — members may have no access to the room it + // originated in — and so does access to the room the chat lives in. That is the same pair + // `video-conference.info` accepts, and both halves are needed: membership alone refuses a room member + // who opens the conference before their join lands, and a refused subscription is never retried. + this.streamVideoConference.allowRead(async function (eventName) { + const user = await getCachedUserForPublication(this); + if (!user) { + return false; + } + + const [callId] = eventName.split('/'); + const call = await VideoConference.findOneById(callId, { projection: { users: 1, rid: 1, discussionRid: 1 } }); + if (!call) { + return false; + } + + if (call.users.some(({ _id }) => _id === user._id)) { + return true; + } + + const chatRids = [call.rid, call.discussionRid].filter((rid): rid is string => !!rid); + const rooms = await Rooms.findByIds(chatRids).toArray(); + + return (await Promise.all(rooms.map((room) => Authorization.canReadRoom(room, user)))).some(Boolean); + }); + this.streamLocal.serverOnly = true; this.streamLocal.allowRead('none'); this.streamLocal.allowEmit('all'); @@ -527,6 +557,11 @@ export class NotificationsModule { progressUpdated(progress: { rate: number } | ImporterProgress): void { this.streamImporters.emit('progress', progress); } + + /** Tells whoever is watching this conference that it changed and is worth reading again. */ + notifyVideoConferenceUpdated(callId: string): void { + this.streamVideoConference.emit(`${callId}/updated`); + } } type ExtractNotifyUserEventName< diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index fc63702ff1cd9..3f8a8f66eab00 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -2,7 +2,7 @@ import { Apps } from '@rocket.chat/apps'; import type { AppVideoConfProviderManager } from '@rocket.chat/apps/dist/server/managers/AppVideoConfProviderManager'; import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders'; import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services'; -import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services'; +import { api, ServiceClassInternal, Message, Presence, Room } from '@rocket.chat/core-services'; import type { IDirectVideoConference, ILivechatVideoConference, @@ -19,14 +19,20 @@ import type { IStats, VideoConference, VideoConferenceCapabilities, + JoinableVideoConference, + VideoConferenceChatAccess, + VideoConferenceChatAccessMode, VideoConferenceCreateData, Optional, ExternalVideoConference, IVoIPVideoConference, } from '@rocket.chat/core-typings'; import { + UserStatus, VideoConferenceStatus, + hasJoinedVideoConference, isDirectVideoConference, + isInVideoConference, isGroupVideoConference, isLivechatVideoConference, } from '@rocket.chat/core-typings'; @@ -40,7 +46,12 @@ import type * as UiKit from '@rocket.chat/ui-kit'; import { Meteor } from 'meteor/meteor'; import { MongoInternals } from 'meteor/mongo'; -import { availabilityErrors } from '../../../lib/videoConference/constants'; +import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { resolveChatAccessMode } from '../../../lib/videoConference/chatAccess'; +import { conferenceNameFor } from '../../../lib/videoConference/conferenceName'; +import { availabilityErrors, CALL_FACES_SHOWN, shouldRingVideoConference } from '../../../lib/videoConference/constants'; +import { isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; +import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; import { callbacks } from '../../lib/callbacks'; @@ -48,6 +59,7 @@ import { i18n } from '../../lib/i18n'; import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging'; import { RocketChatAssets } from '../../lib/media/assets'; import { sendMessage } from '../../lib/messages/sendMessage'; +import { follow } from '../../lib/messaging/threads/functions'; import { metrics } from '../../lib/metrics/lib/metrics'; import { Push } from '../../lib/notifications/push/push'; import PushNotification from '../../lib/notifications/push-config/lib/PushNotification'; @@ -57,14 +69,22 @@ import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { updateCounter } from '../../lib/statistics/functions/updateStatsCounter'; import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; +import { videoConfPresence } from '../../lib/videoConfPresence'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { videoConfTypes } from '../../lib/videoConfTypes'; +import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; import { settings } from '../../settings'; const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; const logger = new Logger('VideoConference'); +/** + * How long a conference is kept alive after the last participant leaves, before it is ended. + * Long enough for a reload to land and cancel it, short enough that a call really over doesn't linger. + */ +const EMPTY_CALL_GRACE_MS = 10_000; + export class VideoConfService extends ServiceClassInternal implements IVideoConfService { protected name = 'video-conference'; @@ -125,7 +145,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf createdBy: caller, rid, providerName, - } as VideoConferenceCreateData; + }; if (data.type === 'videoconference') { data.title = title; @@ -495,6 +515,16 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf void api.broadcast('room.video-conference', { rid, callId }); } + /** + * Tells anyone watching the conference that something about it moved — its membership, its chat's room, or who + * can read that chat. Whichever it was, the answer on the other side is to read the conference again, so this + * is one signal rather than three: the call window needs it to know whether it is still waiting on anyone, and + * a participant's chat panel needs it to follow the chat. + */ + private notifyConferenceUpdate(callId: VideoConference['_id']): void { + void api.broadcast('video-conference.updated', { callId }); + } + private async endCall(callId: VideoConference['_id']): Promise { const call = await this.getUnfiltered(callId); if (!call) { @@ -505,13 +535,25 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runVideoConferenceChangedEvent(call._id); this.notifyVideoConfUpdate(call.rid, call._id); + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + await this.notifyUsersOfRoom(call.rid, '', 'end', { + callId: call._id, + rid: call.rid, + uid: call.createdBy._id, + }); + } + + // Ending the call ends it for whoever was still in it, and each of them is owed their status back. Nobody + // else reports their departure: the call is over, so there is no leave left to arrive. + await Promise.all(call.users.filter(isInVideoConference).map(({ _id }) => this.releaseBusyForCall(_id))); + if (call.type === 'direct') { return this.endDirectCall(call); } } private async expireCall(callId: VideoConference['_id']): Promise { - const call = await VideoConferenceModel.findOneById>(callId, { projection: { messages: 1 } }); + const call = await this.getUnfiltered(callId); if (!call) { return; } @@ -577,6 +619,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private async validateProvider(providerName: string): Promise { + // Embedded (built-in) providers like LiveKit are registered by core + // only when their prerequisites are satisfied (e.g. VideoConf_LiveKit_ + // Enabled + URL + API key + secret). Their presence in the registry + // IS the "fully configured" signal. Going through the apps-engine + // manager would fail because there's no app behind them. + if (videoConfProviders.getProviderCapabilities(providerName)?.embedded) { + return; + } const manager = await this.getProviderManager(); const configured = await manager.isFullyConfigured(providerName).catch(() => false); if (!configured) { @@ -752,19 +802,32 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runNewVideoConferenceEvent(callId); + // Being called makes you a member, exactly as being added to a group conference does. Without this the + // callee only appears once they answer, so nothing can tell "still ringing" from "nobody was called", + // and a call they missed leaves them no history entry. + await this.addAbsentMember(callId, calleeId); + await this.maybeCreateDiscussion(callId, user); const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null; if (!call) { throw new Error('failed-to-create-direct-call'); } - const url = await this.generateNewUrl(call); - await VideoConferenceModel.setUrlById(callId, url); + // Embedded providers (LiveKit) don't have an external URL to open — + // the call is rendered inline. Skip URL generation for them. + const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + if (!isEmbedded) { + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + } const messageId = await this.createMessage(call, user); call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call as IDirectVideoConference); + // After 40 seconds if the status is still "calling", we cancel the call automatically. setTimeout(async () => { try { @@ -783,8 +846,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } }, 40000); - await this.sendPushNotification(call, calleeId); - return { type: 'direct', callId, @@ -834,16 +895,25 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('failed-to-create-group-call'); } - const url = await this.generateNewUrl(call); - await VideoConferenceModel.setUrlById(callId, url); - - call.url = url; + // Embedded providers (LiveKit) render the call inline in Rocket.Chat — + // no URL handoff. Skip both URL generation and ringing notifications: + // the call shows up as an "active call" banner in the room and other + // participants tap to join. No incoming-call sound/modal. + const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + if (!isEmbedded) { + const url = await this.generateNewUrl(call); + await VideoConferenceModel.setUrlById(callId, url); + call.url = url; + } const messageId = await this.createMessage(call, useAppUser ? undefined : user); call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); - if (call.ringing) { + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call as IGroupVideoConference); + + if (call.ringing && !isEmbedded) { await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id }); } @@ -879,6 +949,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf call.messages.started = messageId; await VideoConferenceModel.setMessageById(callId, 'started', messageId); + // Auto-follow the thread for anyone who joined between call creation and message creation. + await this.autoFollowCallThreadForAllParticipants(call as ILivechatVideoConference); + return { type: 'livechat', callId, @@ -894,6 +967,31 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser); + // Embedded providers (LiveKit) don't return a URL — the client mounts + // the call inline via the embedded provider's React tree. We still + // track the per-participant join time so the cleanup cron + the + // raise-hand queue have something to work with. Returning an empty + // string tells the client there's no URL to open. + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (user) { + await VideoConferenceModel.addEmbeddedParticipant(call._id, { + id: user._id, + username: user.username, + displayName: user.name, + joinedAt: new Date(), + }); + + await this.notifyUsersOfRoom(call.rid, user._id, 'started', { + callId: call._id, + rid: call.rid, + uid: call.createdBy._id, + }); + + this.notifyUser(user._id, 'started', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + } + return ''; + } + return this.getUrl(call, user, options); } @@ -926,7 +1024,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf _id: call._id, type: call.type, rid: call.rid, - createdBy: call.createdBy as Required, + createdBy: call.createdBy, title, providerData: call.providerData, discussionRid: call.discussionRid, @@ -993,7 +1091,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf type: call.type, rid: call.rid, url: call.url, - createdBy: call.createdBy as Required, + createdBy: call.createdBy, providerData: { ...(call.providerData || {}), ...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) }, @@ -1026,6 +1124,13 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + // Embedded (built-in) providers have no apps-engine app behind them, + // so the provider-manager dispatch would be a no-op at best and + // throw at worst. Skip the lifecycle hook for them. + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + return; + } + return (await this.getProviderManager()).onNewVideoConference(call.providerName, call); } @@ -1044,6 +1149,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + return; + } + return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call); } @@ -1062,6 +1171,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + return; + } + return (await this.getProviderManager()).onUserJoin(call.providerName, call, user); } @@ -1075,26 +1188,647 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.addUserToDiscussion(call.discussionRid, _id); } - if (call.users.find((user) => user._id === _id)) { + // A user is in one call at a time, and this is where that becomes true rather than hoped for. A window that + // dies without reporting its departure — a crash, a killed tab — otherwise leaves its user counted as + // present forever, which both misreports them and keeps a finished call listed as occupied. + await this.leaveOtherCalls(call._id, _id); + + // Already in the call — nothing to record. This asks about presence, not about having joined at some + // point: a member who joined and left is joined-ever but absent, and returning here would leave their + // `leftAt` in place, reporting them as gone while they are back on the call. + const member = call.users.find((user) => user._id === _id); + if (member && isInVideoConference(member)) { return; } - await VideoConferenceModel.addUserById(call._id, { _id, username, name, avatarETag, ts }); + // Both writes are idempotent, and both are needed: the first covers someone who wasn't a member yet + // (it no-ops for an existing member), the second marks them present. Running both also closes the race + // where two joins land between the read above and the write. + await VideoConferenceModel.addMemberById(call._id, { _id, username, name, avatarETag, ts }); + await VideoConferenceModel.setUserJoinedById(call._id, _id, ts); + this.notifyConferenceUpdate(call._id); + + // In a call is busy, for as long as it lasts. + await this.claimBusyForCall(_id); + + // When persistent chat is in "thread" mode, auto-follow the call's chat + // thread so the participant receives thread notifications for messages + // sent during the call. `follow` uses $addToSet and is idempotent. + await this.autoFollowCallThread(call, _id); if (call.type === 'direct') { - return this.updateDirectCall(call as IDirectVideoConference, _id); + await this.ringCalleeOnCallerArrival(call, _id); + return this.updateDirectCall(call, _id); } this.notifyVideoConfUpdate(call.rid, call._id); } + /** + * Registers users as members of the conference without touching any room. Membership is what authorizes + * joining the call, so this is how someone outside the conference's room gets in — reading the chat is a + * separate concern, surfaced in the UI rather than decided here. + */ + public async addMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + { ring = true }: { ring?: boolean } = {}, + ): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + const users = await Users.find>>( + { username: { $in: usernames } }, + { projection: { username: 1, name: 1, avatarETag: 1 } }, + ).toArray(); + + const added: IUser['_id'][] = []; + const ts = new Date(); + + for (const user of users) { + // Already associated with the call — leave their entry (and any `joinedAt`) untouched. + if (call.users.some(({ _id }) => _id === user._id)) { + continue; + } + + await VideoConferenceModel.addMemberById(callId, { ...user, ts }); + added.push(user._id); + } + + if (added.length) { + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + } + + // The list being rung is just the people added, and the endpoint caps a single add at the ringing limit — + // so unlike starting a call in a large room, an add can always ring. Whether it does is the adder's to + // say: someone added to carry on later is not someone to interrupt now. + if (ring && shouldRingVideoConference(added.length)) { + await this.ringUsers(callId, call.rid, uid, added); + } + + return added; + } + + /** + * Records that a user dismissed the call instead of joining. + * + * This only writes to the member's entry — it never ends the conference, which is what separates + * declining a conference from rejecting a 1:1 call. A member who declines can still join afterwards. + * + * Someone rung as a room member has no entry yet, so one is created for them: without it there would be + * nowhere to record the decline. + */ + public async declineCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + if (!call.users.some(({ _id }) => _id === uid) && !(await this.addAbsentMember(callId, uid))) { + throw new Error('invalid-user'); + } + + await VideoConferenceModel.setUserDeclinedById(callId, uid); + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + } + + /** + * Rings members who aren't in the call, again — all of them, or the ones asked for. + * + * A ring is one-shot, so the caller of a call nobody picked up needs a way to try again — and adding the + * same person a second time won't do it, since they are already a member. Returns who was rung. + * + * Members who already left are rung too: they were there and are not now, which is exactly the case + * "call them back" is for. Anyone already in the call is never rung, whether or not they were asked for. + */ + public async ringMembers(uid: IUser['_id'], callId: VideoConference['_id'], userIds?: IUser['_id'][]): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + if (call.endedAt) { + return []; + } + + const requested = userIds?.length ? new Set(userIds) : undefined; + const absent = call.users + .filter((member) => member._id !== uid && !isInVideoConference(member) && (!requested || requested.has(member._id))) + .map(({ _id }) => _id); + + if (!shouldRingVideoConference(absent.length)) { + return []; + } + + await this.ringUsers(callId, call.rid, uid, absent); + + return absent; + } + + /** + * Associates a user with the call without marking them present — being a member is not being in the call. + * + * Two paths need it: being called, and declining a call you were only rung about as a room member. In both, + * the person has to exist on the call before anything — an answer, a decline, a history row — can be recorded + * against them. Says whether it found the user, which is the only thing the two callers disagree about. + */ + private async addAbsentMember(callId: VideoConference['_id'], uid: IUser['_id']): Promise { + const user = await Users.findOneById>>(uid, { + projection: { username: 1, name: 1, avatarETag: 1 }, + }); + if (!user) { + return false; + } + + await VideoConferenceModel.addMemberById(callId, user); + return true; + } + + /** Leaves every other call this user is still counted as being in. See `addUserToCall`. */ + private async leaveOtherCalls(callId: VideoConference['_id'], uid: IUser['_id']): Promise { + // Asking the database for "still in it" rather than reading every membership and sifting in memory. + const others = await VideoConferenceModel.find( + { + _id: { $ne: callId }, + endedAt: { $exists: false }, + users: { $elemMatch: { _id: uid, joined: { $ne: false }, leftAt: { $exists: false } } }, + }, + { projection: { _id: 1 } }, + ).toArray(); + + // One at a time in practice, so the cost is a read that usually finds nothing. + await Promise.all(others.map(({ _id }) => this.leaveCall(uid, _id))); + } + + /** + * The calls that are running right now and that this user may join. + * + * This is how a call is reached without having caught its ring — which matters because a ring is one-shot and + * a conference started in a room with more than ten subscribers rings nobody at all. + * + * Nothing new is stored to answer it: the conference records already hold membership, liveness and the room. + * The scan is over *running* conferences rather than over this user's rooms, so its cost follows how many + * calls are in progress — few — rather than how many rooms the user is in. + * + * A call is offered when the user is a member of it, or is in the room it belongs to. Room *membership* rather + * than room *access*: a public channel is readable by anyone, and a call in a channel the user never joined + * has no business in their sidebar. + * + * Calls nobody is in are left out. A conference only stops when someone ends it or the expiry cron reaches it, + * so without this an abandoned one would be advertised as joinable for a day. + */ + public async listJoinableCalls(uid: IUser['_id']): Promise { + const running = await VideoConferenceModel.find( + { endedAt: { $exists: false } }, + // `createdBy` is here because naming a direct call needs it — a call is named after a person, and for a + // member with no subscription that person is whoever started it. + { projection: { rid: 1, discussionRid: 1, users: 1, title: 1, type: 1, createdAt: 1, createdBy: 1 }, sort: { createdAt: -1 } }, + ).toArray(); + + const occupied = running.filter(({ users }) => users.some(isInVideoConference)); + + // One query for every room in play. It decides both halves of the answer: whether the user is in the room, + // and — for a direct message, which has no name of its own — what to call it, since a DM is named after the + // other person and that name lives on each side's own subscription. + const rids = [...new Set(occupied.flatMap(({ rid, discussionRid }) => [rid, discussionRid].filter((id): id is string => !!id)))]; + const subscriptions = new Map( + rids.length + ? (await Subscriptions.findByUserIdAndRoomIds(uid, rids, { projection: { rid: 1, name: 1, fname: 1, t: 1 } }).toArray()).map( + (sub) => [sub.rid, sub], + ) + : [], + ); + + const joinable = occupied.filter((call) => { + if (call.users.some(({ _id }) => _id === uid)) { + return true; + } + + return subscriptions.has(call.rid) || (!!call.discussionRid && subscriptions.has(call.discussionRid)); + }); + + return Promise.all( + joinable.map(async (call) => { + const member = call.users.find(({ _id }) => _id === uid); + const present = call.users.filter(isInVideoConference); + const subscription = subscriptions.get(call.discussionRid || call.rid) ?? subscriptions.get(call.rid); + + return { + callId: call._id, + // The room is the last resort, and only for a call named after a room in the first place — a + // direct call is named after a person, including for a member who has no subscription to read + // one from. `getRoomName` ends at the raw room id, which is nobody's idea of a name. + name: + conferenceNameFor(call, uid, subscription?.fname || subscription?.name, subscription?.t) || (await this.getRoomName(call.rid)), + createdAt: call.createdAt, + usersCount: present.length, + // A few of them travel with the call so the list can show faces. Capped here rather than at the + // reader, because a call in a busy channel would otherwise send a roster to draw three avatars. + participants: present.slice(0, CALL_FACES_SHOWN).map(({ _id, username, name }) => ({ _id, username, name })), + joined: !!member && isInVideoConference(member), + declined: !!member?.declined, + // Whether that ring is still live is the reader's to decide, so the moment is what travels. + ...(member?.ringingAt && { ringingAt: member.ringingAt }), + }; + }), + ); + } + + /** + * Rings a set of members: the in-product ring, the desktop notification that reaches someone who isn't + * looking at the app, and the record of when it happened — which is what lets every client tell a phone that + * is ringing now from one that was rung and ignored. + */ + private async ringUsers(callId: VideoConference['_id'], rid: IRoom['_id'], uid: IUser['_id'], memberIds: IUser['_id'][]): Promise { + memberIds.forEach((memberId) => this.notifyUser(memberId, 'ring', { callId, rid, uid })); + await VideoConferenceModel.setUsersRingingById(callId, memberIds); + this.notifyConferenceUpdate(callId); + + // The ring only reaches a client that is on screen, and it is one-shot. A desktop notification is what + // reaches someone who isn't looking at the app. + await this.notifyUsersAddedToConference(uid, memberIds, callId, rid); + } + + /** + * Rings the other side of a direct call when its caller arrives in it. + * + * Creating the call is not asking anyone to answer it: the caller lands on the preflight screen first, and + * being rung into a call whose caller is still choosing a camera means answering to an empty room. So the + * ring waits for them to actually enter — which is this moment. + * + * Only members who have never been rung, so rejoining doesn't ring anyone again; the call window's own + * "ring again" is how a second attempt is asked for. + */ + private async ringCalleeOnCallerArrival(call: IDirectVideoConference, uid: IUser['_id']): Promise { + if (call.createdBy._id !== uid) { + return; + } + + const absent = call.users.filter((user) => user._id !== uid && isUnaskedConferenceMember(user)); + if (!absent.length) { + return; + } + + await this.ringUsers( + call._id, + call.rid, + uid, + absent.map(({ _id }) => _id), + ); + + // The in-product ring only reaches a client that is on screen; a direct call is also worth a push. + await Promise.all(absent.map(({ _id }) => this.sendPushNotification(call, _id))); + } + + /** + * Records that a member left the call, and ends the conference once nobody is left in it. + * + * This is what gives a conference an end at all for providers that never report one — closing the call + * window is the only signal there is. Ending it is what writes everyone's call history, so without this a + * call sits at `STARTED` until the expiry cron notices it a day later. + * + * Leaving is not declining and not un-joining: membership and `joined` both stand, so the member keeps their + * history entry and can rejoin. + * + * The call is not ended the moment it empties. `pagehide` fires on a reload just as it does on a close, and + * the two are indistinguishable from it — so ending on the spot meant refreshing the call window killed the + * call. Instead the emptiness is confirmed after a grace period, which a rejoin cancels by simply being back + * in the call. That also absorbs a network blip taking the window down for a moment. + */ + public async leaveCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { + projection: { rid: 1, users: 1, endedAt: 1, providerName: 1, createdBy: 1 }, + }); + if (!call || call.endedAt) { + return; + } + + if (!call.users.some(({ _id }) => _id === uid)) { + return; + } + + const leftAt = new Date(); + await VideoConferenceModel.setUserLeftById(callId, uid, leftAt); + this.notifyVideoConfUpdate(call.rid, callId); + this.notifyConferenceUpdate(callId); + + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + await this.notifyUsersOfRoom(call.rid, uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + this.notifyUser(uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + } + + // Out of the call, so back to whatever status they had before it. + await this.releaseBusyForCall(uid); + + // Decide on the state we just wrote rather than the one we read, so the member who is leaving is counted + // as gone. Reading again would be a second round trip for the same answer. + const remaining = call.users.map((member) => (member._id === uid ? { ...member, leftAt } : member)); + if (remaining.some(isInVideoConference)) { + return; + } + + setTimeout(() => { + void this.endCallIfEmpty(callId).catch((err) => logger.error({ msg: 'Failed to end an empty conference', callId, err })); + }, EMPTY_CALL_GRACE_MS); + } + + /** + * Says the user is busy for as long as they are in a call, without overwriting the status they chose. + * + * A *claim* rather than a status. `internal` is the strongest source there is, so busy is what shows for as long + * as the call lasts; the status it displaced is stashed and handed back when the claim ends, which is how someone + * who set themselves away before the call is away again after it. A status the user sets *during* the call is + * queued the same way rather than displayed — the call is not overruled while it is happening, and their latest + * intent is what they are left with once it ends. + * + * Ended by id, so it can end in any order relative to a voice call's own claim: two `internal` claims stash for + * each other rather than one clobbering the other. + * + * Nothing here is allowed to break a call. Presence is a courtesy; joining is not. + */ + private async claimBusyForCall(uid: IUser['_id']): Promise { + try { + const user = await Users.findOneById>(uid, { projection: { language: 1 } }); + const lng = user?.language || settings.get('Language') || 'en'; + + await Presence.setActiveState(uid, { + statusDefault: UserStatus.BUSY, + statusText: i18n.t('Presence_status_on_a_call', { lng }), + statusSource: 'internal', + statusId: this.name, + }); + } catch (err) { + logger.warn({ msg: 'Failed to mark a user busy for a call', uid, err }); + } + } + + /** Gives the user their own status back. A no-op if something with a stronger claim has taken over since. */ + private async releaseBusyForCall(uid: IUser['_id']): Promise { + try { + await Presence.endActiveState(uid, this.name); + } catch (err) { + logger.warn({ msg: 'Failed to restore a user status after a call', uid, err }); + } + } + + /** + * Renews a member's presence lease: their call window telling us it is still in the call. + * + * Provider-agnostic by construction — the conference window is ours whoever runs the media, so this is the one + * presence signal that exists for every provider. See `lib/videoConference/presence` for why presence is a + * lease rather than a reported departure. + */ + public async renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + await VideoConferenceModel.renewUserPresenceById(callId, uid, new Date(), INFERRED_LEAVE_REASONS); + } + + /** + * Marks everyone whose presence lease has run out as having left, and ends the calls that empties. + * + * This is the durable half of leaving. `leaveCall` is the reported half: accurate, immediate, and impossible + * to rely on — it needs a live client talking to a live server, so it is lost exactly when the workspace goes + * down under a call that carries on in the provider. It is also lost by a crashed tab or a closed laptop, and + * the grace period `leaveCall` schedules for an emptied call is an in-process timer that a restart discards. + * Leases cover all of it, because their evidence lives in the database rather than in anyone's memory. + * + * Departures are stamped with the last evidence we had, never with the moment of the sweep — see + * `expiredPresenceLeases`. Callers must respect `isPresenceSweepDue` first: right after a restart every lease + * looks expired whether or not anyone actually left. + */ + public async expirePresenceLeases(now = new Date()): Promise { + for await (const call of VideoConferenceModel.findActiveWithMembers()) { + try { + // A provider that can say who is in its room is asked first, and its answer renews leases the same + // way a client's heartbeat does. Silence is not absence: `undefined` leaves the leases as they are. + const present = await videoConfPresence.getProbe(call.providerName)?.(call); + const users = present ? call.users.map((user) => (present.includes(user._id) ? { ...user, lastSeenAt: now } : user)) : call.users; + + if (present?.length) { + await VideoConferenceModel.renewUsersPresenceById(call._id, present, now); + } + + const expired = expiredPresenceLeases(users, now); + if (!expired.length) { + continue; + } + + for (const { uid, leftAt } of expired) { + logger.info({ msg: 'Presence lease expired', callId: call._id, uid, leftAt }); + await VideoConferenceModel.setUserLeftById(call._id, uid, leftAt, 'timeout'); + // Whoever stopped renewing is not in a call any more, whatever their client failed to say — and a + // status left on busy by a crashed tab is exactly the kind of thing nobody thinks to fix by hand. + await this.releaseBusyForCall(uid); + // Embedded providers keep a second per-participant record, and the two disagreeing is how a + // call ends up counted as occupied by one half of the code and empty by the other. + await VideoConferenceModel.markEmbeddedParticipantLeft(call._id, uid, leftAt); + } + + this.notifyVideoConfUpdate(call.rid, call._id); + this.notifyConferenceUpdate(call._id); + + // No second grace period: the lease *was* the grace period, and it is far longer than the one a + // reported departure gets. Anyone who came back renewed it and is not in `expired` at all. + const remaining = users.filter(({ _id }) => !expired.some((lease) => lease.uid === _id)); + if (!remaining.some(isInVideoConference)) { + await this.endCall(call._id); + } + } catch (err) { + // One unreachable provider or one malformed call must not stop the sweep for every other call. + logger.error({ msg: 'Failed to expire presence leases for a conference', callId: call._id, err }); + } + } + } + + /** Ends a conference only if it is still empty — a rejoin inside the grace period is what cancels it. */ + private async endCallIfEmpty(callId: VideoConference['_id']): Promise { + const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, endedAt: 1 } }); + if (!call || call.endedAt || call.users.some(isInVideoConference)) { + return; + } + + await this.endCall(callId); + } + + /** + * Where the conference's chat lives and which members can't read it, because membership deliberately grants + * no room access. Surfacing them is the point: the choice of how to fix it is offered once it actually + * matters, rather than being forced on whoever adds a participant — so this also reports what that choice + * is, since it depends on the room and on who is asking. + * + * Access isn't always a subscription question — a plain public channel is readable by anyone, so + * `getMembersWithoutRoomAccess` answers both that and the plain private-room case from one `Subscriptions` + * read instead of one authorization call per member. A team-owned, discussion, or ABAC-attributed room can + * grant access through paths a room+subscriptions read can't see (team membership, the parent room's own + * rules, an ABAC decision), so those still ask per member — getting one of those wrong is worse than the + * extra reads, and conferences are small. + */ + public async getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + return (await this.resolveChatAccess(uid, callId)).access; + } + + /** + * `getChatAccess`, plus the *usernames* of the members it decided about. + * + * The public shape carries ids, because that is what a client matches against the members it already holds. A + * room invite needs usernames — and they were in hand while the ids were being worked out, so resolving the + * access doesn't have to read the conference a second time to find them. + */ + private async resolveChatAccess( + uid: IUser['_id'], + callId: VideoConference['_id'], + ): Promise<{ access: VideoConferenceChatAccess; usernamesWithoutAccess: NonNullable[] }> { + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1, users: 1 } }); + if (!call) { + throw new Error('invalid-video-conference'); + } + + const rid = call.discussionRid || call.rid; + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Error('invalid-room'); + } + + const membersWithoutAccess = await this.getMembersWithoutRoomAccess( + room, + call.users.map(({ _id }) => _id), + ); + const withoutAccess = new Set(membersWithoutAccess); + + return { + access: { + rid, + name: room.fname || room.name || '', + type: room.t, + membersWithoutAccess, + // Ask the room whether it can take new members rather than testing for a DM: the room type owns that + // rule, and it accounts for cases a `t === 'd'` check would miss, like a federated DM that *can* grow. + canInvite: await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.INVITE, uid), + }, + usernamesWithoutAccess: call.users + .filter(({ _id }) => withoutAccess.has(_id)) + .map(({ username }) => username) + .filter((username): username is string => !!username), + }; + } + + /** + * A team-owned public channel can be read by any team member without them ever having subscribed to it, a + * discussion inherits its access from the parent room it was split off from, and a room carrying ABAC + * attributes can bypass subscriptions entirely — none of that is visible from this room's own subscriptions, + * so those keep asking `canAccessRoomIdAsync` once per member, exactly as before. + * + * Everything else reduces to one `Subscriptions` read for every member at once: a plain public channel (no + * team) is readable by anyone unless banned from it specifically, and a plain private room (group or DM) is + * readable only by whoever holds an actual, non-invited subscription to it. + */ + private async getMembersWithoutRoomAccess( + room: Pick, + memberIds: IUser['_id'][], + ): Promise { + if (!memberIds.length) { + return []; + } + + if ((room.t === 'c' && room.teamId) || room.prid || room.abacAttributes?.length) { + const access = await Promise.all(memberIds.map(async (_id) => ({ _id, allowed: await canAccessRoomIdAsync(room._id, _id) }))); + return access.filter(({ allowed }) => !allowed).map(({ _id }) => _id); + } + + const subscriptions = await Subscriptions.findByRoomIdAndUserIds(room._id, memberIds, { + projection: { 'u._id': 1, 'status': 1 }, + }).toArray(); + const statusByMember = new Map(subscriptions.map(({ u, status }) => [u._id, status])); + + if (room.t === 'c') { + return memberIds.filter((_id) => statusByMember.get(_id) === 'BANNED'); + } + + // A subscription with a `status` (invited, banned) doesn't count as one: only an existing, plain + // subscription does, the same as `canAccessRoomIdAsync` would find for a private room. + return memberIds.filter((_id) => !statusByMember.has(_id) || statusByMember.get(_id) !== undefined); + } + + /** + * Names a running group conference, for the person who started it. + * + * The name is what the provider is told to call the meeting and what the call is listed as everywhere it + * appears, so it is worth being able to set it once the call exists rather than only in the instant it is + * created. Only the creator: a title everyone in the call could rewrite is a title nobody can rely on. + * + * A direct call has no title of its own — it is named after the other person — so there is nothing to set. + */ + public async renameCall(uid: IUser['_id'], callId: VideoConference['_id'], title: string): Promise { + const call = await VideoConferenceModel.findOneById(callId, { + projection: { type: 1, rid: 1, createdBy: 1, endedAt: 1 }, + }); + if (!call || call.endedAt || !isGroupVideoConference(call)) { + throw new Error('error-invalid-video-conf'); + } + + if (call.createdBy._id !== uid) { + throw new Error('error-not-allowed'); + } + + const name = title.trim(); + if (!name) { + throw new Error('error-invalid-name'); + } + + await VideoConferenceModel.setTitleById(callId, name); + this.notifyVideoConfUpdate(call.rid, callId); + } + + /** + * Gives every member who can't read the chat access to it, either by bringing them into the room — which + * exposes its whole history — or by moving the chat to a discussion. Both are lossy in different ways, so + * the caller chooses; without a choice, the room's own rules decide. Returns the room the chat now lives in. + */ + public async shareChatWithMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + mode?: VideoConferenceChatAccessMode, + ): Promise { + const { + access: { rid, membersWithoutAccess, canInvite, type }, + usernamesWithoutAccess: usernames, + } = await this.resolveChatAccess(uid, callId); + if (!membersWithoutAccess.length) { + return rid; + } + + const resolved = resolveChatAccessMode({ mode, canInvite, type }); + if (!resolved) { + throw new Error('error-not-allowed'); + } + + if (resolved === 'discussion') { + // Moving the chat to a discussion announces the conference itself changed, which is what makes every + // participant's panel follow the chat to its new room. + return this.createConferenceDiscussionWithParticipants(uid, callId, usernames); + } + + const invitedRid = await this.addUsersToConferenceRoom(uid, callId, usernames); + + // Inviting leaves the conference record untouched — only who can read the chat changed — so nothing else + // tells the participants to look again. Without this their notice stays up until a reload. + this.notifyConferenceUpdate(callId); + + return invitedRid; + } + private async addAnonymousUser(call: Optional): Promise { await VideoConferenceModel.increaseAnonymousCount(call._id); } private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise { - // If it's an user that hasn't joined yet - if (call.ringing && !call.users.find(({ _id }) => _id === newUserId)) { + // If it's an user that hasn't joined yet — a member who was added but never joined still counts as not + // having joined, so the ring must keep going for them. + if (call.ringing && !call.users.some(({ _id, joined }) => _id === newUserId && hasJoinedVideoConference({ joined }))) { this.notifyUser(call.createdBy._id, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); if (newUserId !== call.createdBy._id) { this.notifyUser(newUserId, 'join', { rid: call.rid, uid: newUserId, callId: call._id }); @@ -1122,8 +1856,49 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return settings.get('VideoConf_Enable_Persistent_Chat') && settings.get('Discussion_enabled') && !encryptionEnforced; } + private getPersistentChatMode(): 'thread' | 'main_room' { + return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'thread'; + } + + /** + * Auto-follow the call's chat thread for a single user. Called when a + * participant joins the call, so they receive thread notifications for + * messages posted during the conference. Only applies when persistent + * chat is enabled in "thread" mode and the started message already + * exists. The underlying `follow` is idempotent ($addToSet). + */ + private async autoFollowCallThread(call: Optional, uid: IUser['_id']): Promise { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'thread') { + return; + } + + if (!call.messages.started) { + return; + } + + await follow({ tmid: call.messages.started, uid }); + } + + /** + * Auto-follow the call's chat thread for every participant already in + * the call. Called when `messages.started` is first set (i.e. the + * thread parent message has just been created) so that any user who + * joined before the message existed gets subscribed retroactively. + */ + private async autoFollowCallThreadForAllParticipants(call: VideoConference): Promise { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'thread') { + return; + } + + if (!call.messages.started || !call.users.length) { + return; + } + + await Promise.all(call.users.map(({ _id }) => follow({ tmid: call.messages.started!, uid: _id }))); + } + private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise { - if (!this.isPersistentChatEnabled()) { + if (!this.isPersistentChatEnabled() || this.getPersistentChatMode() !== 'main_room' || !settings.get('Discussion_enabled')) { return; } @@ -1144,17 +1919,226 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return; } + await this.createDiscussionForConference(this.getDiscussionDisplayName(), call, createdBy); + } + + private getDiscussionDisplayName(): string { const name = settings.get('VideoConf_Persistent_Chat_Discussion_Name') || i18n.t('[date] Video Call Chat'); - let displayName; const date = new Date().toISOString().substring(0, 10); - if (name.includes('[date]')) { - displayName = name.replace('[date]', date); - } else { - displayName = `${date} ${name}`; + return name.includes('[date]') ? name.replace('[date]', date) : `${date} ${name}`; + } + + // Creates a discussion off the conference's room and points the conference's `discussionRid` at it so + // the chat continues there without exposing the parent room's history to the new participants. For a + // DM (which can't grow past two people) the discussion keeps the DM members; for other rooms it keeps + // the room's current members. In both cases the newly selected users are added. + private async createConferenceDiscussionWithParticipants( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + ): Promise { + const [call, user] = await Promise.all([ + VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1 } }), + Users.findOneById(uid), + ]); + if (!call) { + throw new Error('invalid-video-conference'); + } + if (!user) { + throw new Error('invalid-user'); + } + + // Build from the room the chat is *currently* in, not the room the call started in — otherwise a second + // discussion would be derived from the original room and silently drop everyone added since the first + // one. One read also covers the walk up to the top-level room: `prid` is only set for a discussion. + const baseRoom = await Rooms.findOneById>(call.discussionRid || call.rid, { + projection: { t: 1, usernames: 1, prid: 1, teamId: 1 }, + }); + if (!baseRoom) { + throw new Error('invalid-room'); + } + + const parent = baseRoom.prid ? await this.getRoomForDiscussion(baseRoom.prid) : baseRoom; + const type = await roomCoordinator.getRoomDirectives(parent.t).getDiscussionType(parent); + if (!type) { + throw new Error('error-invalid-discussion-type'); + } + + // Carry over the current participants so they keep the chat: DMs expose them on the room doc, while + // channels/groups read them from the room's subscriptions (the conference's `users` list only holds + // people who already joined the call, so it's not a good proxy for the room's members). The newly + // selected users are added on top. + const existingMembers = + baseRoom.t === 'd' + ? baseRoom.usernames || [] + : (await Subscriptions.findByRoomIdWhenUsernameExists(baseRoom._id, { projection: { 'u.username': 1 } }).toArray()) + .map((subscription) => subscription.u.username) + .filter((username): username is string => !!username); + const members = [...new Set([...existingMembers, ...usernames])].filter(Boolean); + + const name = this.getDiscussionDisplayName(); + + const discussion = await createRoom( + type, + Random.id(), + user, + members, + false, + false, + { + fname: name, + prid: parent._id, + encrypted: false, + }, + { + creator: user._id, + }, + ); + + // Leave a "discussion created" pointer in the parent room so its members can follow along. + await Message.saveSystemMessage('discussion-created', parent._id, name, user, { drid: discussion._id }); + + // The conference's `rid` always stays the original room; the chat to display is driven by + // `discussionRid`. This sets it and announces the change so participants follow along. + await this.assignDiscussionToConference(callId, discussion._id); + + // Let the newly invited users know with a desktop notification; clicking it opens the discussion. + await this.notifyUsersInvitedToConference(user, usernames, callId, discussion); + + return discussion._id; + } + + // Adds the users to the conference's active room, so they get its history — the counterpart to + // `createConferenceDiscussionWithParticipants`. + private async addUsersToConferenceRoom( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + ): Promise { + const [call, user] = await Promise.all([ + VideoConferenceModel.findOneById(callId, { projection: { rid: 1, discussionRid: 1 } }), + Users.findOneById(uid), + ]); + if (!call) { + throw new Error('invalid-video-conference'); + } + if (!user) { + throw new Error('invalid-user'); + } + + // The active conference room is the discussion when one was created, otherwise the original room. + const rid = call.discussionRid || call.rid; + + const room = await Rooms.findOneById>(rid, { + projection: { t: 1, name: 1, fname: 1 }, + }); + if (!room) { + throw new Error('invalid-room'); } - await this.createDiscussionForConference(displayName, call, createdBy); + await addUsersToRoomMethod(uid, { rid, users: usernames }, user); + + // Let the added users know with a desktop notification; clicking it opens the room. + await this.notifyUsersInvitedToConference(user, usernames, callId, room); + + return rid; + } + + /** + * Tells people about a conference through the desktop, for the case the in-product ring can't reach: a + * backgrounded tab, or no client open at all. Clicking focuses the app, and the "Join call" action joins the + * conference itself. + * + * Whether it carries a **room** is the one thing that matters here, because that is what makes the click + * navigate. Someone invited *into* the room can be sent there; someone merely added to the call cannot — + * membership grants no room access, so the room behind the call may be one they can't open. + */ + private async notifyUsersAboutConference({ + recipients, + sender, + callId, + rid, + title, + room, + }: { + recipients: Pick[]; + sender: AtLeast; + callId: VideoConference['_id']; + rid: IRoom['_id']; + title: string; + /** Given only when the recipients can open it, which is what lets the notification navigate there. */ + room?: AtLeast; + }): Promise { + for (const recipient of recipients) { + const text = i18n.t('You_were_invited_to_a_conference', { lng: recipient.language }); + + void api.broadcast('notify.desktop', recipient._id, { + title, + text, + // Keep it on screen until acted on — a call is worth interrupting for. + requireInteraction: true, + actions: [{ action: 'join', title: i18n.t('Join_call', { lng: recipient.language }) }], + payload: { + _id: callId, + rid, + sender: { _id: sender._id, username: sender.username as string, name: sender.name }, + ...(room && { type: room.t, name: room.name }), + conferenceId: callId, + message: { msg: text }, + // The ringing popup plays the ringtone. Left unset this would also play the new-message sound, + // so a call announced itself as a message arriving. + audioNotificationValue: 'none', + }, + }); + } + } + + /** Tells the users just added to a conference that it is ringing for them. */ + private async notifyUsersAddedToConference( + adderId: IUser['_id'], + memberIds: IUser['_id'][], + callId: VideoConference['_id'], + rid: IRoom['_id'], + ): Promise { + const [adder, members] = await Promise.all([ + Users.findOneById>(adderId, { projection: { username: 1, name: 1 } }), + Users.find>({ _id: { $in: memberIds } }, { projection: { language: 1 } }).toArray(), + ]); + + if (!adder) { + return; + } + + await this.notifyUsersAboutConference({ + recipients: members, + sender: adder, + callId, + rid, + title: adder.name || adder.username || '', + }); + } + + /** Tells the users just invited into the conference's room about it; clicking takes them to that room. */ + private async notifyUsersInvitedToConference( + inviter: AtLeast, + usernames: NonNullable[], + callId: VideoConference['_id'], + room: AtLeast, + ): Promise { + const invitedUsers = await Users.find>( + { username: { $in: usernames } }, + { projection: { language: 1 } }, + ).toArray(); + + await this.notifyUsersAboutConference({ + recipients: invitedUsers, + sender: inviter, + callId, + rid: room._id, + title: room.fname || room.name || '', + room, + }); } private async getRoomForDiscussion( @@ -1222,7 +2206,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('invalid-room-id'); } - const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, messages: 1 } }); + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, messages: 1 } }); if (!call) { return; } @@ -1233,8 +2217,24 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setDiscussionRidById(callId, rid); } - if (room) { - await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id))); + try { + if (room) { + // Everyone involved with the call should land in the new discussion: the conference's members + // (including any added from outside the room) plus the original room's members, who were part of + // the conversation before the chat moved. Members who never joined the call are included on + // purpose — the discussion is where they catch up. + const roomMemberIds = (await Subscriptions.findByRoomId(call.rid, { projection: { 'u._id': 1 } }).toArray()).map(({ u }) => u._id); + const recipients = new Set([...call.users.map(({ _id }) => _id), ...roomMemberIds]); + + await Promise.all([...recipients].map((uid) => this.addUserToDiscussion(room._id, uid))); + } + } finally { + // Tell every participant's client that the conference's chat moved, so an open conference view + // can follow it. + this.notifyConferenceUpdate(callId); + // Also refresh the in-room conference message block, which listens on `notify-room/videoconf` + // (the same channel used when users join), so its "Join discussion" button updates. + this.notifyVideoConfUpdate(call.rid, callId); } } diff --git a/apps/meteor/tests/e2e/video-conference-ring.spec.ts b/apps/meteor/tests/e2e/video-conference-ring.spec.ts index 1901c592abd5e..fbf05b1d4434f 100644 --- a/apps/meteor/tests/e2e/video-conference-ring.spec.ts +++ b/apps/meteor/tests/e2e/video-conference-ring.spec.ts @@ -29,18 +29,25 @@ test.describe('video conference ringing', () => { await auxContext.page.close(); }); - test('should display call ringing in direct message', async () => { + test('should display call ringing in direct message', async ({ page }) => { await poHomeChannel.navbar.openChat('user2'); await auxContext.poHomeChannel.navbar.openChat('user1'); await test.step('should user1 calls user2', async () => { + // The caller's own window opens on the click that asked for it, which is what gives `window.open` the user + // activation browsers are entitled to demand. So the caller is in the call from that moment and the room + // is no longer "calling": what used to be a "Calling user2" popup in the room is that window now. + const callWindow = page.context().waitForEvent('page'); + await poHomeChannel.content.btnVideoCall.click(); await poHomeChannel.content.btnStartVideoCall.click(); - await expect(poHomeChannel.content.getVideoConfPopup('Calling user2')).toBeVisible(); + // Ringing runs from the caller's room page, not from that window, so the callee is rung either way. await expect(auxContext.poHomeChannel.content.getVideoConfPopup('Incoming call from user1')).toBeVisible(); - await auxContext.poHomeChannel.content.btnDeclineVideoCall.click(); + + // Closing it is the caller giving up, which is what leaves the call behind them for the step below. + await (await callWindow).close(); }); await test.step('should user1 be able to call user2 again ', async () => { diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts new file mode 100644 index 0000000000000..a955831260fc2 --- /dev/null +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts @@ -0,0 +1,24 @@ +import { isVideoConfShareChatProps } from '@rocket.chat/rest-typings'; +import { assert } from 'chai'; + +/** + * What this schema says that the others don't: a `mode` is optional, and when given it has to be one this server + * can actually act on. The "rejects a non-object", "requires the id" and "refuses extra properties" cases are + * `type: 'object'`, `required` and `additionalProperties: false` doing their job — ajv's, not ours. + */ +describe('isVideoConfShareChatProps', () => { + it('accepts a callId with nothing else, leaving the choice to the room', () => { + assert.isTrue(isVideoConfShareChatProps({ callId: 'callId' })); + }); + + it('accepts either way of sharing the chat', () => { + assert.isTrue(isVideoConfShareChatProps({ callId: 'callId', mode: 'invite' })); + assert.isTrue(isVideoConfShareChatProps({ callId: 'callId', mode: 'discussion' })); + }); + + // Silently doing the other thing would give away history nobody agreed to give away. + it('rejects a mode it cannot act on', () => { + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId', mode: 'whatever' })); + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId', mode: '' })); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts new file mode 100644 index 0000000000000..a1097a3472c08 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts @@ -0,0 +1,46 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { chatAccessLeadsWithDiscussion, resolveChatAccessMode } from '../../../../lib/videoConference/chatAccess'; + +describe('videoConference chat access', () => { + describe('chatAccessLeadsWithDiscussion', () => { + it('leads with the invite for a public channel, whose history is already open', () => { + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'c' })).to.be.false; + }); + + it('leads with the discussion where the invite would expose a history that was not open', () => { + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'p' })).to.be.true; + expect(chatAccessLeadsWithDiscussion({ canInvite: true, type: 'd' })).to.be.true; + }); + + it('leads with the discussion whenever the room cannot take new members, whatever its type', () => { + for (const type of ['c', 'p', 'd', 'l'] as IRoom['t'][]) { + expect(chatAccessLeadsWithDiscussion({ canInvite: false, type })).to.be.true; + } + }); + }); + + describe('resolveChatAccessMode', () => { + it('honours an explicit choice the room can carry out', () => { + expect(resolveChatAccessMode({ mode: 'invite', canInvite: true, type: 'c' })).to.equal('invite'); + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true, type: 'c' })).to.equal('discussion'); + }); + + it('honours a discussion even for a room that could have taken the members instead', () => { + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true, type: 'p' })).to.equal('discussion'); + }); + + // Falling back to the discussion would move the whole conversation on the strength of a request the + // room can't honour. Refusing leaves the decision with whoever asked. + it('refuses an invite the room cannot take, rather than quietly doing the other thing', () => { + expect(resolveChatAccessMode({ mode: 'invite', canInvite: false, type: 'd' })).to.be.null; + }); + + it('falls back to whichever action leads when no choice is made', () => { + expect(resolveChatAccessMode({ mode: undefined, canInvite: true, type: 'c' })).to.equal('invite'); + expect(resolveChatAccessMode({ mode: undefined, canInvite: true, type: 'p' })).to.equal('discussion'); + expect(resolveChatAccessMode({ mode: undefined, canInvite: false, type: 'd' })).to.equal('discussion'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts b/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts new file mode 100644 index 0000000000000..4d25565f9d66b --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/conferenceName.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; + +import { conferenceNameFor } from '../../../../lib/videoConference/conferenceName'; + +const rodrigo = { _id: 'rodrigo', name: 'Rodrigo Nascimento', username: 'rodrigo.nascimento' }; +const alice = { _id: 'alice', name: 'Alice', username: 'alice' }; +const cleiton = { _id: 'cleiton', name: 'Cleiton', username: 'cleiton' }; + +/** A call in a DM between Rodrigo and Alice, started by Rodrigo, with Cleiton added from outside. */ +const dmCall = { + type: 'direct' as const, + createdBy: rodrigo, + users: [alice, rodrigo, cleiton], +}; + +describe('conferenceNameFor', () => { + // The bug this exists for. Cleiton is a member of the call and has no subscription to the DM it happens in, by + // design — so the name fell through to the room, a DM room has neither `name` nor `fname`, and the last resort + // was the raw room id. What he needs to know is who is calling. + it('names a direct call after whoever started it, for someone added from outside', () => { + expect(conferenceNameFor(dmCall, 'cleiton')).to.equal('Rodrigo Nascimento'); + }); + + // The creator is not the answer for the creator: they know who they are. + it('names it after the other person when the viewer started it', () => { + expect(conferenceNameFor(dmCall, 'rodrigo')).to.equal('Alice'); + }); + + // Where a subscription exists it is the better answer: a DM is named per side, and that is where the name lives. + it("prefers the reader's own subscription", () => { + expect(conferenceNameFor(dmCall, 'alice', 'Rodrigo N.')).to.equal('Rodrigo N.'); + }); + + it('names a group conference by its title, whoever is asking', () => { + const group = { type: 'videoconference' as const, title: 'Sprint planning', createdBy: rodrigo, users: [rodrigo, alice] }; + + expect(conferenceNameFor(group, 'alice')).to.equal('Sprint planning'); + expect(conferenceNameFor(group, 'alice', 'the-channel')).to.equal('Sprint planning'); + }); + + // A non-ringing DM call is `type: 'videoconference'` with a title set from `room.fname`. That title is the + // creator's view of the room, so Alice sees her own name — wrong. When the room is a DM, the per-viewer + // subscription name wins over the title. + it('prefers the subscription name over the title in a DM room', () => { + const nonRingingDm = { type: 'videoconference' as const, title: 'Alice', createdBy: rodrigo, users: [rodrigo] }; + + expect(conferenceNameFor(nonRingingDm, 'alice', 'Rodrigo Nascimento', 'd')).to.equal('Rodrigo Nascimento'); + expect(conferenceNameFor(nonRingingDm, 'rodrigo', 'Alice', 'd')).to.equal('Alice'); + }); + + // Without a subscription (member added from outside), fall back to the other-party logic even in a DM + // videoconference, so the member sees the name of whoever started the call. + it('names a DM videoconference after the creator when no subscription exists', () => { + const nonRingingDm = { type: 'videoconference' as const, title: 'Alice', createdBy: rodrigo, users: [rodrigo] }; + + expect(conferenceNameFor(nonRingingDm, 'cleiton', undefined, 'd')).to.equal('Rodrigo Nascimento'); + }); + + // A room-named call is the caller's to resolve, because only the caller can read the room — and making that + // explicit is what keeps the room lookup off the path that doesn't need it. + it('answers with nothing when only the room can say', () => { + const untitled = { type: 'videoconference' as const, createdBy: rodrigo, users: [rodrigo, alice] }; + + expect(conferenceNameFor(untitled, 'alice')).to.equal(''); + }); + + it('falls back to a username when a member has no name', () => { + const call = { ...dmCall, createdBy: { _id: 'rodrigo', username: 'rodrigo.nascimento' } }; + + expect(conferenceNameFor(call, 'cleiton')).to.equal('rodrigo.nascimento'); + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts new file mode 100644 index 0000000000000..73c176fb36732 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts @@ -0,0 +1,118 @@ +import { VIDEO_CONF_RINGING_LIMIT, hasJoinedVideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { shouldRingVideoConference } from '../../../../lib/videoConference/constants'; +import { + canRingConferenceMember, + getConferenceMemberStatus, + isUnaskedConferenceMember, +} from '../../../../lib/videoConference/memberStatus'; + +const at = new Date('2026-08-02T10:00:00.000Z'); + +// `users[]` entries were only ever written on join before membership existed, so anything without the flag is +// historical data describing someone who did join. Reading those as "not joined" would make every past +// conference look empty. Every predicate below inherits that rule, which is why it is stated once, here. +describe('hasJoinedVideoConference', () => { + it('reads an explicit flag either way', () => { + expect(hasJoinedVideoConference({ joined: true })).to.be.true; + expect(hasJoinedVideoConference({ joined: false })).to.be.false; + }); + + it('treats an entry predating the flag as joined', () => { + expect(hasJoinedVideoConference({})).to.be.true; + expect(hasJoinedVideoConference({ joined: undefined })).to.be.true; + }); +}); + +describe('getConferenceMemberStatus', () => { + it('reports a member who is in the call as joined', () => { + expect(getConferenceMemberStatus({ joined: true })).to.equal('joined'); + }); + + it('reports a member who was added and has not answered as invited', () => { + expect(getConferenceMemberStatus({ joined: false })).to.equal('invited'); + }); + + it('reports a member who dismissed the call as declined', () => { + expect(getConferenceMemberStatus({ joined: false, declined: true })).to.equal('declined'); + }); + + it('reports a member who joined and left as left', () => { + expect(getConferenceMemberStatus({ joined: true, leftAt: at })).to.equal('left'); + }); + + // The entry accumulates rather than replaces, so the fields have to be read in order of what happened last. + it('prefers having joined over an earlier decline', () => { + expect(getConferenceMemberStatus({ joined: true, declined: true, declinedAt: at } as never)).to.equal('joined'); + }); + + it('prefers having left over an earlier decline, since they did answer', () => { + expect(getConferenceMemberStatus({ joined: true, declined: true, leftAt: at })).to.equal('left'); + }); +}); + +describe('canRingConferenceMember', () => { + const now = at.getTime(); + + it('will not ring someone already in the call', () => { + expect(canRingConferenceMember({ joined: true }, now)).to.be.false; + }); + + it('rings anyone who is not', () => { + expect(canRingConferenceMember({ joined: false }, now)).to.be.true; + expect(canRingConferenceMember({ joined: false, declined: true }, now)).to.be.true; + expect(canRingConferenceMember({ joined: true, leftAt: at }, now)).to.be.true; + }); + + // There is nothing to ask for while their phone is already ringing. + it('will not ring a member who is being rung right now', () => { + expect(canRingConferenceMember({ joined: false, ringingAt: new Date(now - 3_000) }, now)).to.be.false; + }); + + // A ring is one-shot and stops on its own, with nothing to announce that it has — so the offer comes back. + it('rings a member whose ring has since run out', () => { + expect(canRingConferenceMember({ joined: false, ringingAt: new Date(now - 30_000) }, now)).to.be.true; + }); + + // Declining is an answer: the phone has stopped, so calling back is immediately on the table. + it('rings a member who declined the ring that is still inside its window', () => { + const ringingAt = new Date(now - 3_000); + expect(canRingConferenceMember({ joined: false, ringingAt, declined: true, declinedAt: new Date(now - 1_000) }, now)).to.be.true; + }); + + // A decline recorded *before* this ring says nothing about it — they were rung again since. + it('will not ring a member whose decline predates the current ring', () => { + expect( + canRingConferenceMember({ joined: false, ringingAt: new Date(now - 3_000), declined: true, declinedAt: new Date(now - 60_000) }, now), + ).to.be.false; + }); +}); + +describe('isUnaskedConferenceMember', () => { + it('is true for someone nobody has asked yet', () => { + expect(isUnaskedConferenceMember({ joined: false })).to.be.true; + }); + + // All three mean they have been asked: their phone rang, they answered, or they turned it down. + it('is false once they have been rung, joined, or declined', () => { + expect(isUnaskedConferenceMember({ joined: false, ringingAt: at })).to.be.false; + expect(isUnaskedConferenceMember({ joined: true })).to.be.false; + expect(isUnaskedConferenceMember({ joined: false, declined: true })).to.be.false; + }); +}); + +describe('shouldRingVideoConference', () => { + // Ringing a large room would mean a broadcast per subscriber, so past a point a call rings nobody at all. + it('rings a list up to the limit, and none beyond it', () => { + expect(shouldRingVideoConference(1)).to.be.true; + expect(shouldRingVideoConference(VIDEO_CONF_RINGING_LIMIT)).to.be.true; + expect(shouldRingVideoConference(VIDEO_CONF_RINGING_LIMIT + 1)).to.be.false; + }); + + // Nobody to ring is not the same as a list small enough to ring — it saves a pointless broadcast when a + // conference starts in an empty room, or when every user in an add was already a member. + it('rings nobody for an empty list', () => { + expect(shouldRingVideoConference(0)).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts b/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts new file mode 100644 index 0000000000000..bf810e9127eb9 --- /dev/null +++ b/apps/meteor/tests/unit/lib/videoConference/presence.spec.ts @@ -0,0 +1,88 @@ +import type { IVideoConferenceUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; + +import { + PRESENCE_HEARTBEAT_MS, + PRESENCE_LEASE_MS, + expiredPresenceLeases, + isPresenceSweepDue, +} from '../../../../lib/videoConference/presence'; + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +const member = (overrides: Partial & Pick): IVideoConferenceUser => ({ + username: `${overrides._id}.user`, + name: overrides._id, + avatarETag: null, + ts, + joined: true, + joinedAt: ts, + ...overrides, +}); + +describe('expiredPresenceLeases', () => { + it('keeps a member whose lease is still good', () => { + const users = [member({ _id: 'fresh', lastSeenAt: at(PRESENCE_LEASE_MS) })]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS + 1))).to.deep.equal([]); + }); + + it('gives up on a member who stopped renewing', () => { + const users = [member({ _id: 'gone', lastSeenAt: ts })]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS))).to.deep.equal([{ uid: 'gone', leftAt: ts }]); + }); + + // The whole reason a watermark is kept rather than just a flag. A call recovered twenty minutes after the + // workspace went down must not add twenty minutes to everyone's call history: the last evidence is the honest + // answer, and it happens to land at about the moment the lights went out. + it('dates the departure from the last evidence, not from the sweep', () => { + const lastSeenAt = at(60_000); + const users = [member({ _id: 'gone', lastSeenAt })]; + + const [expired] = expiredPresenceLeases(users, at(20 * 60_000)); + + expect(expired.leftAt).to.deep.equal(lastSeenAt); + }); + + // A conference that was already running when leases arrived has members with nothing but a join to go on. + // Reading that as the last evidence is what lets the sweep clear ghosts left behind by the old behaviour. + it('falls back to the join, then to the membership, for entries written before leases existed', () => { + const joinedAt = at(60_000); + + expect(expiredPresenceLeases([member({ _id: 'old', joinedAt })], at(60_000 + PRESENCE_LEASE_MS))).to.deep.equal([ + { uid: 'old', leftAt: joinedAt }, + ]); + expect(expiredPresenceLeases([member({ _id: 'older', joinedAt: undefined })], at(PRESENCE_LEASE_MS))).to.deep.equal([ + { uid: 'older', leftAt: ts }, + ]); + }); + + // Presence is joined-and-not-left, so the two states that aren't presence have no lease to lose. Reporting + // them would rewrite a departure the member reported themselves with a later, invented one. + it('ignores members who never joined or have already left', () => { + const users = [ + member({ _id: 'invited', joined: false, joinedAt: undefined }), + member({ _id: 'left', lastSeenAt: ts, leftAt: at(1_000) }), + ]; + + expect(expiredPresenceLeases(users, at(PRESENCE_LEASE_MS * 2))).to.deep.equal([]); + }); +}); + +describe('isPresenceSweepDue', () => { + // The guard that makes leases survivable across a restart: from the database, "everyone left" and "we were not + // here to be told" are the same picture, and only the clock tells them apart. + it('holds off until a process has been up for a full lease', () => { + expect(isPresenceSweepDue(0)).to.be.false; + expect(isPresenceSweepDue(PRESENCE_LEASE_MS - 1)).to.be.false; + expect(isPresenceSweepDue(PRESENCE_LEASE_MS)).to.be.true; + }); + + // The grace period is only useful if everyone still in a call gets to renew inside it — several times over, + // since a browser throttles a hidden window's timers to roughly one a minute. + it('waits long enough for a surviving window to renew', () => { + expect(PRESENCE_LEASE_MS / PRESENCE_HEARTBEAT_MS).to.be.at.least(3); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts b/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts new file mode 100644 index 0000000000000..d687f333ffaec --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/videoConfAccess.spec.ts @@ -0,0 +1,54 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const canAccessRoomIdAsyncMock = sinon.stub(); + +const { canAccessConference } = p.noCallThru().load('../../../../server/lib/videoConfAccess', { + './authorization/canAccessRoom': { canAccessRoomIdAsync: canAccessRoomIdAsyncMock }, +}); + +type Call = Pick; + +const callWith = (memberIds: string[], overrides: Partial = {}): Call => + ({ + rid: 'room1', + users: memberIds.map((_id) => ({ _id })), + ...overrides, + }) as Call; + +describe('canAccessConference', () => { + beforeEach(() => { + canAccessRoomIdAsyncMock.reset(); + canAccessRoomIdAsyncMock.resolves(false); + }); + + // The regression this exists for. A conference started in a DM, joined by a third person: they are a member of + // the call and have no subscription to the DM, by design. Checking the room instead of the membership refused + // them the credentials for their own call — they saw themselves alone with controls that did nothing. + it('admits a member who has no access to the call’s room', async () => { + expect(await canAccessConference(callWith(['dm-one', 'dm-two', 'added']), 'added')).to.be.true; + expect(canAccessRoomIdAsyncMock.called, 'membership settles it without asking about the room').to.be.false; + }); + + it('admits someone who can see the room the call started in', async () => { + canAccessRoomIdAsyncMock.withArgs('room1', 'onlooker').resolves(true); + + expect(await canAccessConference(callWith(['host']), 'onlooker')).to.be.true; + }); + + // A conference's chat can move to a discussion whose members have no access to the parent room, so that + // discussion is its own way in. + it('admits someone who can see the discussion the chat moved to', async () => { + canAccessRoomIdAsyncMock.withArgs('discussion1', 'discussion-member').resolves(true); + + expect(await canAccessConference(callWith(['host'], { discussionRid: 'discussion1' }), 'discussion-member')).to.be.true; + }); + + it('refuses a stranger, and anyone not signed in', async () => { + expect(await canAccessConference(callWith(['host']), 'stranger')).to.be.false; + expect(await canAccessConference(callWith(['host']), undefined)).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts new file mode 100644 index 0000000000000..ef873b01c40fa --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -0,0 +1,149 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, commonServiceStubs, resetAll } from './testHarness'; +import { PRESENCE_LEASE_MS } from '../../../../../lib/videoConference/presence'; + +/** + * The claim this suite is about is made through `Presence`, which the shared harness has no stub for — so this one + * assembles `@rocket.chat/core-services` itself rather than teaching every other spec about presence. + */ +const proxyquire = require('proxyquire'); + +let fixture: VideoConference; + +const PresenceMock = { + setActiveState: sinon.stub().resolves(true), + endActiveState: sinon.stub().resolves(true), +}; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), + setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } + }), + renewUsersPresenceById: sinon.stub().resolves(), + markEmbeddedParticipantLeft: sinon.stub().resolves(), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves({ _id: 'joiner', language: 'en' }) }; + +const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast: sinon.stub().resolves() }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + Presence: PresenceMock, + }, + '@rocket.chat/models': { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + }, + }, + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false, CALL_FACES_SHOWN: 2 }, +}); + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +describe('VideoConfService presence while in a call', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + PresenceMock.setActiveState, + PresenceMock.endActiveState, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.setUserJoinedById, + ); + PresenceMock.setActiveState.resolves(true); + }); + + // Being in a call is being busy, and saying so is what stops people ringing someone mid-conversation. + it('claims busy when a member joins', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + + await service.addUser('call1', 'joiner'); + + expect(PresenceMock.setActiveState.calledOnce).to.be.true; + const [uid, claim] = PresenceMock.setActiveState.firstCall.args; + expect(uid).to.equal('joiner'); + expect(claim).to.include({ statusDefault: UserStatus.BUSY, statusSource: 'internal', statusId: 'video-conference' }); + }); + + // A *claim* rather than a status: the presence service keeps whatever it displaced and hands it back, so the + // status someone chose before the call is the status they have after it. Ending by id is what lets a voice call's + // own claim end in either order relative to this one. + it('ends the claim by id when they leave, so their own status returns', async () => { + fixture = buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(PresenceMock.endActiveState.calledWith('leaver', 'video-conference')).to.be.true; + }); + + // The departure nobody reported: a crashed tab leaves a status on busy, which is exactly the sort of thing + // nobody thinks to put right by hand. + it('ends the claim for a member whose presence lease ran out', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(PresenceMock.endActiveState.calledWith('gone', 'video-conference')).to.be.true; + }); + + // When the call itself ends there is no leave left to arrive, so everyone still in it is owed their status back. + it('ends the claim for everyone still in a call that ends', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'present' }), + buildMember({ _id: 'left-earlier', leftAt: at(-60_000) }), + buildMember({ _id: 'never-joined', joined: false, joinedAt: undefined }), + ]); + + await service.endCall('call1'); + + const released = PresenceMock.endActiveState.args.map((args: any[]) => args[0] as string); + expect(released).to.include('present'); + expect(released).to.not.include('left-earlier'); + expect(released).to.not.include('never-joined'); + }); + + // Presence is a courtesy. A presence service that is down, or slow, or unlicensed must not be able to stop + // someone joining a call. + it('lets the join through when presence cannot be set', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + PresenceMock.setActiveState.rejects(new Error('presence is unavailable')); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts new file mode 100644 index 0000000000000..73e3c21c7b0a3 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts @@ -0,0 +1,141 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus, isInVideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; + +// Mirrors `ringMembers.spec.ts`'s approach: `fixture` is the single canonical record and +// `VideoConference.findOneById` hands out a clone of it on every call, regardless of projection. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + // Mirrors the model: an entry is pushed as *not* present, whatever the caller passed. Getting this wrong + // would leave fixture members with no `joined` flag at all, which every reader treats as joined. + addMemberById: sinon.stub().callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }), + setUserDeclinedById: sinon.stub().callsFake(async (_callId: string, uid: string) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).declined = true; + } + }), + setDataById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), +}; + +// `declineCall` reads `Users.findOneById` only for someone with no existing `users[]` entry (a room member +// rung who never had a membership entry created for them). +const UsersMock = { + findOneById: sinon.stub().resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }), +}; + +const broadcastStub = sinon.stub().resolves(); + +const VideoConfService = createService({ + broadcast: broadcastStub, + models: { Users: UsersMock, VideoConference: VideoConferenceModelMock }, +}); + +// The one broadcast `declineCall` sends besides the room update: `video-conference.updated`, which is what tells +// an open call window to re-read the conference — and so its membership. +const conferenceUpdatedCalls = (): { callId: string }[] => + broadcastStub.args.filter(([channel]) => channel === 'video-conference.updated').map(([, payload]) => payload as { callId: string }); + +describe('VideoConfService.declineCall', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserDeclinedById, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + UsersMock.findOneById, + broadcastStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + VideoConferenceModelMock.addMemberById.callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }); + VideoConferenceModelMock.setUserDeclinedById.callsFake(async (_callId: string, uid: string) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).declined = true; + } + }); + UsersMock.findOneById.resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }); + broadcastStub.resolves(); + }); + + it("records the decline on the caller's own entry", async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(VideoConferenceModelMock.setUserDeclinedById.calledOnceWith('call1', 'decliner')).to.be.true; + const decliner = fixture.users.find((user) => user._id === 'decliner'); + expect(decliner?.declined).to.be.true; + }); + + // This is what separates declining a conference from rejecting a 1:1 call — a decline must never end the + // conference or write call history. + it('never ends the conference: no endedAt/status write, no call-history insert', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(fixture.endedAt).to.be.undefined; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(VideoConferenceModelMock.setDataById.called).to.be.false; + expect(VideoConferenceModelMock.setStatusById.called).to.be.false; + }); + + // A user rung as a room member (never added to `users[]`) has no membership entry — one must be created so + // there is somewhere to record the decline. + it('creates an entry for someone who has none, without marking them present', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + UsersMock.findOneById.resolves({ _id: 'roomMember', username: 'roomMember.user', name: 'Room Member' }); + + await service.declineCall('roomMember', 'call1'); + + expect(VideoConferenceModelMock.addMemberById.calledOnce).to.be.true; + const [callId, member] = VideoConferenceModelMock.addMemberById.firstCall.args; + expect(callId).to.equal('call1'); + expect(member).to.include({ _id: 'roomMember' }); + + // Turning a call down is not being in it. + const created = fixture.users.find(({ _id }) => _id === 'roomMember'); + expect(created && isInVideoConference(created)).to.be.false; + + expect(VideoConferenceModelMock.setUserDeclinedById.calledOnceWith('call1', 'roomMember')).to.be.true; + }); + + // A member who declines can still join afterwards — the existing entry's `joined` flag must be left alone, + // only `declined` changes. + it("leaves an existing entry's joined flag alone", async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + const decliner = fixture.users.find((user) => user._id === 'decliner'); + expect(decliner?.joined).to.equal(false); + expect(decliner?.declined).to.be.true; + }); + + // Open call windows re-read the conference's membership off this broadcast. + it('announces the change with an updated broadcast', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'decliner', joined: false, joinedAt: undefined })]); + + await service.declineCall('decliner', 'call1'); + + const updates = conferenceUpdatedCalls(); + expect(updates).to.have.length(1); + expect(updates[0]).to.deep.equal({ callId: 'call1' }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts new file mode 100644 index 0000000000000..799e5f71ec4f6 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -0,0 +1,181 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; +import { PRESENCE_LEASE_MS } from '../../../../../lib/videoConference/presence'; + +const ts = new Date('2026-08-02T10:00:00.000Z'); +const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); + +/** The one canonical record, as in `leaveCall.spec`: reads are copies of it and the write stubs mutate it. */ +let fixture: VideoConference; + +/** What the provider answers when asked who is in the room, or `undefined` for "no answer". */ +let present: string[] | undefined; +let probe: sinon.SinonStub | undefined; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + // A real cursor is async-iterable, which is how the sweep walks it. + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } + }), + renewUsersPresenceById: sinon.stub().resolves(), + markEmbeddedParticipantLeft: sinon.stub().resolves(), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().callsFake(async (_callId: string, status: VideoConference['status']) => { + fixture.status = status; + }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves(null) }; + +const VideoConfService = createService({ + models: { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + }, + overrides: { + '../../lib/videoConfPresence': { videoConfPresence: { getProbe: () => probe } }, + }, +}); + +describe('VideoConfService.expirePresenceLeases', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + present = undefined; + probe = undefined; + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.findActiveWithMembers, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.renewUsersPresenceById, + VideoConferenceModelMock.markEmbeddedParticipantLeft, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + ); + }); + + // The case this exists for: the workspace was down while the call carried on in the provider, so the leave + // never reached anyone. Nothing was reported and nothing had to be — the missing renewals are the evidence. + it('marks a member whose lease ran out as having left', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'staying', lastSeenAt: at(0) }), + buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), + ]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.calledOnce).to.be.true; + const [callId, uid, leftAt, reason] = VideoConferenceModelMock.setUserLeftById.firstCall.args; + expect({ callId, uid, reason }).to.deep.equal({ callId: 'call1', uid: 'gone', reason: 'timeout' }); + expect(leftAt).to.deep.equal(at(-PRESENCE_LEASE_MS)); + }); + + // Both records of who is in the call have to agree, or one half of the code counts the call as occupied while + // the other counts it as empty. + it('records the departure against the embedded participant list too', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.markEmbeddedParticipantLeft.calledWith('call1', 'gone', at(-PRESENCE_LEASE_MS))).to.be.true; + }); + + it('leaves a member who is still renewing alone', async () => { + fixture = buildGroupCall([buildMember({ _id: 'staying', lastSeenAt: at(-1_000) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + + // The call has to *end*, not just empty: ending is what writes everyone's history, and a call left open is a + // call the room keeps offering to join. No second grace period — the lease was one, and a long one. + it('ends the call when the last lease expires', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + it('keeps the call open while anyone is still in it', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'staying', lastSeenAt: at(0) }), + buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), + ]); + + await service.expirePresenceLeases(at(0)); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + + describe('when the provider can say who is in the room', () => { + beforeEach(() => { + probe = sinon.stub().callsFake(async () => present); + }); + + // Why asking the provider is worth anything at all: a window that isn't in front has its timers throttled + // by the browser, so the member most likely to look absent is someone listening while they work. + it('holds on to a member the provider can see, whatever their heartbeat did', async () => { + present = ['throttled']; + fixture = buildGroupCall([buildMember({ _id: 'throttled', lastSeenAt: at(-PRESENCE_LEASE_MS * 2) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + expect(VideoConferenceModelMock.renewUsersPresenceById.calledWith('call1', ['throttled'], at(0))).to.be.true; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + + // Silence is not absence. A provider we cannot reach must not be able to empty a call — that would turn + // our own network trouble into everyone else's departure. + it('changes nothing about a lease when the provider cannot be asked', async () => { + present = undefined; + fixture = buildGroupCall([buildMember({ _id: 'staying', lastSeenAt: at(-1_000) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.renewUsersPresenceById.called).to.be.false; + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + }); + + // An empty array is the provider stating the room is empty, unlike `undefined`. It renews nobody, so the + // leases decide — which they do at their own pace rather than instantly. + it('lets the leases decide when the provider reports an empty room', async () => { + present = []; + fixture = buildGroupCall([buildMember({ _id: 'fresh', lastSeenAt: at(-1_000) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.renewUsersPresenceById.called).to.be.false; + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + }); + + // One unreachable provider, or one malformed call, must not stop the sweep for every other call. + it('carries on when the probe throws', async () => { + probe = sinon.stub().rejects(new Error('LiveKit is unreachable')); + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts new file mode 100644 index 0000000000000..33096e5938043 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/getChatAccess.spec.ts @@ -0,0 +1,141 @@ +import type { IRoom, VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; + +// `getChatAccess` reads the conference once, then decides per room whether it can answer from a single +// `Subscriptions` read or has to fall back to asking `canAccessRoomIdAsync` once per member — so each test +// configures a `fixture` (the conference) and a `room` (returned by `Rooms.findOneById`), and asserts both the +// resulting `membersWithoutAccess`/`canInvite` and which of the two access paths actually ran. +let fixture: VideoConference; +let room: Pick; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), +}; + +const RoomsMock = { + findOneById: sinon.stub().callsFake(async () => ({ ...room })), +}; + +const canAccessRoomIdAsyncStub = sinon.stub(); + +const findByRoomIdAndUserIdsStub = sinon.stub(); +const SubscriptionsMock = { + findByRoomIdAndUserIds: findByRoomIdAndUserIdsStub, +}; + +// `canInvite` comes from the room directives, not from access itself — a DM can't take new members, anything +// else can, which is enough to tell the two apart without pulling in the real `roomCoordinator`. +const allowMemberActionStub = sinon.stub().callsFake(async (targetRoom: Pick) => targetRoom.t !== 'd'); + +// The two room-access paths and the invite rule are what this suite is about, so those come from the spec +// rather than from the harness's inert defaults. +const VideoConfService = createService({ + models: { + VideoConference: VideoConferenceModelMock, + Rooms: RoomsMock, + Subscriptions: SubscriptionsMock, + }, + overrides: { + '../../lib/authorization/canAccessRoom': { canAccessRoomIdAsync: canAccessRoomIdAsyncStub }, + '../../lib/rooms/roomCoordinator': { + roomCoordinator: { getRoomDirectives: () => ({ allowMemberAction: allowMemberActionStub, getDiscussionType: () => 'p' }) }, + }, + }, +}); + +describe('VideoConfService.getChatAccess', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + RoomsMock.findOneById, + canAccessRoomIdAsyncStub, + findByRoomIdAndUserIdsStub, + allowMemberActionStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + RoomsMock.findOneById.callsFake(async () => ({ ...room })); + canAccessRoomIdAsyncStub.reset(); + findByRoomIdAndUserIdsStub.reset(); + findByRoomIdAndUserIdsStub.returns({ toArray: sinon.stub().resolves([]) }); + allowMemberActionStub.callsFake(async (targetRoom: Pick) => targetRoom.t !== 'd'); + }); + + // A plain public channel (no team) is readable by anyone — the whole point of the fast path is that this + // case costs one `Subscriptions` read (to catch a ban), never a per-member authorization call. + it('treats every member of a public channel as having access, except one explicitly banned', async () => { + fixture = buildGroupCall([buildMember({ _id: 'alice' }), buildMember({ _id: 'bob' }), buildMember({ _id: 'banned' })]); + room = { _id: 'room1', t: 'c', name: 'general', fname: 'general' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'banned' }, status: 'BANNED' }]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess).to.deep.equal(['banned']); + expect(result.type).to.equal('c'); + expect(canAccessRoomIdAsyncStub.called).to.be.false; + expect(findByRoomIdAndUserIdsStub.calledOnce).to.be.true; + expect(findByRoomIdAndUserIdsStub.firstCall.args[0]).to.equal('room1'); + expect(findByRoomIdAndUserIdsStub.firstCall.args[1].sort()).to.deep.equal(['alice', 'banned', 'bob']); + expect(result.canInvite).to.be.true; + }); + + // A public channel that belongs to a private team can be read through team membership alone, with no + // subscription to this specific channel — a case a `Subscriptions`-only read can't see. This has to keep + // asking the real per-member check rather than guessing from this room's own subscriptions. + it('falls back to asking per member for a public channel that belongs to a team', async () => { + fixture = buildGroupCall([buildMember({ _id: 'teamMember' }), buildMember({ _id: 'outsider' })]); + room = { _id: 'room1', t: 'c', name: 'team-channel', fname: 'team-channel', teamId: 'team1' }; + canAccessRoomIdAsyncStub.callsFake(async (_rid: string, uid: string) => uid === 'teamMember'); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess).to.deep.equal(['outsider']); + expect(canAccessRoomIdAsyncStub.callCount).to.equal(2); + expect(canAccessRoomIdAsyncStub.args.map(([, uid]) => uid).sort()).to.deep.equal(['outsider', 'teamMember']); + expect(findByRoomIdAndUserIdsStub.called).to.be.false; + }); + + // A private group only grants access to its subscribers — this is the other shape the fast path covers, + // one `Subscriptions` read standing in for what would otherwise be a `canAccessRoomIdAsync` call each. + it('treats only subscribed members of a private group as having access', async () => { + fixture = buildGroupCall([buildMember({ _id: 'member' }), buildMember({ _id: 'invited' }), buildMember({ _id: 'stranger' })]); + room = { _id: 'room1', t: 'p', name: 'private-group', fname: 'private-group' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([ + { u: { _id: 'member' }, status: undefined }, + { u: { _id: 'invited' }, status: 'INVITED' }, + ]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + expect(result.membersWithoutAccess.sort()).to.deep.equal(['invited', 'stranger']); + expect(canAccessRoomIdAsyncStub.called).to.be.false; + expect(findByRoomIdAndUserIdsStub.calledOnce).to.be.true; + expect(result.canInvite).to.be.true; + }); + + // A DM behaves like any other private room for access — subscription required — but can't take new + // members, which is what `canInvite` reports back. + it('treats only subscribed members of a DM as having access, and reports it as not invitable', async () => { + fixture = buildGroupCall([buildMember({ _id: 'member' }), buildMember({ _id: 'neverJoinedRoom' })]); + room = { _id: 'room1', t: 'd', name: 'member.other', fname: '' }; + findByRoomIdAndUserIdsStub.returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'member' }, status: undefined }]), + }); + + const result = await service.getChatAccess('caller', 'call1'); + + // `neverJoinedRoom` is a conference member who was never in the room at all: no subscription document + // exists for them, which must read the same as an explicitly denied one. + expect(result.membersWithoutAccess).to.deep.equal(['neverJoinedRoom']); + expect(result.canInvite).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts new file mode 100644 index 0000000000000..660f0b154c4ef --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -0,0 +1,238 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; + +/** Must match the constant defined in the service. */ +const EMPTY_CALL_GRACE_MS = 10_000; + +// `VideoConference.findOneById` is hit more than once per `leaveCall` → `endCall` flow, with different +// projections (`leaveCall` reads `{ rid, users, endedAt }`, `endCall`'s `getUnfiltered` reads everything). A +// real DB would answer both from the same document, so `fixture` is the single canonical record and the +// mutating model methods below write into it, regardless of the projection asked for. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + // `endCall`'s `getUnfiltered` is `VideoConfService.getUnfiltered`, which itself just calls + // `VideoConference.findOneById(callId)` with no projection — there's no separate model method to stub. + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } + }), + setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { + Object.assign(fixture, data); + }), + setStatusById: sinon.stub().callsFake(async (_callId: string, status: VideoConference['status']) => { + fixture.status = status; + }), + find: sinon.stub().returns({ toArray: async () => [] }), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), +}; + +const UsersMock = { + findOneById: sinon.stub().resolves(null), +}; + +const VideoConfService = createService({ + models: { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + }, + // This suite is about what happens when a call empties, so the ringing the service would otherwise do on a + // join is stubbed out of the way. + overrides: { + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false }, + }, +}); + +describe('VideoConfService.leaveCall', () => { + let service: any; + + let clock: sinon.SinonFakeTimers; + + /** The call empties, then the grace period passes with nobody having come back. */ + const leaveAndSettle = async (uid: string) => { + await service.leaveCall(uid, 'call1'); + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + }; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: false }); + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.setDataById, + VideoConferenceModelMock.setStatusById, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + }); + + afterEach(() => { + clock.restore(); + }); + + // The reported bug: leaving the last-standing spot in a call must end it and leave every member a + // history entry, not just silently mark the leaver as gone. `creator` already left earlier, so `other` + // is genuinely the last one still in the call — this is what makes it "the last participant leaves" + // rather than just "one of several leaves". + it('ends the call when the last participant leaves', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'creator', leftAt: new Date('2026-01-01T00:30:00.000Z') }), + buildMember({ _id: 'other' }), + ]); + + await leaveAndSettle('other'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(fixture.endedAt).to.be.instanceOf(Date); + }); + + // Someone leaving while others remain must not end the call for them. + it('marks the member as left without ending the call when others remain', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'other' })]); + + await service.leaveCall('other', 'call1'); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(fixture.endedAt).to.be.undefined; + + const leaver = fixture.users.find((user) => user._id === 'other'); + expect(leaver?.leftAt).to.be.instanceOf(Date); + }); + + it('ends a direct (1:1) conference when the last participant leaves', async () => { + fixture = buildDirectCall([ + buildMember({ _id: 'creator', leftAt: new Date('2026-01-01T00:30:00.000Z') }), + buildMember({ _id: 'other' }), + ]); + + await leaveAndSettle('other'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + // A conference that already ended (already carries `endedAt`) must not be re-processed at all — this is + // the guard `leaveCall` itself applies before touching anything. + it('does not re-process a conference that already has endedAt', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'other', leftAt: new Date() })], { + status: VideoConferenceStatus.ENDED, + endedAt: new Date('2026-01-01T01:00:00.000Z'), + }); + + await service.leaveCall('creator', 'call1'); + + expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + }); + + // A member who was added to the conference but never joined (`joined: false`) has no active presence in + // the call — they must not hold it open once the only member who actually joined leaves. + it('ends the call when the leaver is the only joined member, even with an unjoined member still on the roster', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'neverJoined', joined: false, joinedAt: undefined })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); + + // `pagehide` fires on a reload exactly as it does on a close, so ending the moment the call empties meant + // refreshing the call window killed the call. Coming back inside the grace period must cancel it. + it('does not end the call when the last participant comes back inside the grace period', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await service.leaveCall('creator', 'call1'); + + // The rejoin: what the client's own join does to the entry, which is all isInVideoConference reads. + const rejoiner = fixture.users.find((user) => user._id === 'creator'); + delete rejoiner?.leftAt; + + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + expect(fixture.endedAt).to.be.undefined; + }); + + it('still ends the call when nobody comes back', async () => { + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await service.leaveCall('creator', 'call1'); + expect(fixture.endedAt, 'ended before the grace period elapsed').to.be.undefined; + + await clock.tickAsync(EMPTY_CALL_GRACE_MS + 1); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + }); +}); + +describe('VideoConfService one call at a time', () => { + let clock: sinon.SinonFakeTimers; + let service: any; + + /** The conferences the model answers about, by id — a join reads the one being joined and any other it finds. */ + let calls: Record; + + beforeEach(() => { + clock = sinon.useFakeTimers({ shouldAdvanceTime: false }); + service = new VideoConfService(); + calls = {}; + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUserLeftById, + VideoConferenceModelMock.find, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserJoinedById, + UsersMock.findOneById, + ); + VideoConferenceModelMock.findOneById.callsFake(async (callId: string) => calls[callId]); + UsersMock.findOneById.resolves({ _id: 'joiner', username: 'joiner.user', name: 'Joiner', avatarETag: null }); + }); + + afterEach(() => { + clock.restore(); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + UsersMock.findOneById.resolves(null); + }); + + /** `other` is a call this user is in, alongside the `wanted` one they are about to join. */ + const joinWhileIn = async (other: VideoConference) => { + calls = { wanted: buildGroupCall([buildMember({ _id: 'host' })], { _id: 'wanted' }), [other._id]: other }; + VideoConferenceModelMock.find.returns({ toArray: async () => [other] }); + + await service.addUser('wanted', 'joiner'); + }; + + // A window that dies without reporting its departure — a crash, a killed tab — leaves its user counted as + // present forever, which both misreports them and keeps a finished call listed as occupied. Joining anything + // is the moment that can be put right. + it('leaves the call a joining user is still counted as being in', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + expect(VideoConferenceModelMock.setUserLeftById.calledWith('stale', 'joiner')).to.be.true; + }); + + it('joins the wanted call all the same', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('wanted', 'joiner')).to.be.true; + }); + + // The query is the whole rule, so it is what this asserts: another call, still running, and one this user is + // *present* in. Membership of a call already left is not presence in it — leaving it again would write a later + // `leftAt` over the real one — and an entry with no `joined` flag predates the flag and counts as present. + it('asks only about the calls it should leave', async () => { + await joinWhileIn(buildGroupCall([buildMember({ _id: 'joiner' })], { _id: 'stale' })); + + const [query] = VideoConferenceModelMock.find.firstCall.args; + expect(query).to.deep.equal({ + _id: { $ne: 'wanted' }, + endedAt: { $exists: false }, + users: { $elemMatch: { _id: 'joiner', joined: { $ne: false }, leftAt: { $exists: false } } }, + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts new file mode 100644 index 0000000000000..b3678c2a1985f --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/listJoinableCalls.spec.ts @@ -0,0 +1,278 @@ +import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, createService, resetAll } from './testHarness'; +import { CALL_FACES_SHOWN } from '../../../../../lib/videoConference/constants'; + +const me = 'me'; + +let running: VideoConference[] = []; +let subscribedRids: string[] = []; +let subscriptionNames: Record = {}; + +/** + * Answers with only the fields the query asked for, as a database would. + * + * Not pedantry: handing back the whole fixture hides a projection that forgot a field the code goes on to read, + * and the failure lands in production as an endpoint that throws — which is exactly what happened here when + * naming a call started needing `createdBy`. + */ +const project = >(doc: T, projection?: Record): Partial => + projection ? (Object.fromEntries(Object.entries(doc).filter(([key]) => key === '_id' || projection[key])) as Partial) : { ...doc }; + +const VideoConferenceModelMock = { + find: sinon.stub().callsFake((_query: unknown, options?: { projection?: Record }) => ({ + toArray: async () => running.map((call) => project({ ...call, users: [...call.users] }, options?.projection)), + })), +}; + +const SubscriptionsMock = { + findByUserIdAndRoomIds: sinon.stub().callsFake((_uid: string, rids: string[]) => ({ + toArray: async () => rids.filter((rid) => subscribedRids.includes(rid)).map((rid) => ({ rid, fname: subscriptionNames[rid] })), + })), +}; + +const RoomsMock = { + findOneById: sinon.stub().callsFake(async (rid: string) => ({ _id: rid, name: `name-of-${rid}`, fname: `name-of-${rid}` })), +}; + +const VideoConfService = createService({ + models: { VideoConference: VideoConferenceModelMock, Subscriptions: SubscriptionsMock, Rooms: RoomsMock }, +}); + +describe('VideoConfService.listJoinableCalls', () => { + let service: any; + + before(() => { + // The service reads settings at construction in some paths; nothing here depends on them. + service = new VideoConfService(); + }); + + beforeEach(() => { + resetAll(VideoConferenceModelMock.find, SubscriptionsMock.findByUserIdAndRoomIds, RoomsMock.findOneById); + running = []; + subscribedRids = []; + subscriptionNames = {}; + }); + + after(() => { + sinon.restore(); + }); + + // The two ways in, and they are the same pair the endpoints authorize with: being a member of the call, or + // being in the room it belongs to. + it('offers a call the user is a member of, even with no subscription to its room', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })])]; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['call1']); + }); + + it('offers a call in a room the user is in, even without membership of the call', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['call1']); + }); + + // A public channel is readable by anyone, so room *access* would put calls from channels the user never joined + // in their sidebar. Being in the room is the line. + it('does not offer a call the user has no claim to', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'somewhere-else' })]; + + const calls = await service.listJoinableCalls(me); + + expect(calls).to.deep.equal([]); + }); + + it('follows the chat into a discussion when that is the room the user is in', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'parent', discussionRid: 'the-discussion' })]; + subscribedRids = ['the-discussion']; + + const calls = await service.listJoinableCalls(me); + + expect(calls).to.have.length(1); + }); + + // A conference only stops when someone ends it or the expiry cron reaches it, so an abandoned one would + // otherwise be advertised as joinable for a day. + it('leaves out a call nobody is in', async () => { + running = [ + buildGroupCall([buildMember({ _id: 'other', leftAt: new Date() })], { rid: 'channel', _id: 'empty' }), + buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', _id: 'occupied' }), + ]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.map(({ callId }: { callId: string }) => callId)).to.deep.equal(['occupied']); + }); + + // One query for every room in play, rather than one per call: it answers both whether the user is in the room + // and what a nameless room should be called. + it('asks about every room in play at once', async () => { + running = [ + buildGroupCall([buildMember({ _id: me })], { rid: 'mine', _id: 'a-member-of' }), + buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', _id: 'by-room' }), + ]; + subscribedRids = ['channel']; + + await service.listJoinableCalls(me); + + expect(SubscriptionsMock.findByUserIdAndRoomIds.callCount).to.equal(1); + const [, rids] = SubscriptionsMock.findByUserIdAndRoomIds.firstCall.args; + expect(rids).to.deep.equal(['mine', 'channel']); + }); + + it('says nothing when no call is running', async () => { + const calls = await service.listJoinableCalls(me); + + expect(calls).to.deep.equal([]); + expect(SubscriptionsMock.findByUserIdAndRoomIds.called).to.be.false; + }); + + describe('what each row says', () => { + it('counts only the people who are in the call', async () => { + running = [ + buildGroupCall( + [ + buildMember({ _id: 'present' }), + buildMember({ _id: 'gone', leftAt: new Date() }), + buildMember({ _id: me, joined: false, joinedAt: undefined }), + ], + { rid: 'channel' }, + ), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(1); + }); + + // Joining another call means leaving this one, so the caller has to be able to tell which one that is. + it('says whether the user is in it', async () => { + running = [ + buildGroupCall([buildMember({ _id: me })], { rid: 'channel', _id: 'in-it' }), + buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })], { + rid: 'channel', + _id: 'not-in-it', + }), + ]; + subscribedRids = ['channel']; + + const calls = await service.listJoinableCalls(me); + + expect(calls.find(({ callId }: { callId: string }) => callId === 'in-it')?.joined).to.be.true; + expect(calls.find(({ callId }: { callId: string }) => callId === 'not-in-it')?.joined).to.be.false; + }); + + // The sidebar hides these and the call history keeps them, so the row has to carry the fact either way. + it('says whether the user declined it', async () => { + const decliner: IVideoConferenceUser = buildMember({ _id: me, joined: false, joinedAt: undefined, declined: true }); + running = [buildGroupCall([buildMember({ _id: 'other' }), decliner], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.declined).to.be.true; + }); + + // Faces, not just a number: the row shows who is already in there, and the count is what a "+3" comes from. + // The list shows faces rather than a number, so a few of the people travel with the call — capped here, + // because a call in a busy channel would otherwise send a whole roster to draw three avatars. + it('carries a few of the people in it, and says how many there are altogether', async () => { + running = [ + buildGroupCall( + [buildMember({ _id: 'one' }), buildMember({ _id: 'two' }), buildMember({ _id: 'three' }), buildMember({ _id: 'four' })], + { + rid: 'channel', + }, + ), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(4); + expect(call.participants).to.have.length(CALL_FACES_SHOWN); + expect(call.participants.map(({ _id }: { _id: string }) => _id)).to.deep.equal(['one', 'two']); + // Enough to draw a face with, and nothing else — a payload is not a place to publish a roster. + expect(Object.keys(call.participants[0]).sort()).to.deep.equal(['_id', 'name', 'username']); + }); + + // Faces are of the people who are *in* the call, not of everyone invited to it. + it('carries nobody who is not in the call', async () => { + running = [ + buildGroupCall([buildMember({ _id: 'here' }), buildMember({ _id: 'invited', joined: false, joinedAt: undefined })], { + rid: 'channel', + }), + ]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.participants.map(({ _id }: { _id: string }) => _id)).to.deep.equal(['here']); + }); + + it('counts nobody who is not in the call', async () => { + running = [buildGroupCall([buildMember({ _id: 'present' }), buildMember({ _id: 'gone', leftAt: new Date() })], { rid: 'channel' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.usersCount).to.equal(1); + }); + + it('names a group conference by its title', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', title: 'Sprint planning' })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('Sprint planning'); + }); + + // A direct message has no name of its own — it is named after the other person, and that name lives on + // each side's own subscription. Falling back to the room would show a raw id. + it("names a direct call from the reader's own subscription", async () => { + running = [buildDirectCall([buildMember({ _id: 'other' })], { rid: 'the-dm' })]; + subscribedRids = ['the-dm']; + subscriptionNames = { 'the-dm': 'Alice Liddell' }; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('Alice Liddell'); + }); + + // A member added from outside the room has no subscription to name it from, and the room cannot help: a DM + // room carries neither `name` nor `fname`, so this used to end at `getRoomName`'s last resort and show the + // reader a raw room id. Whoever started the call is who they want named. + it('names a direct call after whoever started it when there is no subscription', async () => { + running = [ + buildDirectCall([buildMember({ _id: 'other' }), buildMember({ _id: me, joined: false, joinedAt: undefined })], { rid: 'the-dm' }), + ]; + + const [call] = await service.listJoinableCalls(me); + + // `buildDirectCall` names the creator "Creator User" — the point is that it is a person, not the room. + expect(call.name).to.equal('Creator User'); + expect(RoomsMock.findOneById.called, 'no room lookup is needed to name a call after a person').to.be.false; + }); + + // The group case still ends at the room, which is the right answer for a call named after one. + it('falls back to the room for a group conference with no title', async () => { + running = [buildGroupCall([buildMember({ _id: 'other' })], { rid: 'channel', title: undefined as unknown as string })]; + subscribedRids = ['channel']; + + const [call] = await service.listJoinableCalls(me); + + expect(call.name).to.equal('name-of-channel'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts new file mode 100644 index 0000000000000..47d172637d37a --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/renameCall.spec.ts @@ -0,0 +1,76 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, createService, resetAll } from './testHarness'; + +let call: VideoConference | null = null; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => call), + setTitleById: sinon.stub().resolves(), +}; + +const broadcast = sinon.stub().resolves(); + +const VideoConfService = createService({ broadcast, models: { VideoConference: VideoConferenceModelMock } }); + +describe('VideoConfService.renameCall', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + resetAll(VideoConferenceModelMock.findOneById, VideoConferenceModelMock.setTitleById, broadcast); + call = buildGroupCall([buildMember({ _id: 'creator' })]); + }); + + it('names the conference', async () => { + await service.renameCall('creator', 'call1', 'Release planning'); + + expect(VideoConferenceModelMock.setTitleById.calledWith('call1', 'Release planning')).to.be.true; + }); + + // The name reaches the room's own call UI, which reads the conference rather than being handed the change. + it('tells the room the conference changed', async () => { + await service.renameCall('creator', 'call1', 'Release planning'); + + expect(broadcast.calledWith('room.video-conference', { rid: 'room1', callId: 'call1' })).to.be.true; + }); + + it('keeps only what was typed, without the whitespace around it', async () => { + await service.renameCall('creator', 'call1', ' Release planning '); + + expect(VideoConferenceModelMock.setTitleById.firstCall.args[1]).to.equal('Release planning'); + }); + + it('refuses a name that is nothing but whitespace', async () => { + await expect(service.renameCall('creator', 'call1', ' ')).to.be.rejectedWith('error-invalid-name'); + expect(VideoConferenceModelMock.setTitleById.called).to.be.false; + }); + + // A title everyone in the call could rewrite is a title nobody can rely on. + it('refuses anyone but the person who started the call', async () => { + await expect(service.renameCall('someone-else', 'call1', 'Release planning')).to.be.rejectedWith('error-not-allowed'); + expect(VideoConferenceModelMock.setTitleById.called).to.be.false; + }); + + // A direct call is named after the other person, per viewer — there is no one title to set. + it('refuses a direct call', async () => { + call = buildDirectCall([buildMember({ _id: 'creator' })]); + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); + + it('refuses a call that has already ended', async () => { + call = buildGroupCall([buildMember({ _id: 'creator' })], { endedAt: new Date('2026-01-01T01:00:00.000Z') }); + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); + + it('refuses a call that does not exist', async () => { + call = null; + + await expect(service.renameCall('creator', 'call1', 'Release planning')).to.be.rejectedWith('error-invalid-video-conf'); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts new file mode 100644 index 0000000000000..40dbd33f9fef1 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -0,0 +1,363 @@ +import type { IDirectVideoConference, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { isInVideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, resetAll, ringedUserIds } from './testHarness'; + +/** + * Who gets rung, and when. + * + * One suite for the three ways it happens — asking again from the members panel, adding people to a call, and a + * direct call's callee being rung when its caller finally walks in — because they all answer the same question and + * all answer it through the same broadcast. They were three files that each loaded the whole service and each + * rebuilt the same broadcast filter to read the answer out of. + */ + +// The single canonical record; `findOneById` hands out a clone of it, whatever projection is asked for. +let fixture: VideoConference; + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + setUsersRingingById: sinon.stub().callsFake(async (_callId: string, uids: string[], ringingAt: Date) => { + fixture.users.forEach((user) => { + if (uids.includes(user._id)) { + (user as IVideoConferenceUser).ringingAt = ringingAt; + } + }); + }), + // Mirrors the model: an entry is pushed as *not* present, whatever the caller passed. Getting this wrong + // would leave fixture members with no `joined` flag at all, which every reader treats as joined. + addMemberById: sinon.stub().callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }), + setUserJoinedById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +// `notifyUsersAddedToConference` reads the adder and the rung members straight off `Users` and broadcasts a +// desktop notification for each — it must not throw for that to happen, so both calls need to resolve +// something shaped like a real user. +const UsersMock = { + findOneById: sinon.stub().resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }), + find: sinon.stub().returns({ toArray: sinon.stub().resolves([]) }), +}; + +const broadcastStub = sinon.stub().resolves(); + +// Deliberately NOT overriding '../../../lib/videoConference/constants' — the ringing-limit guard +// (`shouldRingVideoConference`, capped at `VIDEO_CONF_RINGING_LIMIT`) is what two of the tests below exercise, +// so it has to be the real implementation. +const VideoConfService = createService({ + broadcast: broadcastStub, + models: { VideoConference: VideoConferenceModelMock, Users: UsersMock }, +}); + +/** + * `notifyUsersAddedToConference` broadcasts one `notify.desktop` per added member, via + * `api.broadcast('notify.desktop', memberId, notification)` — a 3-arg call, unlike the 2-arg ring broadcast. + * `audioNotificationValue`/room identity live under the notification's own nested `payload` property. + */ +const desktopNotifications = (): { memberId: string; payload: Record }[] => + broadcastStub.args + .filter(([channel]) => channel === 'notify.desktop') + .map(([, memberId, notification]) => ({ + memberId: memberId as string, + payload: (notification as { payload: Record }).payload, + })); + +const buildUser = (id: string) => ({ _id: id, username: `${id}.user`, name: id, avatarETag: null }); + +let service: any; + +beforeEach(() => { + service = new VideoConfService(); + resetAll( + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.setUsersRingingById, + VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.addMemberById, + UsersMock.findOneById, + UsersMock.find, + broadcastStub, + ); + VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + VideoConferenceModelMock.addMemberById.callsFake(async (_callId: string, member: IVideoConferenceUser) => { + fixture.users.push({ ...member, joined: false }); + }); + UsersMock.findOneById.resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }); + UsersMock.find.returns({ toArray: sinon.stub().resolves([]) }); + broadcastStub.resolves(); +}); + +describe('VideoConfService.ringMembers', () => { + // The base case: a member added to the call but who never answered has no active presence, so a second + // ring is the only way to reach them. + it('rings members who were never in the call, and nobody else', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'neverJoined1', joined: false, joinedAt: undefined }), + buildMember({ _id: 'neverJoined2', joined: false, joinedAt: undefined }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result.sort()).to.deep.equal(['neverJoined1', 'neverJoined2']); + expect(ringedUserIds(broadcastStub).sort()).to.deep.equal(['neverJoined1', 'neverJoined2']); + }); + + // "Call them back" is exactly this shape: they were on the call and aren't anymore. `isInVideoConference` + // says `joined: true` with a `leftAt` is not currently present, so they must be rung the same as anyone + // who never picked up. + it('rings a member who joined the call and then left it', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'wentQuiet', joined: true, leftAt: new Date('2026-01-01T00:15:00.000Z') }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal(['wentQuiet']); + expect(ringedUserIds(broadcastStub)).to.deep.equal(['wentQuiet']); + }); + + // Someone already on the call has no reason to be interrupted by a ring meant for people who aren't there. + it('does not ring a member who is currently in the call', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'stillHere', joined: true }), + buildMember({ _id: 'absent', joined: false, joinedAt: undefined }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal(['absent']); + expect(ringedUserIds(broadcastStub)).to.not.include('stillHere'); + }); + + // The caller is the one asking for the retry, not a target of it — this has to hold even for a caller + // entry that would otherwise read as absent (e.g. written with `joined: false`), since nothing else in + // `ringMembers` special-cases the caller's own membership shape. + it('never rings the caller themselves, even if their own entry looks absent', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller', joined: false, joinedAt: undefined }), + buildMember({ _id: 'absent', joined: false, joinedAt: undefined }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.not.include('caller'); + expect(ringedUserIds(broadcastStub)).to.not.include('caller'); + }); + + // Nobody absent means nothing to do — this is also what a call with a full house looks like after + // everyone's already answered. + it('returns an empty array when nobody is absent', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'other', joined: true })]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // A conference that already ended is not something you can still ring people into — `ringMembers` must + // bail out before even looking at who's absent. + it('returns an empty array and rings nobody for a conference that has already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'absent', joined: false, joinedAt: undefined })], { + endedAt: new Date('2026-01-01T01:00:00.000Z'), + }); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // The cap itself is pinned on `shouldRingVideoConference` in `tests/unit/lib/videoConference`; what matters + // here is that this path is wired to it, and that tripping it suppresses the ring entirely rather than + // ringing the first ten. + it('rings nobody when the number of absent members exceeds the ringing limit', async () => { + const absentMembers: IVideoConferenceUser[] = Array.from({ length: 11 }, (_, index) => + buildMember({ _id: `absent${index}`, joined: false, joinedAt: undefined }), + ); + fixture = buildGroupCall([buildMember({ _id: 'caller' }), ...absentMembers]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // The members panel rings one person at a time, so the caller says who — everyone else absent is left alone. + it('rings only the members asked for', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'wanted', joined: false, joinedAt: undefined }), + buildMember({ _id: 'other', joined: false, joinedAt: undefined }), + ]); + + const result = await service.ringMembers('caller', 'call1', ['wanted']); + + expect(result).to.deep.equal(['wanted']); + expect(ringedUserIds(broadcastStub)).to.deep.equal(['wanted']); + }); + + // Being asked for doesn't override being present: ringing someone who is already on the call is noise. + it('will not ring a requested member who is in the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'present' })]); + + const result = await service.ringMembers('caller', 'call1', ['present']); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + it('rings everyone absent when no member is named', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'one', joined: false, joinedAt: undefined }), + buildMember({ _id: 'two', joined: false, joinedAt: undefined }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result.sort()).to.deep.equal(['one', 'two']); + }); +}); + +describe('VideoConfService.addMembers', () => { + // Added, not arrived: adding somebody to a call is not answering it for them, so nothing here may mark them + // as being in it. + it('registers each named user as a member without marking them present, and returns the ids added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1'), buildUser('newUser2')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user', 'newUser2.user']); + + expect(result.sort()).to.deep.equal(['newUser1', 'newUser2']); + expect(VideoConferenceModelMock.addMemberById.callCount).to.equal(2); + VideoConferenceModelMock.addMemberById.args.forEach(([callId]) => expect(callId).to.equal('call1')); + expect(VideoConferenceModelMock.addMemberById.args.map(([, member]) => member._id).sort()).to.deep.equal(['newUser1', 'newUser2']); + + const added = fixture.users.filter(({ _id }) => ['newUser1', 'newUser2'].includes(_id)); + expect(added).to.have.length(2); + added.forEach((member) => expect(isInVideoConference(member)).to.be.false); + }); + + // Overwriting an existing entry would wipe out whatever `joinedAt`/`declined` state the member already + // has — this is the guard that stops an add from clobbering a member who is already present. + it('skips a user who already has a users[] entry, leaving their existing state untouched', async () => { + const existingJoinedAt = new Date('2026-01-01T00:10:00.000Z'); + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'already', joined: true, joinedAt: existingJoinedAt, declined: true }), + ]); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('already')]) }); + + const result = await service.addMembers('caller', 'call1', ['already.user']); + + expect(result).to.deep.equal([]); + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + + const existing = fixture.users.filter((user) => user._id === 'already'); + expect(existing).to.have.length(1); + expect(existing[0].joinedAt).to.equal(existingJoinedAt); + expect(existing[0].declined).to.be.true; + }); + + it('rings everyone actually added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1'), buildUser('newUser2')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user', 'newUser2.user']); + + expect(result.sort()).to.deep.equal(['newUser1', 'newUser2']); + expect(ringedUserIds(broadcastStub).sort()).to.deep.equal(['newUser1', 'newUser2']); + }); + + // Nobody was actually added (every requested user was already a member) — there is nobody new to ring. + it('rings nobody when nobody was added', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'already' })]); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('already')]) }); + + const result = await service.addMembers('caller', 'call1', ['already.user']); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // A call must not announce itself with the new-message sound (`audioNotificationValue: 'none'`), and its + // click must not try to open a room the member may not be able to see (no `payload.name`). + it('sends a silent desktop notification with no room name for each added member', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); + const newUsers = [buildUser('newUser1')]; + UsersMock.find.returns({ toArray: sinon.stub().resolves(newUsers) }); + UsersMock.findOneById.resolves({ _id: 'caller', username: 'caller.user', name: 'Caller User' }); + + await service.addMembers('caller', 'call1', ['newUser1.user']); + + const notifications = desktopNotifications(); + expect(notifications).to.have.length(1); + expect(notifications[0].memberId).to.equal('newUser1'); + expect(notifications[0].payload).to.include({ audioNotificationValue: 'none' }); + expect(notifications[0].payload).to.not.have.property('name'); + }); +}); + +// Creating the call is not asking anyone to answer it: the caller lands on the preflight first, and being rung +// into a call whose caller is still choosing a camera means answering to an empty room. +describe('VideoConfService: ringing a direct call when its caller arrives', () => { + const directCall = (callee: Partial = {}): IDirectVideoConference => + buildDirectCall([ + buildMember({ _id: 'creator', joined: false, joinedAt: undefined }), + buildMember({ _id: 'callee', joined: false, joinedAt: undefined, ...callee }), + ]); + + beforeEach(() => { + fixture = directCall(); + UsersMock.findOneById.callsFake(async (uid: string) => ({ _id: uid, username: uid, name: uid, avatarETag: null })); + }); + + it('rings the callee when the caller joins', async () => { + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.calledWith('call1', ['callee'])).to.be.true; + }); + + it('rings nobody when the callee is the one arriving', async () => { + await service.addUser('call1', 'callee'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + // Rejoining must not ring anyone again — the call window's own "ring again" is how a second attempt is asked + // for. + it('does not ring someone who has already been rung', async () => { + fixture = directCall({ ringingAt: new Date('2026-01-01T00:00:00.000Z') }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + it('does not ring someone who already declined', async () => { + fixture = directCall({ declined: true }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); + + it('does not ring someone who is already in the call', async () => { + fixture = directCall({ joined: true, joinedAt: new Date('2026-01-01T00:00:00.000Z') }); + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts new file mode 100644 index 0000000000000..457222dff9fad --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts @@ -0,0 +1,195 @@ +import type { IGroupVideoConference, IDirectVideoConference, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import { VideoConferenceStatus } from '@rocket.chat/core-typings'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// The stubs below never vary between specs in this directory — they satisfy imports the service file needs +// at load time but that no test here actually exercises. Kept in one place so a new spec doesn't have to +// re-list all ~25 of them just to get the module to load; only the modules a spec actually cares about +// (`@rocket.chat/models`, `@rocket.chat/core-services`, and anything else under test) are assembled by the +// spec itself. +export const commonServiceStubs = { + '@rocket.chat/apps': { Apps: {} }, + // Every level, not just `error`: a missing one throws where the service only meant to say something, and the + // service catches around its logging — so the failure surfaces as the work silently not happening. + '@rocket.chat/logger': { + Logger: class { + error() { + /* no-op */ + } + + warn() { + /* no-op */ + } + + info() { + /* no-op */ + } + + debug() { + /* no-op */ + } + }, + }, + '@rocket.chat/random': { Random: { id: () => 'randomId' } }, + '@rocket.chat/tools': { wrapExceptions: (fn: () => unknown) => fn() }, + 'meteor/meteor': { Meteor: { startup: () => undefined } }, + 'meteor/mongo': { MongoInternals: { defaultRemoteCollectionDriver: () => ({ mongo: { db: {} } }) } }, + '../../../definition/IRoomTypeConfig': { RoomMemberActions: {} }, + '../../../lib/videoConference/chatAccess': { resolveChatAccessMode: () => undefined }, + '../../database/readSecondaryPreferred': { readSecondaryPreferred: () => undefined }, + '../../lib/authorization/canAccessRoom': { canAccessRoomIdAsync: async () => true }, + '../../lib/callbacks': { callbacks: { runAsync: () => undefined, run: () => undefined } }, + '../../lib/i18n': { i18n: { t: (s: string) => s } }, + '../../lib/isRoomCompatibleWithVideoConfRinging': { isRoomCompatibleWithVideoConfRinging: () => true }, + '../../lib/media/assets': { RocketChatAssets: { getURL: () => '' } }, + '../../lib/messages/sendMessage': { sendMessage: async () => ({ _id: 'msg1' }) }, + '../../lib/metrics/lib/metrics': { + metrics: { notificationsSent: { inc: () => undefined }, notificationsSentTotal: { inc: () => undefined } }, + }, + '../../lib/notifications/push/push': { Push: { send: async () => undefined } }, + '../../lib/notifications/push-config/lib/PushNotification': { default: { getNotificationId: () => 'notif' } }, + '../../lib/notifyListener': { notifyOnMessageChange: async () => undefined }, + '../../lib/rooms/createRoom': { createRoom: async () => ({ _id: 'room1' }) }, + '../../lib/rooms/roomCoordinator': { + roomCoordinator: { getRoomDirectives: () => ({ allowMemberAction: async () => true, getDiscussionType: () => 'p' }) }, + }, + '../../lib/statistics/functions/updateStatsCounter': { updateCounter: () => undefined }, + '../../lib/utils/getUserAvatarURL': { getUserAvatarURL: () => '' }, + '../../lib/utils/lib/getUserPreference': { getUserPreference: async () => false }, + '../../lib/videoConfProviders': { + videoConfProviders: { + hasAnyProvider: () => false, + getActiveProvider: () => undefined, + isProviderAvailable: () => false, + getProviderCapabilities: () => undefined, + getProviderAppId: () => undefined, + getProviderList: () => [], + }, + }, + '../../lib/videoConfTypes': { videoConfTypes: { isCallManagedByApp: () => false, getTypeForRoom: () => ({}) } }, + '../../meteor-methods/rooms/addUsersToRoom': { addUsersToRoomMethod: async () => undefined }, + '../../settings': { settings: { get: () => undefined } }, +}; + +/** + * Loads the service with the stubs a spec actually cares about, and the inert ones filled in. + * + * Every spec here needs the same three things beyond `commonServiceStubs`: a `broadcast` it can read, the + * do-nothing `ServiceClassInternal`/`Message`/`Room` the service calls into, and a `@rocket.chat/models` map with + * only the collections that spec exercises. Nine copies of that arrangement is nine places to update when the + * service reaches for one more module. + * + * @param broadcast the stub to observe `api.broadcast` through. + * @param models the `@rocket.chat/models` collections this spec stubs; the rest default to inert. + * @param overrides any other module to replace — where a spec is testing something `commonServiceStubs` fakes. + */ +export const createService = ({ + broadcast = sinon.stub().resolves(), + models = {}, + overrides = {}, +}: { + broadcast?: sinon.SinonStub; + models?: Record; + overrides?: Record; +} = {}) => { + const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + }, + '@rocket.chat/models': { + Users: { findOneById: sinon.stub().resolves(null) }, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + }, + ...models, + }, + ...overrides, + }); + + return VideoConfService as new () => any; +}; + +/** + * Bare `sinon.stub()`s live outside sinon's default sandbox, so `sinon.resetHistory()` is a no-op for them — + * each has to be reset by hand or a test would silently read the previous test's calls. + */ +export const resetAll = (...stubs: sinon.SinonStub[]): void => stubs.forEach((stub) => stub.resetHistory()); + +/** + * A read of the fixture, as the database would answer it: a *copy*. + * + * `findOneById` is hit more than once per flow, with different projections, and the mutating model stubs write + * into the canonical record. Handing out the live object would let a later write retroactively change what an + * earlier read shows — which is exactly the staleness some of these flows depend on. + */ +export const cloneFixture = (call: VideoConference): VideoConference => ({ + ...call, + users: call.users.map((user) => ({ ...user })), + messages: { ...call.messages }, +}); + +/** + * Who actually got rung: the `ring` notifications `notifyUser` sends via + * `api.broadcast('user.video-conference', { userId, action, params })`, which is the only observable trace. + */ +export const ringedUserIds = (broadcast: sinon.SinonStub): string[] => + broadcast.args + .filter(([channel, payload]) => channel === 'user.video-conference' && (payload as { action: string }).action === 'ring') + .map(([, payload]) => (payload as { userId: string }).userId); + +export const createdBy = { _id: 'creator', username: 'creator.user', name: 'Creator User' }; + +// A member's shape as it lives in `users[]`: someone who joined and is still in the call, unless overridden. +export const buildMember = (overrides: Partial & Pick): IVideoConferenceUser => ({ + username: `${overrides._id}.user`, + name: overrides._id, + avatarETag: null, + ts: new Date('2026-01-01T00:00:00.000Z'), + joined: true, + joinedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, +}); + +export const buildGroupCall = (users: IVideoConferenceUser[], overrides: Partial = {}): IGroupVideoConference => ({ + _id: 'call1', + type: 'videoconference', + rid: 'room1', + status: VideoConferenceStatus.STARTED, + title: 'Sprint planning', + anonymousUsers: 0, + providerName: 'test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + _updatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdBy, + messages: {}, + users, + ...overrides, +}); + +export const buildDirectCall = ( + users: IVideoConferenceUser[], + overrides: Partial = {}, +): IDirectVideoConference => ({ + _id: 'call1', + type: 'direct', + rid: 'room1', + status: VideoConferenceStatus.STARTED, + providerName: 'test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + _updatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdBy, + messages: {}, + users, + ...overrides, +}); diff --git a/docs/features/video-conference-persistent-chat/README.md b/docs/features/video-conference-persistent-chat/README.md new file mode 100644 index 0000000000000..6e3cc4b55402e --- /dev/null +++ b/docs/features/video-conference-persistent-chat/README.md @@ -0,0 +1,997 @@ +# Video Conference Persistent Chat + +## Overview + +Persistent chat gives a video conference a Rocket.Chat room that lives alongside the call, so the conversation survives after the call ends. Instead of handing the user off to the provider's own page, joining a conference opens an in-product page at `/conference/:id` — the provider's call in an iframe, a control bar along the bottom, and the conference's chat in a collapsible panel docked to the inline end. + +The chat can run in one of two modes, controlled by `VideoConf_Persistent_Chat_Mode`: + +- **Thread** (default): the chat panel renders a thread started from the conference message in the original channel. No discussion room is created. Access is based on the parent channel — anyone who can read the channel can participate in the thread. +- **Main room** (`main_room`): the chat panel shows the channel itself. A separate discussion room is created off the parent channel when needed (requires `Discussion_enabled`). The chat room is resolved from `discussionRid` when the discussion exists, otherwise the conference's `rid`. A conference's `rid` never changes; only `discussionRid` moves. + +Gated by the EE setting `VideoConf_Enable_Persistent_Chat` (module `videoconference-enterprise`). + +## The flows at a glance + +Four diagrams covering a call's life. They describe the feature with persistent chat **on**; with it off none of it +applies — see [Opening a Conference](#opening-a-conference) for what happens instead. + +| | | +|---|---| +| [Starting a call](./starting-a-call.svg) | the camera button, the preflight, and what confirming creates | +| [Being called](./being-called.svg) | accept, decline, silence or ignore — and where each leaves the call | +| [Adding people and chat access](./adding-people-and-chat-access.svg) | who can read the chat, and the two ways to fix it | +| [Ending a call](./ending-a-call.svg) | the four ways a call stops, and what the history records | + +[How this compares to MatrixRTC](./matrix-comparison.md) sets our answers to "who is in this call" and "who may +join it" against Matrix's, and lists the three things worth borrowing. + +Starting a call: the camera button opens a call window at /conference/new showing a preflight; nothing is created until the user confirms, after which the conference exists and the other side rings. + +Being called: a ring reaches you in your call list or as a notification; accepting joins outright, declining is recorded against your own membership only, silencing stops the sound, and ignoring lets the ring lapse after 15 seconds. + +Adding people: someone already in the room can read the chat, someone from outside cannot; once they join, a notice offers either adding them to the room or moving the chat to a discussion, depending on the room type. + +Ending a call: the last person leaving, joining another call, or the 24-hour expiry all end it; an emptied call waits ten seconds so a reload survives, and each member's history row settles to ended or not-answered. + +## Opening a Conference + +**Placing a call** (`startCall`) with persistent chat on posts *nothing*. It opens the call window at +`/conference/new?rid=…`, and the conference is created there, by the [preflight](#the-preflight-screen). Without +persistent chat it goes through `VideoConfManager.startCall` as it always has. + +The room's call button goes straight there. It used to open a popup to confirm and set devices first, which the +preflight now does with the user able to see what they are joining — two confirmations for one call. The popup +remains the only place to set devices when there is no preflight, so it is still what an unconfigured +persistent-chat workspace gets. + +**Joining one that exists** (`joinCall`) emits `call/join`: + +- **Persistent chat enabled** — `{ callId }`, and again nothing is posted: the conference page joins for itself + once its preflight is confirmed. +- **Disabled** — `POST /v1/video-conference.join` first, then `{ url, callId, providerName }`, the pre-existing + behavior. + +`VideoConfProvider` handles `call/join` by opening `/conference/:id` (absolute URL) with persistent chat on, or +the provider URL without it, and `useVideoConfOpenCall` opens the window. On desktop, +`openInternalVideoChatWindow` takes over. + +### The window opens on the click + +Every call type opens its window **on the click that asked for it**, inside the browser's user-activation window. +`window.open` from anything later — a stream event, a timer — is something the browser is entitled to refuse, and +`VideoConfBlockModal` then has to ask the user to click again for a window they already asked for. A direct call +used to be the exception: it rang the callee and kept the caller waiting in the room, opening the window only once +the answer arrived, which is exactly the refusable case. + +So the wait moves into the call window, and the room stops showing an outgoing popup for a call the user is +already sitting in. Telling the caller that nobody picked up is [deferred](#deferred-to-follow-ups); for now the +members panel shows the other side still ringing. + +### When the callee is rung + +Creating a direct call is not asking anyone to answer it. The caller lands on the [preflight](#the-preflight-screen) +first, so the ring waits for them to actually enter the call: `addUserToCall` rings the other side when the +**caller** arrives, and only members who have never been rung — a rejoin rings nobody. A second attempt is what +the members panel's per-member *ring* is for. + +Being rung into a call whose caller is still choosing a camera means answering to an empty room, which is what +this avoids. The screen says as much before it happens ("Alice will be notified when you start the call") and the +button is the call itself rather than a join. + +With persistent chat **off** there is no preflight to wait for, so nothing changes: the caller's own client rings +the callee from the room, on the 1:1 handshake it always used. + +### How the call window is opened + +A call opens as a **popout** — a dedicated window sized to 1280×800 (capped to the available screen) and centred — mirroring the desktop app's dedicated video window and keeping the call visible while the user works in the main app. If the popout is refused, it falls back to an ordinary **tab**; some browsers and extensions block popup-shaped windows while still allowing a plain one. Only if both are blocked does `VideoConfBlockModal` ask the user to allow it. + +`noopener` is deliberately **never** in the features string: it makes `window.open` return `null`, which is indistinguishable from a blocked popup, and the opener link is what lets the main app notice the call window closing (see [The window that opened the call watches it](#the-window-that-opened-the-call-watches-it)). + +Same-origin (in-product) conferences share a named window, `rocketchat-conference`, so repeated joins reuse it instead of stacking duplicates: + +| State of the shared window | Behaviour | +|---|---| +| already showing this conference | focused without reloading (empty URL) and **without features**, so a window the user has arranged is not resized or recentred | +| showing a different conference | navigated to the new one | +| closed, or never opened | opened fresh as a popout | + +Whether it is showing this conference is decided by reading the window's actual `location.pathname`, not the URL we last passed — those differ in string form between the start and join paths. + +External provider URLs (persistent chat off) get their own popout each time, unnamed. + +## The preflight screen + +Opening the call window and being in the call are two different things, and the window opens first. What it shows +until the user says otherwise is `ConferencePreflight`: what the call is called, the devices they will arrive +with, and — for whoever started a group call — a field to name it. + +### Nothing exists until it is confirmed + +Clicking *call* in a room used to create the conference: a message in the room, a ring, a call in everyone's +history — for a call the user might still walk away from. Now the click only opens the window, at +`/conference/new?rid=…`, and `ConferenceStartPage` runs the preflight against the *room*: the name to offer comes +from the reader's own subscription (which is what names a DM after the other person), the devices from +`video-conference.capabilities`. Confirming posts `start` and then `join`, hands the join result to the conference +page through the query cache, and replaces the URL with `/conference/:callId` — so a reload lands on the call +rather than starting a second one, and the page doesn't ask the same questions again. + +**Cancel** sits beside the confirm button and closes the window. On the start screen that leaves no trace at all, +because nothing was created; on a call that already exists it reports leaving first. + +### Why the join waits + +The window has to open on the click, as above. The *join*, though, is what turns mic and camera into the +provider's URL and what marks the user as present in the call — so it waits here instead: + +- `VideoConfManager.joinCall` posts nothing when persistent chat is on — it only opens the window. Posting there + would throw away the URL it returns and count the user as present in a call they have not chosen to enter yet. +- `useConferenceEmbedded` joins as a mutation, from the preflight's confirmation, carrying the preferences it was + given. + +Devices are configured **only** here. The room's start-call and incoming-call popups used to ask, seconds before +a window opened, and then the conference page joined with a hardcoded `{ mic: true, cam: false }` regardless — +so the popups now leave the question alone whenever persistent chat is on. With it off there is no preflight to +ask, so those controls stay exactly as they were. + +What is on offer is what the provider can be told: today the pair it takes, on or off. They sit in `CallBar`, the +same bar the call's own controls occupy, so the control that mutes the mic doesn't move between deciding to join +and being in the call. A native provider will put input and output selection in the same place. + +### What the screen says it is + +A title, because the same screen serves four situations and they are not interchangeable: *Start a new +conference* / *Start conference with Alice* when nothing exists yet, *Join the conference* / *Join conference with +Alice* when it does. The confirm button follows suit — **Start call**, **Call Alice**, or **Join call**. + +The name field sits above the tile, because it is the one thing here that is about the *call* rather than about +how the user shows up in it, and it carries no label: the field is its own label, prefilled with *Meeting in +<room>* for a conference that doesn't exist yet. The room's name is not repeated anywhere else on the screen — +it is either in the title or in that field. + +### No self-view, on purpose + +Where a preview would sit, the screen states what will happen: *your camera is turned off*, or *your camera will +be on* plus where the devices themselves are chosen. There is no `getUserMedia`, so no permission prompt and no +camera held open while the provider is about to ask for the same one. + +That is not a shortcut — a preview would be a lie about the control on offer. All a provider can be told is +whether to start with camera and microphone on; *which* camera, which microphone, which speaker is settled inside +the provider's own UI. A self-view would promise a choice this screen cannot make, and could show a camera the +call never uses. A native provider, able to take a device per stream, is what makes a real preview honest — and +the same tile is where it will go. + +### Naming the call + +A group conference is named on the way in: the field is prefilled with the room's name, and confirming carries it +to `start` as the conference's title. For a call that already exists — its creator opening the preflight again — +the same field goes to `POST /v1/video-conference.rename`, which sets the title of a running **group** +conference, for the person who started it. A direct call has no title of its own — it is named after the other person, per viewer — and a title everyone +in the call could rewrite is a title nobody can rely on. + +The name matters beyond the label: it is what the provider is told to call the meeting (`customCallTitle`, read +at join time — which is *after* the preflight), and what the call is listed as in the sidebar. +The field is prefilled with what the call is called today, which for a fresh conference is the room it was started +in. Renaming is not worth failing a join over: if it doesn't take, the error is surfaced and the user goes into +the call anyway, which is what they actually asked for. + +## Layout + +The conference renders **standalone**, without the app's navigation chrome. + +`LayoutWithSidebar` (NavBar + Sidebar + `MainContent`) is applied by `MainLayout`, not by the authentication chain. This matters: `AuthenticationCheck → LoggedInArea → UsernameCheck → PasswordChangeCheck → TwoFactorAuthSetupCheck` is shared by every authenticated route, so anything it renders would also appear on the conference page. `TwoFactorAuthSetupCheck` therefore returns `children` directly. + +The conference route is the only consumer of `AuthenticationCheck` outside `MainLayout`; every other route (including dynamic admin/account/room/audit groups) wraps in `MainLayout` and keeps the chrome. + +`AuthenticationCheck` also had to learn the difference between "not logged in" and "not logged in *yet*": it +decided from `useUser()` alone, which is null while a stored session is still being resumed, so a window opening +with a session already in hand — a call popout above all — flashed a login form for as long as that took. It now +waits when a stored login token says a resume is coming; a stale token is cleared when the resume fails, landing as +an ordinary logged-out visitor, and a forced login still goes straight to the form. + +The stored token is deliberately the whole of that test. `isLoggingIn` reads as the more direct question and was +asked alongside it at first, but it is true of *any* login in flight — including one someone is making at the form +right now. That unmounted the form mid-attempt, so a rejected password came back to a blank form with neither field +marked invalid, and iframe login could never show its own form at all, since the flow that fetches its URL runs +from inside `LoginPage`. The token covers the resume from end to end on its own: it is written before the window +loads and removed only on an explicit logout or a failed resume. + +The chain's *loading placeholder* needed the same treatment. `UsernameCheck` shows `HomeSkeleton` — a whole fake +app shell — while it resolves the user, so `AuthenticationCheck` and `UsernameCheck` take an optional `loading` +node, defaulting to `HomeSkeleton` so no existing route changes. The conference route passes `PageLoading`, which +is also what the conference shows while joining, making startup one continuous state rather than two. + +Because it has no `MainContent` ancestor to inherit height from, `ConferenceRoute` establishes the `100dvh`/`100%` box the conference fills. The route is also wrapped with `appLayout.wrap(..., { embedded: true })`, which drops the global banner and cloud-announcement regions. + +### Call chrome + +The conference is a column: a row holding the call and the chat panel, then `CallBar` beneath it. + +`CallBar` is the in-call control bar pinned along the bottom — the position third-party providers put their own toolbar in, so an embedded provider and the future native conference read the same. Its actions sit at the inline end, away from wherever the provider puts its own. Today that is the members and chat toggles (the chat one carrying an unread badge while its panel is closed). When the native conference brings mic, camera and hang-up of its own they will want the centre of the bar, which is the point at which what the centre needs will be known rather than guessed at. + +`CallPanel` is the product's own `Contextualbar`, so a panel beside a call has the same edges and elevation as one beside a room; it is a **sibling of the call area, not a child of the bar**. That is what makes toggling the chat animate its own width without ever reflowing the bar — the bar stays full width and fixed in place by construction, not by careful sizing. Its inner box keeps full width while the outer collapses, so content slides instead of reflowing mid-animation. On viewports narrower than `md` it floats over the call instead of taking width from it. + +The panel is docked to the inline end, so its close button sits at the far end of its header — matching every other closable surface in the product. Both panels share that header (`CallPanelHeader`, the contextual bar's own header/title/close), so two docked side by side can't disagree about where their own edges are. + +### Stage layout + +The call stage (`CallStage`) supports three layouts, cycled by a button in the control bar: + +- **Grid** (default) — all participants in equal-sized tiles, rows/cols computed by `useTileGridLayout` to fill the stage within a [3:4 .. 16:9] aspect band. When there are more than 9 participants, only 8 tiles are shown plus a "+N" overflow placeholder; tiles with camera enabled and the active speaker are prioritised for the visible slots, and the local participant always stays visible. To simulate many participants for testing, set `localStorage.setItem('videoconf-simulate-tiles', '20')` in the browser console before joining a call. +- **Spotlight** — the active speaker fills the stage; the local user's self-view floats as a small PiP in the bottom-right corner. When the local user *is* the active speaker, the first remote participant is shown large instead. +- **Sidebar** — the active speaker is large on the left, other participants are shown in a thumb column on the right (or row at the bottom on narrow stages). The number of visible thumbs is dynamically limited to what fits without scrolling: the capacity is computed from the stage size and thumb dimensions (column: 200 px wide, 16:9 aspect; row: 140 px wide, 96 px strip). When there are more participants than fit, the last slot shows a "+N" overflow placeholder (camera-on and local participant are prioritised for the visible slots). The thumb container never scrolls. + +Active speaker detection runs in `useActiveSpeakerId`: a single `AudioContext` with one `AnalyserNode` per participant, sampling at ~12 Hz. A 1.5 s hold prevents flickering between speakers during conversational pauses. When nobody is speaking, the fallback is the first remote participant. + +When a screen share is active, the existing screen-share spotlight takes over regardless of the selected layout — the screen always wins. + +The bar carries two counts: how many people are in the call, and what is unread in the chat while its panel is +closed. The unread one goes through `useUnreadDisplay`, the sidebar's own rules, so a mention reads as urgent in +both places and a muted room stays quiet in both. The members count is deliberately `secondary` — a count of who is +here is information, and a red badge would read as a problem. + +Knowing what is unread needs the room's subscription, and that is the **page's** business rather than the chat +panel's: the badge exists precisely when that panel is closed, and a panel that isn't mounted can't keep anything +fresh. `useConferenceSubscription` seeds it and follows `subscriptions-changed` for the life of the page. Nothing +else would — the conference renders outside the main app, so the sidebar's own watcher never starts. + +## Route Behavior (`/conference/:id`) + +| Condition | Renders | Auth | +|-----------|---------|------| +| `?callUrl=` present | `ConferencePage` — hands off to the provider's external URL | `guest` allowed | +| `:id` is `new`, with `?rid=` | `ConferenceStartPage` — the preflight for a conference that doesn't exist yet | authentication required (`guest={false}`) | +| `:id` present | `ConferenceEmbeddedPage` — call + chat split view | authentication required (`guest={false}`) | +| neither | `ConferencePageError` | — | + +Guests can't be members of the conference's room, so the embedded page requires a real account. A user without access to the conference's room gets `ConferenceUnauthorizedPage`, which logs out **without navigating away**, so re-login returns to the same conference. It and `ConferencePageError` are the same `ConferenceStatePage` with different words: the window is all the user has, so both keep the conference header and carry whatever way out they have. + +## Chat Panel + +The conference page renders one room outside the main app, so the cached stores the room UI reads from are never populated by the sidebar's subscriptions: + +- `ConferenceStoresReady` marks the cached stores ready. That is all it does: the room UI waits on them being + *ready*, not on them being full, and the one room in play is fetched by `useOpenRoomById` below. It used to + fetch that room here as well, which meant two `rooms.info` for the same room a moment apart. +- In **main room mode**, `ConferenceRoom` opens the room by id (`useOpenRoomById`), forces `isEmbedded` layout, and subscribes to `notify-user/…/subscriptions-changed` to keep unread counts fresh (no sidebar watcher is running). +- In **thread mode**, `ConferenceThread` opens the original channel via `RoomProvider`, then mounts a `ChatProvider` with `tmid` (the conference message's `_id`) and renders `ConferenceThreadChat` — a thread message list and composer scoped to the conference message. Access is governed by the parent channel; no discussion is created. Participants are auto-followed on the thread when they join the call (see [Thread Auto-Follow](#thread-auto-follow)). +- `useOpenRoomById` is the by-rid counterpart to the router-driven `useOpenRoom`. It fetches via `GET /v1/rooms.info` (hence `mapRoomFromApi` to deserialize dates) and falls back to fetching the subscription directly, since `Subscriptions.state` may be empty here. + +`LegacyRoomManager.open` is what starts the message stream the composer waits on. It resolves rooms by **name** for channels/groups but by **rid** for DMs — passing the wrong identifier leaves the composer stuck loading. + +`ConferenceRoom` also carries `narrowRoomStyle`, which reclaims horizontal space for the 400px panel: it restores the composer's inline padding (the embedded layout zeroes it, sized for the tiny `?layout=embedded` iframe) and trims the message start padding and avatar gutter margin. It is scoped to that subtree, so the room's normal full-width appearance and every external embed are untouched. Only the *start* padding is trimmed — the message toolbar and timestamp column sit against the end padding and need the room. + +The call iframe is named with `aria-label` rather than `title`: a `title` on a full-viewport iframe also renders as a hover tooltip, floating a label over the call for as long as the pointer is inside it. + +Video conference message blocks inside the panel have their join/call-back actions disabled (`videoConfJoinDisabled`, set when the current route is `conference`) — joining another conference from inside a conference would replace the call the user is in. + +When the preflight opens for a conference that has already ended (`endedAt` is set on the info response), a "Call ended" state page is shown instead of the preflight — with a Close button that tears down the window. The real-time `updated` subscription also catches a call ending while the user is still on the preflight. + +### Thread Auto-Follow + +When persistent chat is in **thread** mode, participants are automatically subscribed to the call's chat thread +(`messages.started`) when they join the call, so they receive thread notifications for messages posted during the +conference without having to manually follow the thread. + +Two hooks in `VideoConfService` implement this: + +- `autoFollowCallThread` — called from `addUserToCall` after a participant is successfully added. If persistent + chat is enabled, mode is `thread`, and `messages.started` exists, the user is followed on the thread via the + same `follow()` function that the manual "Follow message" action uses. The underlying `$addToSet` is + idempotent, so re-joining a call does not create duplicates. + +- `autoFollowCallThreadForAllParticipants` — called from `startDirect`, `startGroup` and `startLivechat` right + after `messages.started` is first set. It retroactively follows every user already in `call.users`, covering + the edge case where a participant joined between call creation and the started message being persisted. + +Both methods are no-ops when persistent chat is disabled or when the mode is `main_room`. + +## Members Panel + +Who is on the call and where each of them stands. It shares the side panel with the chat — **one at a time**, +since two side panels would leave the call a sliver — and it is the one open by default: on arriving in a call +the useful question is who else is here, and for the caller of a call still ringing it is the only place that +answers it. A bar button switches between the two, and the provider bridge's chat commands act on the chat +specifically rather than closing whatever happens to be open. + +It is split in two — **In call** and **Not in the call** — because the halves answer different questions: who is +here, and who still isn't. A section nobody is in isn't shown. Rows are shaped like the room's own members list +(avatar, name, `@username`, presence) so the two read the same way, and members in the call need no label beyond +the section they are in. + +For the rest, one status from `getConferenceMemberStatus`: + +| Status | Meaning | +|---|---| +| **Ringing** | rung within the last 15s, and hasn't answered yet | +| **Waiting for answer** | rung longer ago than that, and never answered | +| **Declined** | dismissed the ring | +| **Left** | joined and left since | + +The entry accumulates rather than replaces — `joined` never goes back to false, and a decline stays recorded +after the person changes their mind — so the fields are read in order of what happened *last*. Being in the call +beats everything; having left beats an earlier decline, since they did answer. + +Members who can't read the chat carry an icon beside their name — beside, because it qualifies who that person +is in the call, and a second line pushed every row apart for something most members never have. It is the one +thing about a member the other participants can act on (from the notice above the call). Anyone not currently in the call can be **rung individually**, +including someone who declined or left — "call them back" is exactly that case. + +The ring button is offered only when there is something to ask for: not while they are in the call, and not while +their phone is *already* ringing (`canRingConferenceMember`). `ringingAt` on the entry is what makes that knowable +to everyone rather than only to whoever pressed the button — every ring records itself, including the one that +starts a direct call. A ring stops on its own with nothing to announce it, so the row wakes itself when its window +is up, through the same `useRingingExpiry` the calls list uses. + +This panel is where the membership model becomes visible at all: before it, a decline was recorded and an outside +member counted in aggregate, with nowhere to see either against a name. + +## Confined Navigation + +The chat panel is a full room UI, so a link, channel reference or user mention would navigate the conference window away from `/conference/:id` and **tear down the call**. `useConfinedNavigation` pins the window to the conference, covering both interaction paths: + +- **`` clicks** — intercepted on the *capture* phase, so it runs before React/router handlers. Left alone: modified/non-primary clicks, `target` other than self/top/parent, `download`, non-http(s) protocols, and same-path URLs (`?jump=`, `#hash`) which the app handles in place. +- **Programmatic `router.navigate`** — mentions and room links don't go through an anchor, so the shared `navigate` is monkey-patched. The patch is idempotent (`_confined` marker) and cleanup only restores when its own wrapper is still installed, so a newer patch is never clobbered and a stale one never reinstated. Numeric deltas and same-pathname navigations pass through untouched. + +Anything that would leave the conference opens in a **`noopener` new tab**, internal or external alike. + +In **main room** chat mode the panel renders the full room, where thread indicators are visible but the conference route has no `tab`/`context` params to open them. The same wrapper detects a thread navigation (`params.tab === 'thread'`) and calls an `onOpenThread` callback instead of navigating, which opens the thread in a `ConferenceThreadModal` — a Fuselage `Modal` wrapping `ConferenceThread`. The callback is only wired when the chat mode is main room (no `tmid`); in thread mode the panel renders the thread directly and there are no indicators to click. + +Handing internal routes to the window that launched the call would read better — the link would land in the app +the user already has open, as a client-side navigation rather than a fresh tab — but it needs a desktop bridge and +a `postMessage` handshake with the opener. That is [deferred](#deferred-to-follow-ups); a tab is the honest +one-line version until it earns its own change. + +## Adding Participants + +Adding someone to a conference makes them a **member of the conference**. It puts them in no room: membership is what authorizes joining the call, and being able to read the chat is a separate concern, surfaced afterwards rather than decided here. See [Chat Access](#chat-access). + +`AddParticipantsModal` picks users with `UserAutoCompleteMultiple`, the same component the room's own "add users" flow uses, given an `exceptions` list. The conference's room members are excluded — they can already join, so adding them would be a no-op — and everyone else is offerable, which is the point. The exclusion list is best-effort: a member who can't read the chat has no room to enumerate, and the modal still works for them, offering everyone. + +> Dial-out (typing a raw phone/SIP destination into the same field) is **not** wired up. No provider on this branch exposes a dial-out channel, so the affordance would have silently discarded the input; it was removed rather than left as dead UI. Restoring it means passing a provider-supplied `onDialOut` down to the modal. + +`POST /v1/video-conference.add-participants` takes `{ callId, users }` — no `keepHistory`, no room choice — and calls `addMembers`: + +- Each user who isn't already associated with the call gets a `users[]` entry with `joined: false`. Users who already have an entry are skipped, so an existing member's `joinedAt` (or `declined`) is never overwritten. +- Everyone actually added is **rung** (`notifyUser(…, 'ring', …)`). The endpoint caps a single add at `VIDEO_CONF_RINGING_LIMIT` (10) — the same constant the server rings by, which is what guarantees an add always rings, unlike starting a call in a large room where the subscriber count can exceed the cap and nobody is rung. +- They also get a desktop notification, because the ring only reaches a client that is on screen and is one-shot. It deliberately carries **no room name**, which is what stops its click from navigating: the room behind the call may be one they can't open. Clicking focuses the app, where the ring is; the "Join call" action joins the conference itself. + +Nothing about the conference's rooms changes, so `discussionRid` is untouched and no discussion is created. + +### What the member sees + +| | | +|---|---| +| Rung, app on screen | The incoming-call popup, describing the call. It renders without the room — see [the popup note](#the-incoming-call-popup-assumed-the-callee-was-in-the-room). | +| Accepts | Joins the conference outright; no handshake with whoever added them — see [Accepting a server ring](#accepting-a-server-ring-joins-it-doesnt-negotiate). | +| Declines | Recorded on their `users[]` entry. It never ends the call for anyone else, and they can still join afterwards. | +| Misses the ring | The conference is joinable from the sidebar's ongoing calls list. The ring itself doesn't repeat. | +| Opens the chat panel without room access | An explanation, not an error — see [A member who can't read the chat](#a-member-who-cant-read-the-chat-is-told-so-not-shown-an-error). | + +## Busy While In A Call + +Being in a call is being busy, and saying so is what stops someone ringing a person mid-conversation. Joining sets a +presence **claim** — `Presence.setActiveState` with `statusDefault: busy`, the *On a call* status text, and +`statusId: 'video-conference'` — and every way out of a call ends it by that id. + +A claim rather than a status, because the point is getting the old one back. `internal` is the strongest source the +presence engine has, so busy is what shows for as long as the call lasts; whatever it displaced is stashed in +`previousState` and handed back when the claim ends. Someone who set themselves away before the call is away again +after it. Someone who sets a status *during* the call has it queued the same way rather than displayed — the call is +not overruled while it is happening, and their latest intent is what they are left with once it ends. Ending by id +is what lets a voice call's claim and this one end in either order: two `internal` claims stash for each other. + +All three departures release it, which is the same list as everywhere else in this feature: + +| Departure | Where | +|---|---| +| reported | `leaveCall` | +| inferred, when renewals stop | the [presence-lease sweep](#knowing-who-is-still-in-the-call) | +| the call itself ending | `endCall`, for everyone still in it — no leave is coming for them | + +Nothing here is allowed to break a call. Both calls are wrapped: a presence service that is down, slow, or +unlicensed logs a warning and the join carries on. Presence is a courtesy; joining is not. + + +## Leaving a Call + +A conference has no natural end when the provider doesn't report one, so closing the call window is the signal. +`useLeaveConferenceOnClose` posts `POST /v1/video-conference.leave` on `pagehide`, and `leaveCall` decides what +it means: + +- The member's entry gets a `leftAt`. Leaving is neither declining nor un-joining — membership and `joined` both + stand, so they keep their history entry and can rejoin, which clears `leftAt`. +- If nobody is left in the call, the conference **ends** after `EMPTY_CALL_GRACE_MS` (10s) with nobody having come + back. The grace period is what makes a **reload** survivable: the + page unloading reports a leave, and for a moment the call is empty because its only participant is on their way + back into it. + +"Left in the call" is `isInVideoConference` — joined, and not left since. `joined` never goes back to false +(it records that they were there), so presence has to be that pair. A member who was added and never joined +doesn't hold a call open, so an unanswered ring can't keep one alive forever. + +Ending is a consequence of the call being empty, never of one participant asking for it — the same rule +declining follows. What covers the cases nobody can report is **presence leases**, below. + +`pagehide` rather than `beforeunload`: it fires for the bfcache case too and doesn't suppress the cache. The +request needs `keepalive`, because the document is being torn down and an ordinary `fetch` dies with it; +`sendBeacon` would be the usual tool but can't carry the auth headers the REST API needs. + +### Knowing who is still in the call + +A reported departure is the accurate path and it usually works, but it can only be sent by a live client to a live +server — and the call does not depend on either. The provider is a separate service, so **the workspace can be +down while the call carries on**: people leave during the outage, nothing reaches us, and when we come back the +call still lists them as present. The same hole swallows a crashed tab, a killed browser, a dead battery and a +`keepalive` fetch that didn't make it out. + +So presence is a **lease** rather than a report. The conference window renews it every +`PRESENCE_HEARTBEAT_MS` (30s) with `POST /v1/video-conference.heartbeat`, which stamps `lastSeenAt` on the +member's entry. A cron sweeps every minute: anyone whose lease is older than `PRESENCE_LEASE_MS` (3min) is marked +as having left, and a call that empties as a result **ends**, which is what settles everyone's history. Nothing has +to arrive at the moment someone goes; what matters is that nothing arrives afterwards. + +Three details carry most of the weight: + +- **The departure is dated from the last evidence, never from the sweep.** Stamping "now" on a call recovered + twenty minutes after an outage would misreport the call duration. `leftAt` is `lastSeenAt` — + which, during an outage, lands at about the moment the lights went out. `leftReason: 'timeout'` records that it + was inferred, so nothing has to pretend the precision of a reported leave. +- **A restart waits out a full lease before evicting anyone** (`isPresenceSweepDue`). From the database, + "everyone left" and "we weren't here to be told" are the same picture — every lease is expired either way — so + the only honest move is to give whoever is still there a chance to renew. Their window heartbeats every 30s, so + three minutes is generous. In a multi-instance workspace this costs nothing: the instances that stayed up were + never absent and keep sweeping throughout. +- **A renewal undoes an inferred departure, and only an inferred one.** A lease given up on while the window was + in fact alive was simply wrong, and the window still talking to us is the correction. A member who *reported* + leaving is never revived this way — the guard is in `renewUserPresenceById`'s query, so a heartbeat still in + flight behind someone who left matches nothing. + +This is deliberately **provider-agnostic**: the renewing window is ours whether the call renders inside it or is +handed to an iframe, so it needs no cooperation from Pexip, Jitsi or anyone else. Where a provider *can* be asked +who is in a room it may register a **presence probe** (`videoConfPresence`), whose answer renews the same leases +from the server side — which matters because browsers throttle a background window's timers to roughly one a +minute, and a call is usually something you listen to while looking at something else. LiveKit registers one; a +provider reached by URL registers nothing and loses nothing but that. A probe returning `undefined` means "no +answer", which is what an unreachable provider says, and it is never read as "nobody is there" — our own network +trouble must not empty someone else's call. + +**Known limitation.** For a provider with no probe, presence means *"still has the conference window open on this +call"*. Hang up inside the iframe and leave the tab open and you stay listed until the window closes. Closing that +gap needs the provider to report it (the `postMessage` bridge described in [Deferred to +follow-ups](#deferred-to-follow-ups)) or a management API to ask — both per-provider, which is why the lease is the +floor rather than the ceiling. + +### The window that opened the call watches it + +A page can only report its own departure once it is running, and the user counts as being in the call before +that: `video-conference.join` is posted by the **main app**, before the call window is even opened. Accept a call +and close the window while it is still loading and nothing ever reported the leave — the user sat listed as +present in a call they never saw, holding it open. + +So the opener watches the window it opened. `useLeaveCallOnWindowClose` polls `closed` once a second and posts +the leave when the window goes, which covers the whole gap: closed while loading, and closed without `pagehide` +firing at all. One call is watched at a time, since a user is in one call at a time and the window is shared — +opening the next call replaces the watch. Leaving twice is harmless, so it makes no attempt to work out whether +the page got there first, and the watch is dropped rather than fired when the main app itself goes away: that is +not the call window closing, and the call window is meant to outlive it. + +Two gaps this leaves, both ending in the same place — the next join, which reconciles presence server-side (see +[One call at a time](#one-call-at-a-time)) — or the expiry cron: a popup the browser blocked outright (the user +is joined with no window at all), and the main app being closed alongside the call window. + +## Being called makes you a member + +Starting a direct call registers the callee with `joined: false`, exactly as being added to a group conference +does. That is what lets anything tell "still ringing" from "nobody was called", and what gives a missed 1:1 call a +`not-answered` row in the callee's history rather than no trace at all. + +## Chat Access + +`video-conference.info` carries a `chatAccess` descriptor: the room the chat lives in (`discussionRid || rid`), its display name and type, which members can't read it, and whether that room can take new members (`canInvite`). + +Access isn't always a subscription question — a plain public channel is readable by anyone — so a plain public channel and a plain private room (group or DM) are each answered from one `Subscriptions` query for all the member ids at once: a public channel is free for everyone except anyone explicitly banned from it, a private room needs an actual (non-invited) subscription. A room that belongs to a team, is a discussion, or carries ABAC attributes can grant access through paths a subscription read doesn't see (team membership, the parent room's own rules, an ABAC decision), so those still ask `canAccessRoomIdAsync` once per member, exactly as before. + +`ChatAccessNotice` surfaces the situation to participants who *can* read the chat, and hides itself from the members it is about — they can't resolve it for themselves. It counts only members who have **joined**: someone merely invited may never turn up, and a banner about a person who isn't there asks everyone else to fix a situation that hasn't happened. + +It sits above the call and both panels, not inside either. The situation is about the call rather than about whichever panel happens to be open, and a banner that moved as panels changed would read as a different message each time. + +`POST /v1/video-conference.share-chat` applies the remedy, taking a `mode`: + +| `mode` | Effect | `discussionRid` | +|---|---|---| +| `'invite'` | The missing members are added to the chat's room, exposing its whole history | unchanged | +| `'discussion'` | The chat moves to a fresh discussion carrying the union of the room's members and the conference's | the new discussion | +| omitted | The room's own rules decide: `invite` when it can take members, otherwise `discussion` | as above | + +`invite` is refused for a room that can't take new members, re-derived server-side rather than trusted from the client. The room is asked with `allowMemberAction(room, RoomMemberActions.INVITE, uid)` rather than tested for `t === 'd'`: the room type owns that rule, and it covers cases the type check misses, such as a federated DM that *can* grow. + +Which action leads in the modal is a privacy judgement — see [Resolving chat access is the user's call](#resolving-chat-access-is-the-users-call). + +Discussion type comes from `roomCoordinator.getRoomDirectives(parent.t).getDiscussionType(parent)`: `'c'` for a public channel (`'p'` if it belongs to a private team), `'p'` for everything else including DMs. Nesting is always flattened — `getRoomForDiscussion` walks `prid` up to the top-level room, so discussions never nest inside discussions. + +Whichever way it goes, the chat is built from `discussionRid || rid` — the room the chat is *currently* in. +Building from the room the call started in instead is how a second discussion used to drop everyone added since +the first one. + +## Realtime Updates + +Several things can change a conference under a participant: its chat moves to another room, the same room becomes +readable by members who couldn't read it, or its membership shifts — someone joins, declines, leaves or is added. + +All of them are answered the same way: read the conference again, which carries the room, who can see it, and who +is in it. So there is **one** signal, `video-conference.updated` on the `/updated` stream key, and one +subscription that invalidates one query. It started as three events with a payload on one of them; the subscriber +registered the same callback for all three and never read the payload. + +`assignDiscussionToConference` also broadcasts `notify-room/…/videoconf`, so the in-room conference message block +refreshes its "Join discussion" button. The participant who *asked* for a change invalidates locally rather than +waiting on the round trip. + +The stream's `allowRead` accepts **conference membership or access to the chat's room**, the same pair `video-conference.info` accepts. Both halves matter: members may have no access to the room the call originated in, and membership alone would refuse a room member who opens the conference before their join lands — a refused subscription is never retried. + +## Access Control + +Every conference endpoint authorizes through one `canAccessConference` check, which accepts, in order: + +1. **Conference membership** — a `users[]` entry. This is the point of the membership model: it authorizes joining the call without granting any room access. +2. Access to `call.rid`, the room the call was started in. +3. Access to `call.discussionRid`, the room the chat moved to. Someone who belongs only to the discussion has no access to the parent room, so checking only `rid` would lock them out of the call. + +Because all of them share that check, `add-participants` no longer disagrees with `join` and `info` about who is allowed in. `loadAccessibleConference` is the shared prologue: it reads the call, applies the check, and answers both failures the same way — `invalid-params`, deliberately vague about which of the two it was, so a stranger can't use an endpoint to learn that a call id is real. + +The check lives in `server/lib/videoConfAccess.ts` rather than beside these endpoints, because a provider's own endpoints need it too and two versions of "may this person be here" drift into two answers for the same person. That is not hypothetical: the LiveKit transport endpoint originally checked room access instead, so a member added to a call in a DM was refused the credentials for the very call they had just joined — a window showing them alone, with inert controls, because a refused token looks exactly like one that hasn't arrived yet. + +## Reaching a call without a ring + +Ringing is a poor only-route into a call: it is one-shot, it lasts seconds, and a conference started in a room with +more than ten subscribers rings **nobody at all**. So a call is also reachable from a list of the calls running +now — docked at the top of the sidebar, and behind a navbar button when there is no sidebar to dock it in. + +### What the list shows + +Every row is something to act on: **join** it with the ✓, or turn it down with the ✕ so it stops asking. The call +the reader is *already in* is left out entirely — they are in it, there is nothing to reach, and a row reading "in +call" left them with something they could do nothing about. Rows are newest first, and all of them: being a group of +the sidebar's list means the list's own scrolling covers it, so there is no cap and no *show all* toggle to reach +past. + +The calls are **a group of the sidebar's own list**, not a card above it: *Ongoing calls*, always first, collapsing +and scrolling exactly as Discussions or Channels do (`useRoomList` prepends it; `RoomList` renders a call row where +a room row would go). Prepended rather than placed by `sidebarSectionsOrder`, because that order is a user +preference saved before this group existed and a stored copy of it has no place for calls. + +A row **is** the room item — `sidebar/Item/Extended`, the same component every channel renders — with a call's +things in its slots: a camera icon in front of the name, the name in the item's own title tokens, when the call +started in the timestamp corner, and the faces on the second line where a room puts its last message. The actions +sit at the end of that second line. The one slot it never fills is the avatar: a call has no single face to show, +its faces are on the second line, and the avatar column would indent every call by an avatar's width to say +nothing. + +A **ringing** call is the same row again, said by its buttons rather than by a colour behind it: a green phone in +place of the window, and a third action, since a ring can be silenced without being answered. + +**Clicking the row opens the call window on its preflight** — the same thing the row's own button does, and the same +bargain the rooms under it offer, where clicking a row opens what it describes. It is deliberately not a join: the +preflight describes the call and chooses the devices, so a mis-click costs a window rather than putting someone into +a call with their camera on. A press on one of the row's *buttons* is not a press on the row; the buttons sit inside +it, so their clicks arrive there too, and without asking the event where it came from, declining a call also opened +it. The row also has to `preventDefault`: the item renders as an anchor with nowhere to go, and an unhandled click +reloaded the page out from under the call list. + +**A call the reader has joined stays listed**, as one simply running. It used to drop out of the list on being +joined, on the grounds that there was nothing left to offer — but leaving a call is easy to do by accident, and a +call that vanished the moment it was joined left no way back into it. Joining also stops the row asking anything: +it is listed as running even while the record of the ring is still on the call, and it offers no decline, because +the way out of a call you are in is to leave it (`canDeclineCall`). + +Each row says who is in the call as **faces, then how many more** — `[][][] + 3 joined` — which is exactly how the +call's own message block puts it in the room, down to the phrases (`plus__usersCount__joined`, or `joined` when +they are all shown). A call met in the sidebar and met again in its room should read the same both times. Faces +answer the question the reader actually has, which is whether this is a call worth walking into; a number never +did. + +`CallParticipants` draws one avatar per person the payload carries, capped at `CALL_FACES_SHOWN` (3), since a row +has a name to fit beside them. They overlap slightly, each stacked above the one before it, with a `drop-shadow` on +each so a row of faces reads as several people rather than one smudge — `drop-shadow` rather than `box-shadow` +because it follows the avatar's own rounded shape. The full count stays as the group's label, for anyone who cannot +see the avatars and because "+3" means nothing without a total. With the `displayAvatars` preference off there is +nobody to show, so it says the count in words instead, again as the message block does. + +The same component appears on the [preflight](#the-preflight-screen) when joining, under a *Participants in the +call* label and five faces at a time, since a screen has more room than a sidebar row. Those come from the call +window's own copy of the members, so nothing extra travels for them. + +Each row is named by `conferenceNameFor` (`lib/videoConference/conferenceName.ts`), shared with the call window so +the two can't disagree: a group conference's own title; otherwise the reader's own subscription, since a DM is +named per side; and for a **direct** call with no subscription to read, whoever started it. That last case is the +member added from outside a DM, and it is not a nicety — a DM room carries neither `name` nor `fname`, so falling +back to the room reached `getRoomName`'s last resort and showed them the raw room id. + +### A ringing call is listed, not popped + +An incoming call used to take over the screen with a popup that had to be answered before anything else could +happen. It is now the first row of the *Ongoing calls* group — the same row as any other call, in primary blue, with +**accept**, **decline** and **silence** where the running calls carry join and dismiss. The ring still sounds. When +it stops, the row settles into an ordinary one: the call is still there, it just isn't asking any more. + +**Silencing** is not answering. The bell button stops this client's ring and leaves the call exactly where it is, +so the user can decide in their own time. It only appears while there is a sound to stop — a ring this client never +heard, because the page was reloaded, has nothing to silence — and once used becomes a plain bell-off icon, which +is what says why the room went quiet. Silenced ids are remembered by `useOngoingCalls`, because the manager forgets +a dismissed call entirely and "silenced" would otherwise be indistinguishable from "never heard". + +Whether a ring is still ringing is the reader's own judgement (`isRingingVideoConferenceMember` over the `ringingAt` +the joinable list carries), with `useRingingExpiry` waking the list when the earliest one is due to stop — nothing +announces that a ring *ended*, so nothing can be waited for. The list also refreshes on the ring itself rather than +on the poll: a ring *is* announced to the person being rung, and waiting up to twenty seconds to show a call that is +ringing right now would miss it entirely. + +### Where the list lives + +`components/OngoingCalls` holds the two rows and the data behind them. `useOngoingCallItems` says what the list +*is* — ringing first, then the running ones, then the declined behind a toggle — and both places that show calls +walk the same items so they cannot drift into different orders: + +- the sidebar's `RoomList` renders them as the first group of its own list, one row at a time, because that list is + virtualised and this is a group of it; +- `NavBarItemOngoingCalls` renders `OngoingCallsList` in a dropdown, which wants the whole thing at once. + +A collapsed sidebar hides the group, so the navbar button stands in for it whenever `sidebar.isCollapsed`: red while +something is ringing, and it opens itself when a ring starts, because a ringing call the user has to go looking for +is a missed call. It counts what is being offered — the declined ones stay behind their toggle rather than being +counted at someone who already turned them down. + + + +### What the server answers with + +`GET /v1/video-conference.joinable`, via `listJoinableCalls`. Nothing new is stored to support it: the conference +records already hold membership (`users[]`), liveness (`endedAt`) and the room. The scan is over *running* +conferences rather than over the user's rooms, so its cost follows how many calls are in progress — few — rather +than how many rooms the user is in. A sparse index on `{endedAt, createdAt}` keeps it to the calls that are live, +since a conference carries `endedAt` only once it has stopped. + +A call is offered when the user is a **member** of it, or is **in the room** it belongs to. Room membership rather +than room *access*: a public channel is readable by anyone, and a call in a channel the user never joined has no +business in their sidebar. That is narrower than `canAccessConference` on purpose — the endpoints still authorize +with the broader rule, so nobody is refused a call they can reach. + +Calls nobody is in are left out. A conference only stops when someone ends it or the expiry cron reaches it, so +without that filter an abandoned call would be advertised as joinable for a day. + +The name comes from the conference's title, or — for a direct message, which has no name of its own — from the +reader's **own subscription**, since a DM is named after the other person and that name is per-viewer. Both fall +back to the room. One subscription query answers this and the room-membership question together. The payload +carries nothing else: a list needs enough to decide whether to walk in, and joining goes by `callId`. + +### One call at a time + +Joining a call while in another leaves the first, and says so before it does. `useJoinCall` is the shared entry +point for both lists: it asks for confirmation, naming the call being left, then **posts the leave explicitly** +before joining. + +That explicit leave matters and is easy to miss. The call window is shared, so joining a second call already +replaces the first one's page — but replacing a page is not leaving a call. Without the leave, the abandoned +call keeps counting its participant, which keeps it listed as occupied and stops it ever emptying out. With it, +the call empties, ends after the grace period, and drops out of both lists on its own. + +The server enforces the same rule rather than trusting the client to have asked: `addUserToCall` first runs +`leaveOtherCalls`, leaving every *other* running call this user is still counted as being in. A window that dies +without reporting its departure — a crash, a killed tab, a client that never sent the leave — otherwise leaves its +user counted as present forever, which both misreports them and keeps a finished call listed as occupied. Joining +anything is the moment that can be put right, and it costs one indexed read that usually finds nothing. + +### Liveness is polled, and why + +A call appearing does **not** reach these lists over a stream. Announcing a call to everyone who could join it +means a broadcast to every subscriber of its room, which is the same fan-out that makes ringing a large room +impossible in the first place — the problem this feature exists to work around. So `useJoinableCalls` polls, +every 20 seconds, and anything the user does themselves invalidates the query at once. + +That is a deliberate trade. This list is not latency-critical: it exists precisely for the calls whose ring never +arrived, where the alternative today is no route at all. A per-user signal remains the better answer if it can be +made cheap — see [Improvement suggestions](#improvement-suggestions). + +## Provider Requirements + +A provider must declare the **`persistentChat` capability** for `maybeCreateDiscussion` to create a discussion for its conferences. + +Providers that don't declare it still work in the split view — the chat panel falls back to the conference's `rid`, showing the room the call was started in — but get no dedicated per-call discussion. The bundled **Jitsi app (v2.1.1) declares only `{ mic, cam, title }`**, so it falls into this case; adding `persistentChat` is an app-side change. + +The provider's URL is embedded in an iframe, so it must permit framing (no restrictive `X-Frame-Options` / `frame-ancestors`). Rocket.Chat's own CSP allows `frame-src *`. Note the public `meet.jit.si` server disconnects embedded calls after 5 minutes and asks you to use a self-hosted instance or JaaS. + +## Settings + +| Setting | Notes | +|---------|-------| +| `VideoConf_Enable_Persistent_Chat` | EE. Gates whether joining opens the in-product conference page. | +| `VideoConf_Persistent_Chat_Mode` | `thread` (default) or `main_room`. Thread opens a thread from the call message; main room shows the channel itself in the chat panel. | +| `VideoConf_Persistent_Chat_Discussion_Name` | Discussion name (only in discussion mode); `[date]` is substituted, or the date is prefixed when absent. Requires `Discussion_enabled`. | + +## REST Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/v1/video-conference.add-participants` | Register users as conference members and ring them; touches no room. Capped at 10 per call | +| POST | `/v1/video-conference.decline` | Record that the caller dismissed the call, without ending it | +| POST | `/v1/video-conference.leave` | Record that the caller left; ends the conference when nobody is left in it | +| POST | `/v1/video-conference.heartbeat` | Renew the caller's presence lease on a call, so they aren't treated as gone | +| POST | `/v1/video-conference.ring` | Ring the members who aren't in the call again | +| GET | `/v1/video-conference.joinable` | The running calls the caller may join — the sidebar and history lists | +| POST | `/v1/video-conference.join` | Join a conference — accepts `discussionRid` members | +| POST | `/v1/video-conference.rename` | Name a running group conference; the creator only | +| POST | `/v1/video-conference.share-chat` | Give the members who can't read the chat access to it (`mode: 'invite' \| 'discussion'`) | +| GET | `/v1/video-conference.info` | Conference info — accepts `discussionRid` members; carries `chatAccess` | +| GET | `/v1/video-conference.list` | Paginated history, with discussion title / last message | + +## Streams + +| Stream | Event | Payload | Authorized for | +|--------|-------|---------|----------------| +| `video-conference` | `/updated` | — | conference members, or anyone who can read the chat's room | + +## Why membership exists + +The prose above describes the shipped behaviour. This section is the record of *why* it is shaped that way, +plus the follow-up work deliberately left out. The phase-by-phase plan it was built from lives in the git +history and is not repeated here. + +Before it, adding someone to a conference *put them in a room* — the conference's own, or a fresh discussion — +and authorization to join was derived from room membership. That conflated two separate things: being in the call, +and being able to read the chat. What replaced it is described under [Adding Participants](#adding-participants) +and [Access Control](#access-control). + +### Decisions on record + +| # | Decision | +|---|---| +| 1 | Conference membership lives on the existing `users[]`, with a per-entry `joined` flag — not a second array. Keeps one list of "who is associated with this call" and leaves room for future participant kinds. | +| 2 | `ts` keeps its current meaning (added to the conference). A separate `joinedAt` records when they actually joined. | +| 3 | Ringing is decided **per call event** against the list being rung, capped at 10. At start the list is the room's subscribers (so a >10-person room still rings nobody). On add, the list is the added users, capped at 10 per action — so an add always rings. | +| 4 | A decline is recorded as a flag on the member's `users[]` entry. It must never end the call for anyone else. | +| 5 | Membership never expires, and is additive-only. Leaving, declining and rejoining all annotate the entry rather than removing it, which is what makes the call log and the members list possible after the fact. | +| 6 | `assignDiscussionToConference` subscribes the **union of the original room's members and the conference's members**, so a newly created discussion contains everyone involved rather than only those who joined the call. | +| 7 | "External" (a member with no access to the chat) is **derived**, not stored, so it stays true as access changes. It is surfaced per member in the members panel and in aggregate by the chat-access notice. | +| 8 | An incoming call is an item in the list of calls, not a popup over the screen. Answering it, turning it down and leaving it ringing are all things the user can do without the rest of the app being blocked — see [A ringing call is listed, not popped](#a-ringing-call-is-listed-not-popped). | + +### Future work (not in scope) + +- **Non-user participants.** Members are registered Rocket.Chat users only, for now. Representing SIP + extensions, phone numbers, external email addresses, or participants derived from a calendar event is + wanted later. `IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'>` — required + `username` *and* `name` — so that constraint has to relax when it happens. Adding a nullable `source` + discriminator to the entry while Phase 1 is being written costs nothing and avoids a migration later. + +## Implementation notes + +Things worth knowing that aren't visible from the code alone. + +### Group ringing was dead code before this work + +The server had long broadcast an `action: 'ring'` to each room member of a group conference, and **no client ever +handled it** — 1:1 ringing is driven entirely client-side, by the caller's own `VideoConfManager` republishing +`'call'` while it waits. `VideoConfManager` now handles `'ring'`, which is what makes ringing-on-add work; the side +effect is that group conferences which had been silently not ringing **will now ring**, which the EE code always +intended but is a visible change beyond "ring on add". + +A server-originated ring is one-shot: nothing refreshes the 10s abort timeout a 1:1 caller keeps alive, so it rings +once and gives up. That suits an already-running conference, where there is no caller waiting. + +### Declining makes you a member + +There is nowhere to record a decline except on a `users[]` entry, so declining creates one for someone who +was rung as a room member. Since membership authorizes joining, a member who declines can still join +afterwards — which is intended, but is a consequence of where the flag is stored rather than a separate +decision. + +### Resolving chat access is the user's call + +Both ways out give something away, in different directions — see the mode table under +[Chat Access](#chat-access) — so the choice is the user's, and `ChatAccessModal` spells out each consequence next +to its button, naming the room in bold. That name is the context the decision turns on. + +Which one *leads* is a privacy judgement: opening a private room's history is the bigger step, so private rooms +and DMs lead with the discussion, and public rooms — whose history is already open — lead with the invite. +`chatAccessLeadsWithDiscussion` is shared with the server's own default so the two can't disagree. + +The notice itself is `AnnouncementBanner` — the same banner rooms use for announcements — so it inherits +readable contrast instead of hand-rolled colours. It is passed no `onClick`: the Review button is the only +control, which keeps one interactive element rather than nesting a button inside a `role='button'` bar. + +### A member who can't read the chat is told so, not shown an error + +The server already works out who can't read the chat, so `ConferenceChat` asks `hasConferenceChatAccess` about the +current user and renders the not-shared state rather than attempting a fetch that is known to fail — which +would land on *"The page does not exist or you may not have access permission"* and read as something being broken. + +`ChatAccessNotice` hides itself from those same members for the same reason: it offers to share the chat, and +they are the ones it would be shared with — `share-chat` would fail for them anyway, since they can't add +anyone to a room they can't see. + +### The incoming-call popup assumed the callee was in the room + +Ringing on add is the first case where a call rings someone with no access to the room it belongs to, and the popup +was built entirely around that room: it read it with `useUserRoom(rid)` and returned `null` when it wasn't there, +while still calling `focusManager.focusFirst()` — which then crashed looking for the parent of a node the focus +scope never got. Incoming popups therefore render **without** a room, describing the call from the conference's own +record (`VideoConfPopupCallerInfo`). The popups that act *on* a room — starting or placing a call — still need one. + +### Accepting a server ring joins; it doesn't negotiate + +1:1 accept is a handshake: the callee publishes `accepted` and waits for the caller's client to reply +`confirmed` with the go-ahead, giving up after 5s. A server-originated ring has no caller waiting, so running +that handshake left the added user staring at *"No response from remote user after notifying the call was +accepted"*. Incoming calls now carry a `handshake` flag; without it, accepting joins the conference outright — +membership is what authorizes joining — and declining records the decline without publishing `rejected` to +whoever added them, which their client would read as their own call being turned down. + +### Test coverage and where it lives + +The cheap runners were used deliberately: mocha under `apps/meteor/tests/unit/**` (~2s for the whole config) and +package-level jest. The specs sit beside what they test, so the file names say where to look; enumerating them +here only produced a list that went stale on its own. + +Two things about the arrangement are worth knowing. `apps/meteor/tests/unit/server/services/video-conference/testHarness.ts` +is what makes the service testable at all: `createService` proxyquires it with ~25 inert module stubs (one of +which would otherwise open a Mongo driver at import time) and a models map each spec narrows to the collections +it exercises. And the decisions worth pinning down were deliberately moved *out* of the service into pure +functions — `resolveChatAccessMode`, `chatAccessLeadsWithDiscussion` +(`apps/meteor/lib/videoConference/chatAccess.ts`), the member predicates in +`memberStatus.ts` — each shared by the server and the client that has to agree with it, so a rule is tested once +and the two can't drift. + +`packages/models/src/models/VideoConference.spec.ts` stubs `BaseRaw`, which participates in a circular import +that leaves it uninitialized when the module is loaded directly by jest. + +An end-to-end REST suite covering these endpoints against a real server and real Mongo — membership without room +access, authorization by membership, decline, leave, ring, `chatAccess`, and both `share-chat` modes — is written +and held back for a PR of its own; see [Deferred to follow-ups](#deferred-to-follow-ups). It follows the +provider-app harness in `apps/meteor/tests/end-to-end/apps/video-conferences.ts` and is EE-gated, since a private +app is never enabled outside EE. + +### Nothing else "ends" a Jitsi conference + +`endCall` runs when something tells Rocket.Chat the call is over. For a third-party provider, nothing does: +the Jitsi app never reports an end, so before closing the window became a signal, conferences sat at `STARTED` +until `videoConferencesCron` expired them a day later — and the expire path wrote no history at all. Both gaps +are closed: leaving ends the call when nobody is left, and expiry writes history as a backstop. + +Conferences expired *before* this landed have `endedAt` set already, so they are permanently invisible to +history — the duplicate guard can't distinguish them from ones already written. Only conferences that stop from +now on appear. + +### Verified against live data + +The premise — membership without room access — was confirmed by reading a development workspace's Mongo directly, +not only by test: + +- A conference on a **DM** between two users carried a third, `alice`, as a `users[]` entry with `joined: false` + and **no subscription to that DM**. She is authorized to join the call and cannot read its chat, which is + exactly the state the model exists to represent. +- Entries mixed both shapes as designed: joined members carry `joined: true` and a `joinedAt`; added members carry + `joined: false` and no `joinedAt`. Every entry carries `ts`. Declining from the sidebar wrote `declined` and + `declinedAt` on the decliner's entry alone, leaving the conference's own status untouched. + +Two flows were also walked end to end against that workspace. Placing a DM call opened the call window at +`/conference/:id` immediately, with the callee a member at `joined: false` while still ringing and the room showing +no outgoing popup; after the ring window the call window reported "Nobody answered", naming the callee, and ringing +again restarted the wait. Closing the window then ended the call and settled both history rows — `ended`/`outbound` +for the caller, `not-answered`/`inbound` for the callee — while leaving a call someone else was still in only set +`leftAt`. + +## Deferred to follow-ups + +Eight things were built, reviewed and then held back from the first release to keep it reviewable. Each is a +complete improvement on its own, which is what makes it a good follow-up rather than a gap. All of them are in +git — `git show 5ab58858d7d:` restores any of them intact. + +| Deferred | Why it can wait | What ships instead | +|---|---|---| +| **Telling the caller nobody picked up** (`CallOutcomeModal`, `useCallOutcome`) | the caller is in the call either way; this only names what already happened | the members panel shows each member still ringing, waiting, or declined | +| **The provider → parent bridge** (`useProviderCallBridge`) | **no provider implements it** — not the bundled Jitsi app, which declares only `{ mic, cam, title }` | our own bar owns the panels; a provider showing its own toolbar shows two | +| **Handing internal links to the opener** (the desktop bridge and the `postMessage` handshake) | needs a bridge on both sides for a nicer landing | a `noopener` new tab — see [Confined Navigation](#confined-navigation) | +| **Regrouping the room's call list** into Ongoing/Past, named after the discussion | a redesign of a list that already works, and one every workspace sees | the existing flat list, with the fix that it no longer counts members who never joined | +| ~~**Disabling join on message blocks inside the call window**~~ | done — `videoConfJoinDisabled` on `UiKitContext`, set when `useCurrentRoutePath` starts with `/conference/` | join and call-back buttons are disabled inside the call window | + +The end-to-end REST suite (`tests/end-to-end/apps/video-conference-membership.ts`) is held back for a different +reason: it has never been run locally — the API suite authenticates as a fixture admin a dev workspace does not +have — so its first CI run is its real first run, and that belongs in a PR of its own rather than reddening a +feature PR. Until it lands, the endpoints are covered by unit tests only. + +### The design worth keeping for the provider bridge + +If a provider ever asks for it: an embedded provider posts to the parent window to hide our bar and drive our chat +panel, rather than showing two competing sets of controls. + +```js +parent.postMessage({ type: 'rocketchat:conference', command: 'set-call-bar-visible', visible: false }, '*'); +parent.postMessage({ type: 'rocketchat:conference', command: 'set-chat-visible', visible: true }, '*'); +parent.postMessage({ type: 'rocketchat:conference', command: 'toggle-chat' }, '*'); +``` + +The trust model is the part worth preserving: the iframe is cross-origin, so `event.origin` cannot be allow-listed +against our own. Every message must instead have come from `iframeRef.current.contentWindow` — the exact window we +embedded, which no other frame or tab can forge. + +## Improvement suggestions + +What is still thinner than it looks. Everything previously listed here that has since been built — the members +panel, ringing a member again, reporting what an add actually did, the reload grace period, cheaper chat-access +reads — is described in the sections above instead. + +### The ring can still be missed entirely + +A server ring fires once and gives up after 10s, and the caller's own repeats stop at 30s. Being added is also a +desktop notification, which covers a backgrounded tab, but someone with notifications denied and no client on +screen still has no signal in the moment. + +The docked list closes most of this — the call stays reachable long after its ring stopped, which is the point of +it. What remains is the case where nothing was on screen *at all* while it rang: the call is then found in the +list or the history afterwards, rather than being announced. Repeating the server ring for a bounded window is the +cheap answer if that is not enough. + +### `share-chat`'s invite path is all-or-nothing + +`addUsersToConferenceRoom` hands every missing member to `addUsersToRoomMethod` in one call. If it throws for +one of them, the caller gets an error toast; the others may or may not have gone through. It is not silent — the +notice re-reads and shows whoever is still missing — but the toast says less than it could. Per-user results +would make a partial outcome legible at the moment it happens. + +### Joinable calls are polled rather than pushed + +The sidebar's list refreshes on a 20-second timer, because announcing a new call to every subscriber of its room +is the fan-out this feature exists to avoid. A cheaper push would be better: a signal per *room* that the client +already subscribes to would reach exactly the people who need it, without the server enumerating them. + +### The members panel has no search + +Fine at conference scale, and the list is split into sections that keep it readable. A room's own members list has +a search box and a role filter; if conferences ever carry dozens of members, that is the shape to copy. + +### The autocomplete's exclusion list is capped + +`AddParticipantsModal` excludes the room's existing members from its suggestions, reading at most 100 of them. In +a bigger room existing members can therefore be offered; selecting one is harmless — the server skips them and +the modal now says so — but it is a suggestion that shouldn't have been there. + +### Access lost mid-call falls back to "not found" + +The chat panel decides between the room and the not-shared explanation from `chatAccess`, which is only as fresh +as the last read. A member removed from the room *during* a call still has the room attempted, and gets +`ConferenceRoom`'s not-found fallback until the next read. Rare, and self-correcting. + +## Key Files + +| Layer | File | +|-------|------| +| Conference service | `apps/meteor/server/services/video-conference/service.ts` | +| Busy while in a call | `claimBusyForCall` / `releaseBusyForCall` in the conference service, over `Presence` claims (`ee/packages/presence`) | +| API routes | `apps/meteor/server/api/v1/videoConference.ts` | +| Stream wiring | `apps/meteor/server/modules/notifications/notifications.module.ts`, `modules/listeners/listeners.module.ts` | +| Event signature | `packages/core-services/src/events/Events.ts` | +| Stream typings | `packages/ddp-client/src/types/streams.ts` | +| Conference model | `packages/models/src/models/VideoConference.ts` | +| Route + viewport | `apps/meteor/client/views/conference/ConferenceRoute.tsx`, `ConferenceViewport.tsx` | +| Call chrome | `apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx`, `ConferenceIframe.tsx`, `components/CallBar/`, `components/CallPanel/` | +| Stage layout + active speaker | `packages/ui-voip/src/views/MediaCallRoomSection/CallStage.tsx`, `MediaCallRoomSection.tsx`, `providers/useActiveSpeakerId.ts` | +| Chat panel | `apps/meteor/client/views/conference/ConferenceChat.tsx`, `ConferenceRoom.tsx`, `ConferenceThread.tsx`, `ConferenceThreadChat.tsx`, `ConferenceThreadModal.tsx`, `ConferenceStoresReady.tsx`, `CallPanelHeader.tsx`, `ConferenceChatNotShared.tsx` | +| Nothing to show | `apps/meteor/client/views/conference/ConferenceStatePage.tsx`, `ConferencePageError.tsx`, `ConferenceUnauthorizedPage.tsx` | +| Conference data | `apps/meteor/client/views/conference/hooks/useConferenceEmbedded.tsx` | +| Confined navigation | `apps/meteor/client/views/conference/hooks/useConfinedNavigation.ts` (+ `.spec.ts`) | +| Add participants | `apps/meteor/client/views/conference/AddParticipantsModal.tsx` | +| Chat access | `apps/meteor/client/views/conference/ChatAccessNotice.tsx`, `ChatAccessModal.tsx` | +| Preflight | `apps/meteor/client/views/conference/ConferencePreflight.tsx`, `ConferenceStartPage.tsx`, `hooks/useStartConference.ts`, `hooks/useCallPreferences.ts` | +| Members panel | `apps/meteor/client/views/conference/CallMembersPanel.tsx`, `CallMemberItem.tsx`, `client/hooks/useRingingExpiry.ts` | +| Membership rules (shared) | `apps/meteor/lib/videoConference/memberStatus.ts`, `callHistory.ts`, `chatAccess.ts`, `constants.ts` | +| Reaching a call | `apps/meteor/client/components/OngoingCalls/` (`CallListItem` over the sidebar's own room item, its two rows, `OngoingCallsList` and `useOngoingCalls`), `client/sidebar/hooks/useRoomList.ts` and `RoomList/RoomList.tsx` (where the group is), `client/navbar/NavBarItemOngoingCalls.tsx` (the stand-in), `client/views/conference/hooks/useJoinableCalls.ts`, `hooks/useJoinCall.tsx` | +| Leaving | `apps/meteor/client/views/conference/hooks/useLeaveConferenceOnClose.ts` | +| Presence leases | `apps/meteor/lib/videoConference/presence.ts`, `client/views/conference/hooks/useConferencePresenceLease.ts`, `server/lib/videoConfPresence.ts`, `server/cron/videoConferences.ts` | +| Ringing popups | `apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/` | +| Join routing | `apps/meteor/client/providers/VideoConfProvider.tsx`, `client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.tsx` | +| Room opening | `apps/meteor/client/views/room/hooks/useOpenRoomById.tsx`, `client/lib/utils/mapRoomFromApi.ts` | +| Ongoing banner | `apps/meteor/client/views/room/OngoingConferenceBanner/OngoingConferenceBanner.tsx` | +| Room-scoped call history | `apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/` | +| Join guard | `apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts`, `packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx` | +| Layout | `apps/meteor/client/views/root/MainLayout/MainLayout.tsx`, `TwoFactorAuthSetupCheck.tsx`, `client/lib/appLayout.tsx` | +| Notifications | `apps/meteor/client/hooks/notification/useNotification.ts`, `packages/core-typings/src/INotification.ts` | diff --git a/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg b/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg new file mode 100644 index 0000000000000..4b00af3a09cab --- /dev/null +++ b/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg @@ -0,0 +1,59 @@ + + Adding people to a call, and whether they can read its chat + Adding someone makes them a member of the conference, not of any room. Someone already in the room can read the chat; anyone from outside cannot, and is marked no chat access. Once such a member actually joins, a notice offers a remedy: a public channel leads with adding them to the room, a private group leads with moving the chat to a discussion, and a direct message offers only the discussion. + + + + + + + + Add people from the members panel + up to 10 at once, and every one of them rings + + + + + + Already in the room + they can read the chat + + + From outside the room + marked no chat access + + + + + Notice, once they join + only shown to people who can fix it + + + + + + + Public channel + leads with the invite + + + Private group + leads with a discussion + + + Direct message + discussion only + + + + + + + + Added to the room + its whole history opens to them + + + Chat moves to a discussion + everyone in the call follows it + diff --git a/docs/features/video-conference-persistent-chat/being-called.svg b/docs/features/video-conference-persistent-chat/being-called.svg new file mode 100644 index 0000000000000..b6a4195fcb15e --- /dev/null +++ b/docs/features/video-conference-persistent-chat/being-called.svg @@ -0,0 +1,56 @@ + + What happens when a call rings you + A ring reaches you as the first item of your ongoing-calls list, and as a desktop notification when the app is not on screen. You can accept, decline, silence it, or ignore it — each with a different outcome. Declining is recorded against your own membership only and never ends the call for anyone else. + + + + + + + + A call rings you + top of your call list, + or a notification + + + + + + + + Accept + joins outright + + + Decline + your entry only + + + Silence + stops the sound + + + Ignore + 15 seconds pass + + + + + + + + In the call + after the preflight + + + The call goes on + you can still join later + + + Still listed + decide in your own time + + + An ordinary row + it stops asking + diff --git a/docs/features/video-conference-persistent-chat/ending-a-call.svg b/docs/features/video-conference-persistent-chat/ending-a-call.svg new file mode 100644 index 0000000000000..0b73050cdd0b2 --- /dev/null +++ b/docs/features/video-conference-persistent-chat/ending-a-call.svg @@ -0,0 +1,47 @@ + + How a video call ends, and what the call history records + A call is in everyone's history from the moment it starts, as ongoing. It ends when the last participant closes their window, when the only participant joins another call, or — if nothing ever reports an end — when the expiry cron reaches it after 24 hours. A reload also reports leaving, which is why an emptied call waits ten seconds before ending. When it ends, each member's row settles to ended or not-answered. + + + + + + + + A call is running + already a row in everyone's history, as ongoing + + + + + + The last person leaves + window closed, or navigated away + + + They join another call + the server leaves this one + + + Someone reloads + leaving is reported either way + + + Nothing reports an end + expiry reaches it after 24h + + + + + + + + Empty for ten seconds + a reload gets back inside the grace + + + + + Every member's row settles + ended for whoever joined, not-answered for whoever did not + diff --git a/docs/features/video-conference-persistent-chat/matrix-comparison.md b/docs/features/video-conference-persistent-chat/matrix-comparison.md new file mode 100644 index 0000000000000..3a204c4765daf --- /dev/null +++ b/docs/features/video-conference-persistent-chat/matrix-comparison.md @@ -0,0 +1,186 @@ +# How this compares to MatrixRTC + +A comparison of how Rocket.Chat's conference membership work and Matrix's MatrixRTC each answer the same two +questions: **who is in this call right now**, and **who is allowed in it**. + +Matrix side sourced from the current MSCs (August 2026 — all still open, and MatrixRTC has changed shape more than +once, so treat specifics as a moving target): + +- [MSC4143: MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC/proposals/4143-matrix-rtc.md) — sessions, slots, membership +- [MSC4075: MatrixRTC notifications & call ringing](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixrtc-call-ringing/proposals/4075-rtc-notification-event.md) +- [MSC4310: MatrixRTC decline](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC-call-decline/proposals/4310-matrixRTC-call-decline.md) +- MSC4140 (delayed events), MSC4354 (sticky events), MSC4195 (LiveKit backend) — referenced by the above + +## The one difference everything else follows from + +**Matrix has no call object.** A MatrixRTC session "only exist[s] indirectly through the temporal overlap of +`m.rtc.member` events" — a session is the span of time during which one or more members are continuously joined to +the same slot. There is no record to create, no record to end, and no server that decides either. Clients read the +room's events and compute the answer. + +**We have a call object.** `VideoConference` is a document with `_id`, `status`, `endedAt`, and a `users[]` +membership array. The server decides when a call starts, who is in it, and when it ends. + +Everything below is downstream of that. + +| | MatrixRTC | Rocket.Chat | +|---|---|---| +| Call identity | derived — a slot id plus overlapping membership | a `VideoConference` document with an `_id` | +| "Who is in it" | each client publishes its own `m.rtc.member`; readers aggregate | server-owned `users[]` with `joined` / `leftAt` | +| Presence liveness | a dead-man switch: a delayed leave event the client keeps resetting | reported departures plus server-side reconciliation | +| Call end | when the last membership lapses — nothing is written | `endCall` sets `endedAt` and `status: ENDED` | +| Access to the call | room membership, enforced at the media transport | conference membership **or** room access, enforced at the API | +| Chat access | identical to call access, always | a separate question, surfaced and resolvable | +| Ringing | `m.rtc.notification` with `lifetime`, targeted by `m.mentions` | server rings, capped at 10 recipients, one-shot | +| Decline | `m.rtc.decline` event referencing the notification | `declined` / `declinedAt` on the member's own entry | + +## Keeping "who is in the call" honest + +This is the hardest part of any call system, and the two designs solve it in opposite directions. + +**Matrix — the client is the source of truth, and it must keep proving it.** A membership counts only while its +sticky event has not expired (default 4 hours). Because a browser that crashes never sends a leave, the MSC tells +clients to schedule the *leave* as a **delayed event** (MSC4140) with a 15–30 second delay *before* joining, then +periodically reset its timer. If the client stops resetting it, the homeserver fires the leave on its behalf. The +recovery is automatic and needs no server-side knowledge of calls at all. + +**We — the server is the source of truth, and it reconciles.** Our equivalents accumulated one incident at a time: + +| Failure | Our answer | Where | +|---|---|---| +| The tab closes | `pagehide` posts `leave` with `keepalive` | `useLeaveConferenceOnClose` | +| The window closes before the page ever ran | the opener polls `window.closed` and posts the leave | `useLeaveCallOnWindowClose` | +| The window died without reporting anything | the next join anywhere runs `leaveOtherCalls` | `service.addUserToCall` | +| Reload looks exactly like leaving | an emptied call waits `EMPTY_CALL_GRACE_MS` (10s) before ending | `lib/videoConference/callHistory.ts` | +| Nothing ever reports an end | expiry cron closes it after 24h | `videoConferencesCron` | + +Four mechanisms and a cron where Matrix has one. The difference in kind: **Matrix's recovery is time-based and +runs without us; ours is event-based and only fires when something happens.** A user whose laptop sleeps mid-call +is corrected by Matrix within 30 seconds; with us they stay "in the call" until they, or someone else, next join +something — or for up to 24 hours. + +> **Worth stealing.** A server-side lease is the single most valuable idea here. If joining recorded +> `presenceExpiresAt = now + 60s` and the call window refreshed it while alive, `isInVideoConference` would become +> "joined, not left, and not expired" — and the four mechanisms above would collapse into one that also covers the +> cases none of them do. It fits our model without adopting Matrix's: the field is already per-member, and +> `hasActiveParticipants` is already the one place that asks. + +## Access control + +**Matrix gates at two layers, neither of them the call.** Joining a slot requires the sender's room membership to +be `join` — so *room membership is call membership*, and the room's own join rules (public, invite, knock, +restricted) are the whole access story. Creating or modifying a slot needs power level. But the MSC is explicit +that "slots don't provide access control": a malicious client can ignore them and form a shadow session, so the +real enforcement is at the media transport — for LiveKit (MSC4195), a JWT the client can only obtain by proving +room membership. + +**We gate at the API, and we deliberately split call access from chat access.** `canAccessConference` accepts +conference membership **or** access to `rid` **or** access to `discussionRid`. That first clause is the whole +point of the membership model: you can be in a call without being in any room. Matrix cannot express this — there, +being in the call *is* being in the room. + +That split is our genuinely distinct idea, and it is also our extra work: it creates the "member who can't read +the chat" state, which needs surfacing (`chatAccess` on `video-conference.info`), a notice, and two remedies +(`share-chat`: invite, or move the chat to a discussion). Matrix gets chat access for free because it never +separated the two — and pays for it by having no way to pull an outsider into a call without giving them the +room. + +Neither is strictly better. Ours suits "add the vendor to this call without showing them the channel"; Matrix's +suits "the room is the unit of trust, and nothing escapes it". + +> **Worth noting against us.** Our enforcement is at the REST API only. The provider URL we hand out is a +> capability: anyone holding it can join the conference at the provider, membership or not. Matrix pushes +> enforcement down to the SFU precisely so that the media plane can't be reached by leaking a link. With Jitsi we +> could close this with a signed JWT per participant (`moderator`, `room`, `exp`), which is the same shape as +> MSC4195's LiveKit token. Today that gap exists. + +## Ringing + +| | MatrixRTC (MSC4075) | Rocket.Chat | +|---|---|---| +| Mechanism | `m.rtc.notification` event in the room | `api.broadcast('user.video-conference', { action: 'ring' })` per user | +| Who is rung | whoever is in `m.mentions` (a user list, or `room: true`) | every member being added, or the room's subscribers at start | +| Scale limit | none in the MSC; room-wide notification is gated by the `notifications.room` power level | hard cap: `VIDEO_CONF_RINGING_LIMIT` = 10, else nobody rings | +| Duration | `lifetime` on the event — 30s recommended, clients cap at 2 minutes | one-shot; the callee's client aborts after 10s, `ringingAt` is treated as live for 15s | +| Stops when | `sender_ts + lifetime` elapses, the sender disconnects, or all recipients join | the window lapses; nothing announces the end | +| Ring vs. notify | explicit `notification_type: "ring" \| "notification"` | implicit — a ring is a ring; a desktop notification accompanies it | + +Two observations. + +**Matrix's `lifetime` is better than our implicit windows.** We encode "how long is this still ringing" in three +places that must agree: a 10s client abort, a 15s `VIDEO_CONF_RINGING_WINDOW_MS` that every reader re-derives, and +a 40s outcome timeout. Matrix puts one number on the event and every reader obeys it. If we ever revisit ringing, +carrying an explicit expiry on the ring — rather than a constant compiled into clients — removes a whole class of +disagreement. + +**Their ring stops when someone answers; ours doesn't.** MSC4075 stops the ring when all recipients join *or the +sender disconnects*. Ours has no such signal — a rung client discovers the call is over only by the 15s window +lapsing. That is the mechanism behind our "the ring can still be missed entirely" limitation. + +The cap is ours alone, and it is a real product difference: a Rocket.Chat conference started in an 11-person +channel rings **nobody**, which is exactly why the ongoing-calls list had to exist. Matrix has no such cliff +because a notification is one event in a room the clients are already syncing — the fan-out we avoid is fan-out +they never have. + +## Declining + +Nearly convergent designs, arrived at separately. + +MSC4310 adds `m.rtc.decline`, an event with an `m.reference` relation to the notification it answers. It does not +terminate the call for others — "on receipt of a decline from a participant, update that participant's state" — +it is visible to the room, it deliberately raises no push, and it is kept so that clients can "render when a +person tried to start a call and if that got declined". + +Ours: `POST /v1/video-conference.decline` writes `declined` and `declinedAt` **on the decliner's own entry**, +never touches the conference's status, so the call stays reachable +afterwards. Same three properties: personal, non-terminating, persisted. + +One difference worth keeping: because our decline is a field on the member rather than an event referencing a +particular ring, "did they decline *this* ring?" needs `declinedAt` compared against `ringingAt` — the comparison +`useCallOutcome` makes. Matrix gets that for free from the reference relation. Ours is the cheaper storage; theirs +is the cheaper question. + +## Discovering an ongoing call + +**Matrix:** free. The membership events are in the room; any client synced to the room already has them, so "is +there a call in this room" and "who is in it" need no request. That is also why Element Call can render a +participant list with no server support. + +**Ours:** the expensive one. Announcing a call to everyone who could join it means a broadcast per room +subscriber — the same fan-out that makes ringing a large room impossible — so `GET /v1/video-conference.joinable` +is **polled every 20 seconds** by every client. The scan is over running conferences rather than the user's rooms, +which keeps it cheap server-side, but it is still a poll where Matrix has a push. + +The asymmetry is not a design failure on our side; it is the cost of not having the call in a stream every client +is already subscribed to. The cheaper fix — noted in the feature doc's improvement suggestions — is a per-*room* +signal clients already subscribe to, rather than enumerating recipients. + +## What each design buys + +**MatrixRTC's strengths, honestly stated** +- Liveness is self-healing and time-bounded; a crashed client corrects itself in ~30s with no server involvement. +- No call record means no call record to get wrong: no stale `endedAt`, no expiry cron, no "ended twice". +- Discovery and the participant list are free, because state is already replicated to every client. +- The ring carries its own expiry, so no two readers disagree about whether it is still ringing. +- Enforcement reaches the media plane, not just the API. + +**Ours** +- Membership independent of room membership — someone can be in a call without being given the room. Matrix + structurally cannot do this. +- A durable per-member outcome (`ended`, `not-answered`, `ongoing`) written from the start. Matrix + reconstructs history by replaying notification/decline events, and a call nobody answered + leaves only a notification to interpret. +- One authoritative answer to "is this call still running", which is what lets the sidebar list and + the room's message block agree without each client computing it. +- Chat that outlives the call, with an explicit, resolvable access model. + +## If we were to borrow three things + +1. **A presence lease** (`presenceExpiresAt`, refreshed by the call window). Replaces four recovery mechanisms + with one, and covers the sleeping-laptop case none of them cover. Highest value, smallest change. +2. **An explicit expiry on the ring**, carried in the ring itself rather than compiled into clients as + `VIDEO_CONF_RINGING_WINDOW_MS`, plus a "stop ringing" signal when someone answers or the caller gives up. +3. **Provider-level enforcement** — a per-participant signed token so the media plane checks membership too, and + a leaked conference URL stops being a capability. + +None of these require adopting Matrix's model. They are the parts of it that survive being separated from it. diff --git a/docs/features/video-conference-persistent-chat/starting-a-call.svg b/docs/features/video-conference-persistent-chat/starting-a-call.svg new file mode 100644 index 0000000000000..86bc1f54751c5 --- /dev/null +++ b/docs/features/video-conference-persistent-chat/starting-a-call.svg @@ -0,0 +1,55 @@ + + Starting a video call with persistent chat enabled + The camera button in any room opens a call window at /conference/new showing a preflight screen. Nothing is created until the user confirms. Cancelling leaves no trace; confirming creates the conference, posts a message in the room, and rings the other side — the callee of a direct call, or up to ten room members. + + + + + + + + Camera button in any room + DM, channel, group or discussion + + + + + Call window opens + nothing has been created yet + + + + + Preflight + mic and camera are chosen here + a room call gets a name; a DM says who you are calling + + + + + + Cancel + nothing was created + + + Confirm + the call is created now + + + + + You are in the call + a message appears in the room + and a history row says ongoing + + + + + + Direct call + they ring once you arrive + + + Room call + rings 10 members, or none + diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 7a1c60b52dbd5..79fe4ba72f44c 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -162,6 +162,14 @@ export type EventSignatures = { user: Pick; previousStatus: UserStatus | undefined; }): void; + /** + * Something about the conference changed: its chat moved to another room, who can read that chat changed, or + * its membership moved — someone joined, declined, left, or was added. + * + * One event for all of it because there is one answer to all of it: read the conference again. It carries the + * room, who can see it, and who is in it, so nothing a subscriber needs is worth a payload of its own. + */ + 'video-conference.updated'(data: { callId: VideoConference['_id'] }): void; 'watch.messages'(data: { message: IMessage }): void; 'watch.roles'( data: diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 6d74413ccf211..afcbbbd3cb0b2 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -4,7 +4,10 @@ import type { IUser, IVoIPVideoConference, VideoConference, + JoinableVideoConference, VideoConferenceCapabilities, + VideoConferenceChatAccess, + VideoConferenceChatAccessMode, VideoConferenceCreateData, VideoConferenceInstructions, } from '@rocket.chat/core-typings'; @@ -43,5 +46,23 @@ export interface IVideoConfService { params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] }, ): Promise; assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise; + addMembers( + uid: IUser['_id'], + callId: VideoConference['_id'], + usernames: NonNullable[], + options?: { ring?: boolean }, + ): Promise; + declineCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + leaveCall(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + /** Renews the caller's presence lease on a call, which is what stops them being treated as gone. */ + renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + /** Marks everyone whose presence lease has run out as having left, and ends the calls that empties. */ + expirePresenceLeases(now?: Date): Promise; + ringMembers(uid: IUser['_id'], callId: VideoConference['_id'], userIds?: IUser['_id'][]): Promise; + listJoinableCalls(uid: IUser['_id']): Promise; + getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise; + shareChatWithMembers(uid: IUser['_id'], callId: VideoConference['_id'], mode?: VideoConferenceChatAccessMode): Promise; + + renameCall(uid: IUser['_id'], callId: VideoConference['_id'], title: string): Promise; createVoIP(data: InsertionModel): Promise; } diff --git a/packages/core-typings/src/INotification.ts b/packages/core-typings/src/INotification.ts index c9da1a8ae8090..7bed19449d7b8 100644 --- a/packages/core-typings/src/INotification.ts +++ b/packages/core-typings/src/INotification.ts @@ -54,13 +54,25 @@ export interface INotificationDesktop { text: string; icon?: string; duration?: number; + // Force the notification to stay until the user interacts with it, regardless of the recipient's + // `desktopNotificationRequireInteraction` preference. + requireInteraction?: boolean; + // Optional action buttons rendered on the notification (desktop app only; ignored elsewhere). + actions?: { + action: string; + title: string; + }[]; payload: { _id: IMessage['_id']; rid: IMessage['rid']; tmid?: IMessage['tmid']; sender: IMessage['u']; - type: IRoom['t']; - name: IRoom['name']; + // Omitted by notifications that aren't about a room the recipient can open — a conference ring, for + // one. Without a name the click doesn't navigate anywhere, which is the point. + type?: IRoom['t']; + name?: IRoom['name']; + // When set, the notification can offer a "Join" action that opens this conference directly. + conferenceId?: string; message: { msg: IMessage['msg']; t?: IMessage['t']; diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 74cb7fc04ee63..e99a3e3f61391 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -52,11 +52,117 @@ export type LivechatInstructions = { export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'] | 'voip'; +/** + * Someone associated with a conference — a **member**, which is not the same as someone currently in the + * call. Membership is what authorizes joining (alongside access to the conference's room), and it never + * expires. + * + * `joined` is optional because every entry written before it existed represents someone who had joined, so + * readers must treat an absent flag as joined. Use the `hasJoinedVideoConference` helper rather than + * testing the field directly. + */ +/** + * How a departure came to be recorded. `reported` is the member's own client saying so; `timeout` is their + * presence lease running out, which is what covers everything that can stop a client from reporting. + */ +export type VideoConferenceLeaveReason = 'reported' | 'timeout'; + export interface IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'> { avatarETag: string | null; + /** When the user became a member of the conference. */ ts: Date; + joined?: boolean; + joinedAt?: Date; + /** + * Set when the member dismissed the call rather than joining. It records what happened; it never ends the + * call for anyone else. A member can decline and still join later, so this is not exclusive with `joined`. + */ + declined?: boolean; + declinedAt?: Date; + /** When they left the call. Cleared if they rejoin, so it only ever describes the latest departure. */ + leftAt?: Date; + /** + * How we learned they left. Absent means they told us — which is also how every entry written before this + * existed should be read, since reporting was the only way a departure was recorded then. + */ + leftReason?: VideoConferenceLeaveReason; + /** + * When we last had evidence this member was still in the call: their own call window saying so, or the + * provider confirming it. + * + * Presence is a lease rather than a report because the report can be lost — the workspace can be down while + * the call carries on in the provider, and a crashed tab, a dead battery or a closed laptop never report at + * all. What survives all of those is *the absence of renewals*, which is what this records. + */ + lastSeenAt?: Date; + /** + * When we last rang them. A ring is one-shot and short-lived, so this is what tells "their phone is ringing + * right now" from "they were rung and did nothing", which decides whether ringing again is offered. + */ + ringingAt?: Date; } +/** + * Whether a member is actually in the call. Absent `joined` means the entry predates the flag, and back then + * entries were only written on join — so absent reads as joined. + */ +export const hasJoinedVideoConference = (user: Pick): boolean => user.joined !== false; + +/** + * Whether a member is in the call *right now*, as opposed to having joined it at some point. `joined` never goes + * back to false — it records that they were there — so presence is the pair of it and not having left since. + */ +export const isInVideoConference = (user: Pick): boolean => + hasJoinedVideoConference(user) && !user.leftAt; + +/** + * How long a ring is assumed to still be ringing for. A server-originated ring is one-shot: the callee's client + * gives it 10s before it aborts, so a few seconds beyond that covers the round trip without leaving the caller + * waiting on a phone that has stopped. + */ +export const VIDEO_CONF_RINGING_WINDOW_MS = 15_000; + +/** + * How many people a single call event may ring. Ringing is decided per event against the list being rung: + * starting a call rings the room's members, so a large room rings nobody, while adding participants rings just the + * people added — which is why an add is capped at the same number and therefore always rings. + * + * It lives here because both halves of that rule need it: the server deciding whether to ring, and the endpoint + * capping the batch. They were two constants that had to be kept equal by comment. + */ +export const VIDEO_CONF_RINGING_LIMIT = 10; + +/** Whether this member's phone is ringing right now — as opposed to having been rung and done nothing. */ +export const isRingingVideoConferenceMember = ( + user: Pick, + now = Date.now(), +): boolean => { + if (!user.ringingAt) { + return false; + } + + // Answering by declining stops the ringing, even inside the window. + if (user.declined && user.declinedAt && user.declinedAt.getTime() >= user.ringingAt.getTime()) { + return false; + } + + return now - user.ringingAt.getTime() < VIDEO_CONF_RINGING_WINDOW_MS; +}; + +/** + * Per-participant join/leave tracking. Used by embedded-SFU providers + * (e.g. LiveKit) where the room may persist across users joining and leaving + * independently. URL-based providers (Jitsi/Meet/Zoom) leave this undefined + * — they only know if the call is open at all, not who's currently in. + */ +export type IVideoConferenceParticipant = { + id: IUser['_id']; + username?: string; + displayName?: string; + joinedAt?: Date; + leftAt?: Date; +}; + export interface IVideoConference extends IRocketChatRecord { type: VideoConferenceType; rid: string; @@ -79,6 +185,12 @@ export interface IVideoConference extends IRocketChatRecord { ringing?: boolean; discussionRid?: IRoom['_id']; + + /** + * Populated by a provider that runs the call inside Rocket.Chat (LiveKit) rather than handing off to an + * external URL. URL-based providers (Jitsi/Meet/Zoom) leave it undefined. + */ + participants?: IVideoConferenceParticipant[]; } export interface IDirectVideoConference extends IVideoConference { @@ -114,6 +226,54 @@ export interface IVoIPVideoConference extends IVideoConference { }; } +/** + * Where a conference's chat lives and who can't read it. Conference membership grants no room access, so a + * member added from outside the room takes part in the call without seeing its chat; resolving that is a + * deliberate choice with consequences, so the UI needs enough context to explain them before acting. + */ +export type VideoConferenceChatAccess = { + rid: IRoom['_id']; + /** Display name of the room the chat lives in — the history that would be exposed by inviting. */ + name: string; + type: IRoom['t']; + membersWithoutAccess: IUser['_id'][]; + /** Whether that room can take the missing members in: a DM can't, so its chat has to move instead. */ + canInvite: boolean; +}; + +/** + * A call that is running now and that the reader may join — what the sidebar and the navbar list so a call can be + * reached without having caught its ring. + * + * Deliberately not the conference record: a list needs enough to decide whether to walk in, and the room it + * belongs to is not part of that decision. Joining goes by `callId`. + */ +export type JoinableVideoConference = { + callId: IVideoConference['_id']; + /** What to call it: the conference's own title, or the room's name. */ + name: string; + createdAt: Date; + /** How many people are in it right now. Never zero — an empty call isn't offered. */ + usersCount: number; + /** + * A few of the people in it, so a list can show faces instead of a number. Capped on the server — the count + * above is still the whole truth, and what a "+3" is worked out from. + */ + participants: Pick[]; + /** Whether the reader is one of them, which is what makes joining another call a matter of leaving this one. */ + joined: boolean; + /** Whether the reader already turned this call down. The sidebar hides those. */ + declined: boolean; + /** + * When this reader was last rung, if ever. Whether that ring is still live is decided by the reader — see + * `isRingingVideoConferenceMember` — so the list can stop presenting a call as ringing without being told. + */ + ringingAt?: Date; +}; + +/** How to give the missing members access: bring them into the room, or move the chat to a discussion. */ +export type VideoConferenceChatAccessMode = 'invite' | 'discussion'; + export type ExternalVideoConference = IDirectVideoConference | IGroupVideoConference | ILivechatVideoConference; type InternalVideoConference = IVoIPVideoConference; diff --git a/packages/core-typings/src/VideoConferenceCapabilities.ts b/packages/core-typings/src/VideoConferenceCapabilities.ts index 18070608e53f1..c785fa38675bb 100644 --- a/packages/core-typings/src/VideoConferenceCapabilities.ts +++ b/packages/core-typings/src/VideoConferenceCapabilities.ts @@ -3,4 +3,11 @@ export type VideoConferenceCapabilities = { cam?: boolean; title?: boolean; persistentChat?: boolean; + /** + * When true, the call is rendered embedded inside Rocket.Chat (via an + * SFU like LiveKit) rather than handed off to an external URL/popup. + * Consumers gate URL-generation on the inverse: URL-based providers + * (Jitsi/Meet/Zoom) leave this false/undefined. + */ + embedded?: boolean; }; diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 665368b13f654..85c9578b31652 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -472,6 +472,9 @@ export interface StreamerEvents { { key: 'command/removed'; args: [string] }, { key: 'actions/changed'; args: [] }, ]; + + 'video-conference': [{ key: `${string}/updated`; args: [] }]; + 'local': [ { key: 'broadcast'; diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 04b1b6491103a..7a0415d20f63a 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -67,3 +67,10 @@ export interface IRocketChatDesktop { getE2ePdfPreviewSizeLimit: () => number; openInBrowser: (url: string) => void; } + +export interface IVideoCallWindow { + openInMainWindow: (path: string) => void; + close: () => void; + requestScreenSharing: () => Promise; + getAuthCredentials: () => Promise<{ userId: string; authToken: string; serverUrl: string } | null>; +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2f4f36902aa68..2ef6b03d8b6fa 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7142,6 +7142,10 @@ "VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.", "VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name", "VideoConf_Persistent_Chat_Discussion_Name_Description": "Use [date] tag to set where to include the date. Date will be added to the start if tag is not included.", + "VideoConf_Persistent_Chat_Mode": "Chat Mode", + "VideoConf_Persistent_Chat_Mode_Description": "Thread opens a thread from the call message in the original channel. Main room shows the channel itself in the chat panel.", + "VideoConf_Persistent_Chat_Mode_Main_Room": "Main room", + "VideoConf_Persistent_Chat_Mode_Thread": "Thread", "videoconf-ring-users": "Ring Other Users When Calling", "videoconf-ring-users_description": "Permission to ring other users when calling", "Videos": "Videos", diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 3508471f9d81a..632f505f5146b 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -27,3 +27,43 @@ export async function getPairs(): Promise<[string, string]> { return [spki, pkcs8]; } + +// ---- HS256 (shared-secret) JWTs ---- +// Used for systems like LiveKit that authenticate with an API key/secret pair. + +export type HS256SignOptions = { + secret: string; + issuer?: string; + subject?: string; + // Accepts a duration string like '6h' or '30s', a Date, or seconds since epoch. + expiresIn?: string | number | Date; + // Same accepted forms as expiresIn. Pass 0 for "immediately valid". + notBefore?: string | number | Date; +}; + +export async function signHS256(payload: JWTPayload, options: HS256SignOptions): Promise { + const secretBytes = new TextEncoder().encode(options.secret); + const builder = new SignJWT(payload).setProtectedHeader({ alg: 'HS256', typ: 'JWT' }).setIssuedAt(); + + if (options.issuer) { + builder.setIssuer(options.issuer); + } + if (options.subject) { + builder.setSubject(options.subject); + } + if (options.expiresIn !== undefined) { + builder.setExpirationTime(options.expiresIn as Parameters[0]); + } + if (options.notBefore !== undefined) { + builder.setNotBefore(options.notBefore as Parameters[0]); + } + + return builder.sign(secretBytes); +} + +export async function verifyHS256(jwt: string, secret: string, options?: { issuer?: string }): Promise { + const { payload } = await jwtVerify(jwt, new TextEncoder().encode(secret), { + ...(options?.issuer ? { issuer: options.issuer } : {}), + }); + return payload; +} diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 66a082af85d2a..e6ab3bfd1a8f7 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -3,7 +3,9 @@ import type { ILivechatVideoConference, IRoom, IUser, + IVideoConferenceParticipant, VideoConference, + VideoConferenceLeaveReason, VideoConferenceStatus, IVoIPVideoConference, } from '@rocket.chat/core-typings'; @@ -53,9 +55,33 @@ export interface IVideoConferenceModel extends IBaseModel { setUrlById(callId: string, url: string): Promise; + /** Names a group conference. Only that kind carries a title of its own. */ + setTitleById(callId: string, title: string): Promise; + setProviderDataById(callId: string, providerData: Record | undefined): Promise; - addUserById(callId: string, user: Required> & { ts?: Date }): Promise; + /** Associates a user with the call. It never marks them present — `setUserJoinedById` is what arriving does. */ + addMemberById(callId: string, user: Required> & { ts?: Date }): Promise; + + setUserJoinedById(callId: string, uid: IUser['_id'], joinedAt?: Date): Promise; + + setUserDeclinedById(callId: string, uid: IUser['_id'], declinedAt?: Date): Promise; + setUserLeftById(callId: string, uid: IUser['_id'], leftAt?: Date, reason?: VideoConferenceLeaveReason): Promise; + setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt?: Date): Promise; + + /** Renews one member's presence lease, and with it any departure that was inferred rather than reported. */ + renewUserPresenceById( + callId: string, + uid: IUser['_id'], + lastSeenAt?: Date, + inferredReasons?: VideoConferenceLeaveReason[], + ): Promise; + + /** Renews several leases at once, as a provider reporting who is in its room does. */ + renewUsersPresenceById(callId: string, uids: IUser['_id'][], lastSeenAt?: Date): Promise; + + /** Every open call, with the roster and provider the presence sweep judges it by. */ + findActiveWithMembers(): FindCursor>; setMessageById(callId: string, messageType: keyof VideoConference['messages'], messageId: string): Promise; @@ -70,4 +96,15 @@ export interface IVideoConferenceModel extends IBaseModel { unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; createVoIP(call: InsertionModel): Promise; + + // --- Embedded SFU (LiveKit) helpers --- + // These mirror the per-participant bookkeeping + // that URL-based providers don't need. URL providers (Jitsi/Meet/Zoom) + // never call these. + + findActiveEmbeddedInRoom(rid: IRoom['_id'], providerName: string): Promise; + + addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise; + + markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt?: Date): Promise; } diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts new file mode 100644 index 0000000000000..01d827a6bc881 --- /dev/null +++ b/packages/models/src/models/VideoConference.spec.ts @@ -0,0 +1,220 @@ +// `BaseRaw` participates in a circular import that leaves it uninitialized when this module is loaded +// directly by jest. Only its constructor matters here, so it is stubbed out. +jest.mock('./BaseRaw', () => ({ + BaseRaw: class { + constructor( + public db: unknown, + public name?: string, + ) {} + }, +})); + +// eslint-disable-next-line import-x/first -- must be registered before the module under test is loaded +import { VideoConferenceRaw } from './VideoConference'; + +const member = { _id: 'user-1', username: 'user.one', name: 'User One', avatarETag: 'etag' }; + +/** + * These assert the *shape* of the update sent to Mongo rather than its effect, which is the level the bug + * these methods exist to prevent lives at: `$addToSet` on a whole document silently appends a second entry + * once the first one has been mutated, and the guard that prevents it has to be in the query. + */ +const setupModel = () => { + const updateOne = jest.fn().mockResolvedValue({}); + const model = new VideoConferenceRaw({ collection: () => ({}) } as never); + Object.defineProperty(model, 'updateOne', { value: updateOne }); + + return { model, updateOne }; +}; + +describe('VideoConferenceRaw.addMemberById', () => { + it('should guard on the member not already being present, in the query', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + const [query] = updateOne.mock.calls[0]; + expect(query).toEqual({ '_id': 'call-1', 'users._id': { $ne: 'user-1' } }); + }); + + // `$addToSet` compares entire documents, so it stops de-duplicating the moment an entry can be mutated. + it('should push rather than add-to-set', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + const [, update] = updateOne.mock.calls[0]; + expect(update).toHaveProperty('$push'); + expect(update).not.toHaveProperty('$addToSet'); + }); + + it('should default a new member to not joined', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + expect(updateOne.mock.calls[0][1].$push.users).toMatchObject({ _id: 'user-1', joined: false }); + }); + + it('should omit joinedAt when there is none, rather than storing undefined', async () => { + const { model, updateOne } = setupModel(); + + await model.addMemberById('call-1', member); + + expect(updateOne.mock.calls[0][1].$push.users).not.toHaveProperty('joinedAt'); + }); +}); + +describe('VideoConferenceRaw.setUserJoinedById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const joinedAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserJoinedById('call-1', 'user-1', joinedAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update.$set).toEqual({ + 'users.$[user].joined': true, + 'users.$[user].joinedAt': joinedAt, + 'users.$[user].lastSeenAt': joinedAt, + }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // An earlier departure left in place would report the member as gone while they're on the call, and could + // end the call under them once presence is what decides that. + it('should clear an earlier departure, since rejoining contradicts it', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserJoinedById('call-1', 'user-1'); + + expect(updateOne.mock.calls[0][1].$unset).toEqual({ + 'users.$[user].leftAt': 1, + 'users.$[user].leftReason': 1, + 'users.$[user].ringingAt': 1, + }); + }); +}); + +describe('VideoConferenceRaw.renewUserPresenceById', () => { + it('should stamp the lease on the matching entry via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const lastSeenAt = new Date('2026-08-01T10:00:00Z'); + + await model.renewUserPresenceById('call-1', 'user-1', lastSeenAt); + + const [, update, options] = updateOne.mock.calls[0]; + expect(update.$set).toEqual({ 'users.$[user].lastSeenAt': lastSeenAt }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // A lease we gave up on while the window was in fact alive was simply wrong, and the window still talking to + // us is the correction — otherwise a member evicted during an outage would stay evicted for the whole call. + it('should undo a departure that was only inferred', async () => { + const { model, updateOne } = setupModel(); + + await model.renewUserPresenceById('call-1', 'user-1'); + + expect(updateOne.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }); + }); + + // The guard has to be in the query, because that is the only part of an update that can be conditional: a + // heartbeat still in flight behind someone who chose to leave must not put them back in the call. + it('should refuse to revive a member who reported leaving, in the query', async () => { + const { model, updateOne } = setupModel(); + + await model.renewUserPresenceById('call-1', 'user-1', new Date(), ['timeout']); + + const [query] = updateOne.mock.calls[0]; + expect(query).toEqual({ + _id: 'call-1', + users: { $elemMatch: { _id: 'user-1', $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: ['timeout'] } }] } }, + }); + }); +}); + +describe('VideoConferenceRaw.renewUsersPresenceById', () => { + it('should stamp every named member at once', async () => { + const { model, updateOne } = setupModel(); + const lastSeenAt = new Date('2026-08-01T10:00:00Z'); + + await model.renewUsersPresenceById('call-1', ['user-1', 'user-2'], lastSeenAt); + + const [, update, options] = updateOne.mock.calls[0]; + expect(update).toEqual({ $set: { 'users.$[user].lastSeenAt': lastSeenAt } }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': { $in: ['user-1', 'user-2'] } }] }); + }); + + // Unlike a member's own heartbeat, a provider reporting its room says nothing about whether an inferred + // departure was wrong — and an update with no ids would match every member of the call. + it('should leave departures alone, and do nothing at all with nobody to renew', async () => { + const { model, updateOne } = setupModel(); + + await model.renewUsersPresenceById('call-1', [], new Date()); + expect(updateOne).not.toHaveBeenCalled(); + + await model.renewUsersPresenceById('call-1', ['user-1'], new Date()); + expect(updateOne.mock.calls[0][1]).not.toHaveProperty('$unset'); + }); +}); + +describe('VideoConferenceRaw.setUserLeftById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const leftAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserLeftById('call-1', 'user-1', leftAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update).toEqual({ $set: { 'users.$[user].leftAt': leftAt } }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // Leaving is not un-joining: the member keeps their place in the call's history and can rejoin. + it('should leave joined and declined alone', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserLeftById('call-1', 'user-1'); + + const keys = Object.keys(updateOne.mock.calls[0][1].$set); + expect(keys).toEqual(['users.$[user].leftAt']); + }); + + // How the departure was learned is only worth writing when there is something to say. An absent reason reads + // as reported, which is what every entry written before presence leases existed was. + it('should record how the departure was learned, only when told', async () => { + const { model, updateOne } = setupModel(); + + // `toHaveProperty` reads a dotted string as a path, and every key here is a dotted Mongo field. + await model.setUserLeftById('call-1', 'user-1', new Date(), 'timeout'); + expect(updateOne.mock.calls[0][1].$set['users.$[user].leftReason']).toBe('timeout'); + + await model.setUserLeftById('call-1', 'user-1', new Date()); + expect(Object.keys(updateOne.mock.calls[1][1].$set)).not.toContain('users.$[user].leftReason'); + }); +}); + +describe('VideoConferenceRaw.setUserDeclinedById', () => { + it('should mutate the matching entry in place via arrayFilters', async () => { + const { model, updateOne } = setupModel(); + const declinedAt = new Date('2026-08-01T10:00:00Z'); + + await model.setUserDeclinedById('call-1', 'user-1', declinedAt); + + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + expect(update).toEqual({ $set: { 'users.$[user].declined': true, 'users.$[user].declinedAt': declinedAt } }); + expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + }); + + // Declining must not clear `joined`: a member can dismiss the ring and join later. + it('should not touch the joined flag', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserDeclinedById('call-1', 'user-1'); + + expect(Object.keys(updateOne.mock.calls[0][1].$set)).not.toContain('users.$[user].joined'); + }); +}); diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index fbcad1529f959..83c4383d57087 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -6,6 +6,8 @@ import type { IRoom, RocketChatRecordDeleted, IVoIPVideoConference, + IVideoConferenceParticipant, + VideoConferenceLeaveReason, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import type { FindPaginated, InsertionModel, IVideoConferenceModel } from '@rocket.chat/model-typings'; @@ -31,7 +33,12 @@ export class VideoConferenceRaw extends BaseRaw implements IVid return [ { key: { rid: 1, createdAt: 1 }, unique: false }, { key: { type: 1, status: 1 }, unique: false }, - { key: { discussionRid: 1 }, unique: false }, + // `createdAt` is part of the key so the `$or: [{ rid }, { discussionRid }]` listing below can be + // served by an index-ordered merge instead of a blocking in-memory sort of the whole room history. + { key: { discussionRid: 1, createdAt: 1 }, unique: false }, + // Listing the calls that are running: a sparse index, because a conference carries `endedAt` only once + // it has stopped, so the index holds just the handful that are live. + { key: { endedAt: 1, createdAt: -1 }, unique: false, sparse: true }, ]; } @@ -39,10 +46,11 @@ export class VideoConferenceRaw extends BaseRaw implements IVid rid: IRoom['_id'], { offset, count }: { offset?: number; count?: number } = {}, ): FindPaginated> { - // No data is lost — `providerData` is optional — but `Omit` over the `VideoConference` union collapses it into a single - // object type, so the explicit type argument opts out of projection inference to preserve the discriminated union. + // Matches conferences started in this room (`rid`) and those whose discussion *is* this room + // (`discussionRid`), so a discussion resolves the conference it belongs to — its members may have no + // access to the parent room the conference originated in. return this.findPaginated( - { rid }, + { $or: [{ rid }, { discussionRid: rid }] }, { sort: { createdAt: -1 }, skip: offset, @@ -193,6 +201,14 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }); } + public async setTitleById(callId: string, title: string): Promise { + await this.updateOneById(callId, { + $set: { + title, + }, + }); + } + public async setProviderDataById(callId: string, providerData: Record | undefined): Promise { await this.updateOneById(callId, { ...(providerData @@ -209,21 +225,129 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }); } - public async addUserById( + /** + * Adds a member to the conference, doing nothing if they are already one. + * + * The guard lives in the *query*, not in a read-then-write: `$addToSet` compares whole documents, so once + * an entry can be mutated (by `setUserJoinedById` below) it would no longer match and a second call would + * append a duplicate. Filtering on `users._id` makes this atomic and idempotent in one update, which also + * removes the race in a caller that checks membership in memory first. + */ + public async addMemberById( callId: string, user: Required> & { ts?: Date }, ): Promise { - await this.updateOneById(callId, { - $addToSet: { - users: { - _id: user._id, - username: user.username, - name: user.name, - avatarETag: user.avatarETag, - ts: user.ts || new Date(), + await this.updateOne( + { '_id': callId, 'users._id': { $ne: user._id } }, + { + $push: { + users: { + _id: user._id, + username: user.username, + name: user.name, + avatarETag: user.avatarETag, + ts: user.ts || new Date(), + // Being a member is not being in the call. Whoever is arriving says so with `setUserJoinedById`. + joined: false, + }, }, }, - }); + ); + } + + /** Marks an existing member as being in the call, mutating their entry in place. */ + public async setUserJoinedById(callId: string, uid: IUser['_id'], joinedAt = new Date()): Promise { + await this.updateOne( + { _id: callId }, + { + // Joining is the first renewal of the member's presence lease: it is the strongest evidence there + // is that they are in the call, and stamping it here saves a second write to say so. + $set: { 'users.$[user].joined': true, 'users.$[user].joinedAt': joinedAt, 'users.$[user].lastSeenAt': joinedAt }, + // Rejoining makes an earlier departure meaningless: leaving it behind would report the member as + // gone while they are on the call, and could end the call under them. Clearing ringingAt stops + // the caller's ringback tone — the person answered. + $unset: { 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1, 'users.$[user].ringingAt': 1 }, + }, + { arrayFilters: [{ 'user._id': uid }] }, + ); + } + + /** + * Renews a member's presence lease — their call window reporting that it is still in the call. + * + * A renewal also undoes a departure that was *inferred*: a lease we gave up on while the window was in fact + * alive was simply wrong, and the window saying so is the correction. A departure the member reported is + * never undone this way — they left, and a heartbeat still in flight behind them must not put them back in + * the call. That is the condition in the query, which is why a stale renewal matches nothing at all. + */ + public async renewUserPresenceById( + callId: string, + uid: IUser['_id'], + lastSeenAt = new Date(), + inferredReasons: VideoConferenceLeaveReason[] = ['timeout'], + ): Promise { + await this.updateOne( + { + _id: callId, + users: { $elemMatch: { _id: uid, $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: inferredReasons } }] } }, + }, + { + $set: { 'users.$[user].lastSeenAt': lastSeenAt }, + $unset: { 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }, + }, + { arrayFilters: [{ 'user._id': uid }] }, + ); + } + + /** + * Renews several members' leases at once — what a provider that can be asked who is in its room answers + * with. Unlike a client's own heartbeat this never revives an inferred departure: the provider is reporting + * a room, not a member correcting us about their own window. + */ + public async renewUsersPresenceById(callId: string, uids: IUser['_id'][], lastSeenAt = new Date()): Promise { + if (!uids.length) { + return; + } + + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].lastSeenAt': lastSeenAt } }, + { arrayFilters: [{ 'user._id': { $in: uids } }] }, + ); + } + + /** Records that we just rang these members, so every client can tell a ringing phone from a silent one. */ + public async setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt = new Date()): Promise { + if (!uids.length) { + return; + } + + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].ringingAt': ringingAt } }, + { arrayFilters: [{ 'user._id': { $in: uids } }] }, + ); + } + + /** + * `reason` says how the departure came to be known, and is only written when there is something to say: an + * absent one reads as reported, which is what every entry written before leases existed was. + */ + public async setUserLeftById(callId: string, uid: IUser['_id'], leftAt = new Date(), reason?: VideoConferenceLeaveReason): Promise { + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].leftAt': leftAt, ...(reason && { 'users.$[user].leftReason': reason }) } }, + { arrayFilters: [{ 'user._id': uid }] }, + ); + } + + /** Records that an existing member dismissed the call, mutating their entry in place. */ + public async setUserDeclinedById(callId: string, uid: IUser['_id'], declinedAt = new Date()): Promise { + await this.updateOne( + { _id: callId }, + { $set: { 'users.$[user].declined': true, 'users.$[user].declinedAt': declinedAt } }, + { arrayFilters: [{ 'user._id': uid }] }, + ); } public async setMessageById(callId: string, messageType: keyof VideoConference['messages'], messageId: string): Promise { @@ -231,8 +355,7 @@ export class VideoConferenceRaw extends BaseRaw implements IVid $set: { [`messages.${messageType}`]: messageId, }, - }); // TODO: Remove this cast when TypeScript is updated - // TypeScript is not smart enough to infer that `messages.${'start' | 'end'}` matches two keys of `VideoConference` + }); } public async updateUserReferences(userId: IUser['_id'], username: IUser['username'], name: IUser['name']): Promise { @@ -304,4 +427,48 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }, ); } + + // --- Embedded SFU (LiveKit) helpers --- + // URL-based providers (Jitsi/Meet/Zoom) never call these. The data shape + // is described in the IVideoConferenceParticipant type in core-typings. + + public async findActiveEmbeddedInRoom(rid: IRoom['_id'], providerName: string): Promise { + // "active" means the call is open (not ENDED/EXPIRED/DECLINED). Embedded + // providers use the standard VideoConferenceStatus lifecycle. + return this.findOne({ + rid, + providerName, + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, + }); + } + + /** + * Every call that is still open, with what the presence sweep needs to judge it: who is on the roster, and + * which provider is running the media — the one that may be able to say who is in the room. + * + * Deliberately not scoped to a provider or to an age. Any open call has leases to check, and one whose + * members all vanished ten seconds ago is exactly as stuck as one that has been that way for hours. + */ + public findActiveWithMembers(): FindCursor> { + return this.find( + { + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, + endedAt: { $exists: false }, + }, + { projection: { _id: 1, rid: 1, users: 1, providerName: 1 } }, + ); + } + + public async addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise { + // Pull any prior entry for this user first so a re-join doesn't + // leave a leftAt'd ghost in the array alongside the fresh entry. + await this.updateOne({ _id: callId }, { $pull: { participants: { id: participant.id } } } as any); + await this.updateOne({ _id: callId }, { + $push: { participants: { ...participant, joinedAt: participant.joinedAt ?? new Date() } }, + } as any); + } + + public async markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt = new Date()): Promise { + await this.updateOne({ '_id': callId, 'participants.id': userId }, { $set: { 'participants.$.leftAt': leftAt } } as any); + } } diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts new file mode 100644 index 0000000000000..49d1f0871f6fc --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts @@ -0,0 +1,41 @@ +import { VIDEO_CONF_RINGING_LIMIT } from '@rocket.chat/core-typings'; +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfAddParticipantsProps = { + callId: string; + users: string[]; + /** + * Whether to ring the people being added. Defaults to ringing: someone added to a call in progress is being + * called *now*, and the whole point of adding them is usually that they are wanted in it. + */ + ring?: boolean; +}; + +const videoConfAddParticipantsPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + users: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, + // Adding is capped so the whole batch can always be rung, which is why it is the ringing limit itself. + maxItems: VIDEO_CONF_RINGING_LIMIT, + }, + ring: { + type: 'boolean', + nullable: true, + }, + }, + required: ['callId', 'users'], + additionalProperties: false, +}; + +export const isVideoConfAddParticipantsProps = ajv.compile(videoConfAddParticipantsPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts new file mode 100644 index 0000000000000..84b8001d6bfee --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfCallIdProps.ts @@ -0,0 +1,31 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +/** + * The body of every conference endpoint that only has to say *which* call: cancel, decline, leave. + * + * They had a validator each, character for character the same, which is three places to keep in step for one + * shape. What each endpoint then *does* with the call is where they actually differ. + */ +export type VideoConfCallIdProps = { + callId: string; +}; + +const videoConfCallIdPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isVideoConfCallIdProps = ajv.compile(videoConfCallIdPropsSchema); + +/** The name this shape shipped under before it was shared. Kept because it is part of the published surface. */ +export type VideoConfCancelProps = VideoConfCallIdProps; +export const isVideoConfCancelProps = isVideoConfCallIdProps; diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts deleted file mode 100644 index 9f93576aa302d..0000000000000 --- a/packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -import { ajv } from '../Ajv'; - -export type VideoConfCancelProps = { - callId: string; -}; - -const videoConfCancelPropsSchema: JSONSchemaType = { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - }, - required: ['callId'], - additionalProperties: false, -}; - -export const isVideoConfCancelProps = ajv.compile(videoConfCancelPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts new file mode 100644 index 0000000000000..7e366a2ee1e67 --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRenameProps.ts @@ -0,0 +1,29 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfRenameProps = { + callId: string; + /** What to call the conference. Trimmed, and only the person who started it may set it. */ + title: string; +}; + +const videoConfRenamePropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + title: { + type: 'string', + nullable: false, + minLength: 1, + maxLength: 200, + }, + }, + required: ['callId', 'title'], + additionalProperties: false, +}; + +export const isVideoConfRenameProps = ajv.compile(videoConfRenamePropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts new file mode 100644 index 0000000000000..64f0b47197dac --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts @@ -0,0 +1,29 @@ +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfRingProps = { + callId: string; + /** Ring only these members. Omitted, everyone who isn't in the call is rung. */ + users?: string[]; +}; + +const videoConfRingPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + users: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + nullable: true, + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isVideoConfRingProps = ajv.compile(videoConfRingPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts new file mode 100644 index 0000000000000..a294303c02400 --- /dev/null +++ b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts @@ -0,0 +1,29 @@ +import type { VideoConferenceChatAccessMode } from '@rocket.chat/core-typings'; +import type { JSONSchemaType } from 'ajv'; + +import { ajv } from '../Ajv'; + +export type VideoConfShareChatProps = { + callId: string; + /** When omitted the room's own rules decide; `invite` is rejected for rooms that can't take new members. */ + mode?: VideoConferenceChatAccessMode; +}; + +const videoConfShareChatPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + mode: { + type: 'string', + enum: ['invite', 'discussion'], + nullable: true, + }, + }, + required: ['callId'], + additionalProperties: false, +}; + +export const isVideoConfShareChatProps = ajv.compile(videoConfShareChatPropsSchema); diff --git a/packages/rest-typings/src/v1/videoConference/index.ts b/packages/rest-typings/src/v1/videoConference/index.ts index 02dcc67cc7137..1ea564350db3e 100644 --- a/packages/rest-typings/src/v1/videoConference/index.ts +++ b/packages/rest-typings/src/v1/videoConference/index.ts @@ -1,9 +1,19 @@ -import type { VideoConferenceInstructions, VideoConference, VideoConferenceCapabilities } from '@rocket.chat/core-typings'; +import type { + JoinableVideoConference, + VideoConferenceInstructions, + VideoConference, + VideoConferenceCapabilities, + VideoConferenceChatAccess, +} from '@rocket.chat/core-typings'; -import type { VideoConfCancelProps } from './VideoConfCancelProps'; +import type { VideoConfAddParticipantsProps } from './VideoConfAddParticipantsProps'; +import type { VideoConfCallIdProps } from './VideoConfCallIdProps'; import type { VideoConfInfoProps } from './VideoConfInfoProps'; import type { VideoConfJoinProps } from './VideoConfJoinProps'; import type { VideoConfListProps } from './VideoConfListProps'; +import type { VideoConfRenameProps } from './VideoConfRenameProps'; +import type { VideoConfRingProps } from './VideoConfRingProps'; +import type { VideoConfShareChatProps } from './VideoConfShareChatProps'; import type { VideoConfStartProps } from './VideoConfStartProps'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; @@ -11,7 +21,11 @@ export * from './VideoConfInfoProps'; export * from './VideoConfListProps'; export * from './VideoConfStartProps'; export * from './VideoConfJoinProps'; -export * from './VideoConfCancelProps'; +export * from './VideoConfRingProps'; +export * from './VideoConfCallIdProps'; +export * from './VideoConfAddParticipantsProps'; +export * from './VideoConfShareChatProps'; +export * from './VideoConfRenameProps'; export type VideoConferenceEndpoints = { '/v1/video-conference.start': { @@ -19,15 +33,62 @@ export type VideoConferenceEndpoints = { }; '/v1/video-conference.join': { - POST: (params: VideoConfJoinProps) => { url: string; providerName: string }; + // Embedded providers (e.g. LiveKit) return an empty `url` and include + // `callId` + `rid` instead — the client routes the join into the + // embedded provider's React context rather than opening a popup URL. + POST: (params: VideoConfJoinProps) => { url: string; providerName: string; callId?: string; rid?: string }; + }; + + /** Records that the caller left the call, ending the conference when nobody is left in it. */ + '/v1/video-conference.leave': { + POST: (params: VideoConfCallIdProps) => void; + }; + + /** + * Renews the caller's presence lease on the call. Leaving is inferred from these stopping, so that a departure + * nobody could report — a workspace outage, a crashed tab — is still recorded. + */ + '/v1/video-conference.heartbeat': { + POST: (params: VideoConfCallIdProps) => void; + }; + + /** Rings the members who aren't in the call again; returns the ids actually rung. */ + '/v1/video-conference.ring': { + POST: (params: VideoConfRingProps) => { rang: string[] }; }; '/v1/video-conference.cancel': { - POST: (params: VideoConfCancelProps) => void; + POST: (params: VideoConfCallIdProps) => void; + }; + + '/v1/video-conference.decline': { + POST: (params: VideoConfCallIdProps) => void; + }; + + '/v1/video-conference.add-participants': { + POST: (params: VideoConfAddParticipantsProps) => { added: string[] }; + }; + + /** Renames a running group conference. Only the person who started it may. */ + '/v1/video-conference.rename': { + POST: (params: VideoConfRenameProps) => void; + }; + + '/v1/video-conference.share-chat': { + POST: (params: VideoConfShareChatProps) => { rid: string }; }; '/v1/video-conference.info': { - GET: (params: VideoConfInfoProps) => VideoConference & { capabilities: VideoConferenceCapabilities }; + GET: (params: VideoConfInfoProps) => VideoConference & { + capabilities: VideoConferenceCapabilities; + /** Where the chat lives, who among the members cannot read it, and how that can be resolved. */ + chatAccess: VideoConferenceChatAccess; + }; + }; + + /** The calls running now that the caller may join — how a call is reached without catching its ring. */ + '/v1/video-conference.joinable': { + GET: () => { calls: JoinableVideoConference[] }; }; '/v1/video-conference.list': { From 46e3e92d9117c77980a5ce2ad1cf5df47675203a Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 10:16:46 -0300 Subject: [PATCH 02/31] fix(video-conf): skip presence lease expiry for non-embedded providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-embedded providers (Jitsi, Meet, Pexip) open in an iframe/popup that doesn't send heartbeats, so every lease looks expired and the sweep would end their calls after 3 minutes. Guard the sweep to only process embedded providers — non-embedded calls keep using the existing 24-hour TTL cron as their only cleanup. Co-Authored-By: Claude Opus 4.6 --- .../services/video-conference/service.ts | 8 ++++ .../expirePresenceLeases.spec.ts | 47 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 3f8a8f66eab00..5fa223078e7b3 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1603,6 +1603,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf public async expirePresenceLeases(now = new Date()): Promise { for await (const call of VideoConferenceModel.findActiveWithMembers()) { try { + // Presence leases only apply to embedded providers, whose call window is ours and sends heartbeats. + // Non-embedded providers (Jitsi, Meet, Pexip) open in an iframe/popup we don't control — no heartbeat + // is sent, so every lease would look expired and the sweep would end every call after 3 minutes. + // Those calls are cleaned up by the 24-hour TTL cron instead, exactly as they were before leases existed. + if (!videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + continue; + } + // A provider that can say who is in its room is asked first, and its answer renews leases the same // way a client's heartbeat does. Silence is not absence: `undefined` leaves the leases as they are. const present = await videoConfPresence.getProbe(call.providerName)?.(call); diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts index 799e5f71ec4f6..af14ae5d2eef5 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -1,4 +1,4 @@ -import type { IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import type { IVideoConferenceUser, VideoConference, VideoConferenceCapabilities } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import sinon from 'sinon'; @@ -49,6 +49,11 @@ const VideoConfService = createService({ }, overrides: { '../../lib/videoConfPresence': { videoConfPresence: { getProbe: () => probe } }, + '../../lib/videoConfProviders': { + videoConfProviders: { + getProviderCapabilities: () => ({ embedded: true }), + }, + }, }, }); @@ -178,4 +183,44 @@ describe('VideoConfService.expirePresenceLeases', () => { expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; }); }); + + describe('non-embedded providers', () => { + const NonEmbeddedModelMock = { + findActiveWithMembers: sinon.stub().callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })), + setUserLeftById: sinon.stub().resolves(), + setDataById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + }; + + const NonEmbeddedService = createService({ + models: { VideoConference: NonEmbeddedModelMock }, + overrides: { + '../../lib/videoConfProviders': { + videoConfProviders: { + getProviderCapabilities: (): VideoConferenceCapabilities => ({}), + }, + }, + }, + }); + + let nonEmbeddedService: any; + + beforeEach(() => { + nonEmbeddedService = new NonEmbeddedService(); + resetAll(NonEmbeddedModelMock.setUserLeftById, NonEmbeddedModelMock.setDataById, NonEmbeddedModelMock.setStatusById); + }); + + it('skips calls from non-embedded providers (Jitsi, Meet, etc.)', async () => { + fixture = buildGroupCall([buildMember({ _id: 'jitsiUser', lastSeenAt: at(-PRESENCE_LEASE_MS * 2) })]); + + await nonEmbeddedService.expirePresenceLeases(at(0)); + + expect(NonEmbeddedModelMock.setUserLeftById.called).to.be.false; + expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); + }); + }); }); From a6f7842222e7c9592197eb9a4cf5bd39d1261603 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 10:22:15 -0300 Subject: [PATCH 03/31] chore(video-conf): remove jwt, i18n, desktop-api, docs and changeset These belong in the persistent-chat branch: jwt (HS256 signing for LiveKit), desktop-api (IVideoCallWindow), i18n keys (consumed by client UI), feature docs, and the changeset. Keeping this backend branch focused on server-only changes. Co-Authored-By: Claude Opus 4.6 --- .changeset/videoconf-persistent-chat.md | 27 - .../README.md | 997 ------------------ .../adding-people-and-chat-access.svg | 59 -- .../being-called.svg | 56 - .../ending-a-call.svg | 47 - .../matrix-comparison.md | 186 ---- .../starting-a-call.svg | 55 - packages/desktop-api/src/index.ts | 7 - packages/jwt/src/index.ts | 40 - 9 files changed, 1474 deletions(-) delete mode 100644 .changeset/videoconf-persistent-chat.md delete mode 100644 docs/features/video-conference-persistent-chat/README.md delete mode 100644 docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg delete mode 100644 docs/features/video-conference-persistent-chat/being-called.svg delete mode 100644 docs/features/video-conference-persistent-chat/ending-a-call.svg delete mode 100644 docs/features/video-conference-persistent-chat/matrix-comparison.md delete mode 100644 docs/features/video-conference-persistent-chat/starting-a-call.svg diff --git a/.changeset/videoconf-persistent-chat.md b/.changeset/videoconf-persistent-chat.md deleted file mode 100644 index 28d2122fb2e3f..0000000000000 --- a/.changeset/videoconf-persistent-chat.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@rocket.chat/core-typings': minor -'@rocket.chat/core-services': minor -'@rocket.chat/model-typings': minor -'@rocket.chat/models': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/ddp-client': minor -'@rocket.chat/fuselage-ui-kit': minor -'@rocket.chat/ui-client': minor -'@rocket.chat/ui-kit': minor -'@rocket.chat/ui-voip': minor -'@rocket.chat/i18n': minor -'@rocket.chat/mock-providers': minor -'@rocket.chat/meteor': minor ---- - -Gives a video conference a chat that outlives it, and a window of its own to hold both. - -Joining a conference now opens a dedicated call window at `/conference/:id` — the provider's call beside the conference's chat, with the people on the call in a panel of their own — instead of handing the user off to the provider's page. A preflight screen opens first: it is where the camera and microphone are chosen, where whoever started a group call can name it, and where confirming is what actually creates the call, so a call nobody confirmed leaves no message, no ring and no history behind. Closing the window reports leaving, and a call nobody is left in ends by itself. - -Adding someone to a conference makes them a member of the **conference** rather than putting them in a room. Membership authorizes joining the call alongside room access, so a person from outside the conference's room can join without being handed the room's history — and whether they can read the chat becomes a separate question, surfaced once it matters with a choice of how to resolve it: bring them into the room, or move the chat to a discussion. `video-conference.info` reports the members who can't read it and `POST /v1/video-conference.share-chat` applies the remedy; `video-conference.add-participants` takes just the users and returns the ids it added. - -An incoming call is no longer a popup demanding an answer. It is the first item of a list of the calls running now — docked in the sidebar — where it can be accepted, turned down, or silenced and left ringing while the user finishes what they were doing. That list is also how a call is reached when its ring was missed entirely, which a one-shot ring in a room of more than ten people always is (`GET /v1/video-conference.joinable`). - -Conferences appear in the personal Call History from the moment they start, as `ongoing`, settling per member into `ended` or `not-answered` when the call stops — so a call that was declined or never answered is still in the log, and still joinable from it. Conference discussions carry a banner back into the ongoing call, and the room's own call list stops counting members who were added but never joined. - -New endpoints: `video-conference.decline` (recorded against the caller's own membership, never ending the call for anyone else), `.leave`, `.ring` (to try someone again — a ring is one-shot, so there was previously no second attempt), `.rename` and `.share-chat`. A single `video-conference.updated` stream event tells an open call window that the conference it is showing has changed. diff --git a/docs/features/video-conference-persistent-chat/README.md b/docs/features/video-conference-persistent-chat/README.md deleted file mode 100644 index 6e3cc4b55402e..0000000000000 --- a/docs/features/video-conference-persistent-chat/README.md +++ /dev/null @@ -1,997 +0,0 @@ -# Video Conference Persistent Chat - -## Overview - -Persistent chat gives a video conference a Rocket.Chat room that lives alongside the call, so the conversation survives after the call ends. Instead of handing the user off to the provider's own page, joining a conference opens an in-product page at `/conference/:id` — the provider's call in an iframe, a control bar along the bottom, and the conference's chat in a collapsible panel docked to the inline end. - -The chat can run in one of two modes, controlled by `VideoConf_Persistent_Chat_Mode`: - -- **Thread** (default): the chat panel renders a thread started from the conference message in the original channel. No discussion room is created. Access is based on the parent channel — anyone who can read the channel can participate in the thread. -- **Main room** (`main_room`): the chat panel shows the channel itself. A separate discussion room is created off the parent channel when needed (requires `Discussion_enabled`). The chat room is resolved from `discussionRid` when the discussion exists, otherwise the conference's `rid`. A conference's `rid` never changes; only `discussionRid` moves. - -Gated by the EE setting `VideoConf_Enable_Persistent_Chat` (module `videoconference-enterprise`). - -## The flows at a glance - -Four diagrams covering a call's life. They describe the feature with persistent chat **on**; with it off none of it -applies — see [Opening a Conference](#opening-a-conference) for what happens instead. - -| | | -|---|---| -| [Starting a call](./starting-a-call.svg) | the camera button, the preflight, and what confirming creates | -| [Being called](./being-called.svg) | accept, decline, silence or ignore — and where each leaves the call | -| [Adding people and chat access](./adding-people-and-chat-access.svg) | who can read the chat, and the two ways to fix it | -| [Ending a call](./ending-a-call.svg) | the four ways a call stops, and what the history records | - -[How this compares to MatrixRTC](./matrix-comparison.md) sets our answers to "who is in this call" and "who may -join it" against Matrix's, and lists the three things worth borrowing. - -Starting a call: the camera button opens a call window at /conference/new showing a preflight; nothing is created until the user confirms, after which the conference exists and the other side rings. - -Being called: a ring reaches you in your call list or as a notification; accepting joins outright, declining is recorded against your own membership only, silencing stops the sound, and ignoring lets the ring lapse after 15 seconds. - -Adding people: someone already in the room can read the chat, someone from outside cannot; once they join, a notice offers either adding them to the room or moving the chat to a discussion, depending on the room type. - -Ending a call: the last person leaving, joining another call, or the 24-hour expiry all end it; an emptied call waits ten seconds so a reload survives, and each member's history row settles to ended or not-answered. - -## Opening a Conference - -**Placing a call** (`startCall`) with persistent chat on posts *nothing*. It opens the call window at -`/conference/new?rid=…`, and the conference is created there, by the [preflight](#the-preflight-screen). Without -persistent chat it goes through `VideoConfManager.startCall` as it always has. - -The room's call button goes straight there. It used to open a popup to confirm and set devices first, which the -preflight now does with the user able to see what they are joining — two confirmations for one call. The popup -remains the only place to set devices when there is no preflight, so it is still what an unconfigured -persistent-chat workspace gets. - -**Joining one that exists** (`joinCall`) emits `call/join`: - -- **Persistent chat enabled** — `{ callId }`, and again nothing is posted: the conference page joins for itself - once its preflight is confirmed. -- **Disabled** — `POST /v1/video-conference.join` first, then `{ url, callId, providerName }`, the pre-existing - behavior. - -`VideoConfProvider` handles `call/join` by opening `/conference/:id` (absolute URL) with persistent chat on, or -the provider URL without it, and `useVideoConfOpenCall` opens the window. On desktop, -`openInternalVideoChatWindow` takes over. - -### The window opens on the click - -Every call type opens its window **on the click that asked for it**, inside the browser's user-activation window. -`window.open` from anything later — a stream event, a timer — is something the browser is entitled to refuse, and -`VideoConfBlockModal` then has to ask the user to click again for a window they already asked for. A direct call -used to be the exception: it rang the callee and kept the caller waiting in the room, opening the window only once -the answer arrived, which is exactly the refusable case. - -So the wait moves into the call window, and the room stops showing an outgoing popup for a call the user is -already sitting in. Telling the caller that nobody picked up is [deferred](#deferred-to-follow-ups); for now the -members panel shows the other side still ringing. - -### When the callee is rung - -Creating a direct call is not asking anyone to answer it. The caller lands on the [preflight](#the-preflight-screen) -first, so the ring waits for them to actually enter the call: `addUserToCall` rings the other side when the -**caller** arrives, and only members who have never been rung — a rejoin rings nobody. A second attempt is what -the members panel's per-member *ring* is for. - -Being rung into a call whose caller is still choosing a camera means answering to an empty room, which is what -this avoids. The screen says as much before it happens ("Alice will be notified when you start the call") and the -button is the call itself rather than a join. - -With persistent chat **off** there is no preflight to wait for, so nothing changes: the caller's own client rings -the callee from the room, on the 1:1 handshake it always used. - -### How the call window is opened - -A call opens as a **popout** — a dedicated window sized to 1280×800 (capped to the available screen) and centred — mirroring the desktop app's dedicated video window and keeping the call visible while the user works in the main app. If the popout is refused, it falls back to an ordinary **tab**; some browsers and extensions block popup-shaped windows while still allowing a plain one. Only if both are blocked does `VideoConfBlockModal` ask the user to allow it. - -`noopener` is deliberately **never** in the features string: it makes `window.open` return `null`, which is indistinguishable from a blocked popup, and the opener link is what lets the main app notice the call window closing (see [The window that opened the call watches it](#the-window-that-opened-the-call-watches-it)). - -Same-origin (in-product) conferences share a named window, `rocketchat-conference`, so repeated joins reuse it instead of stacking duplicates: - -| State of the shared window | Behaviour | -|---|---| -| already showing this conference | focused without reloading (empty URL) and **without features**, so a window the user has arranged is not resized or recentred | -| showing a different conference | navigated to the new one | -| closed, or never opened | opened fresh as a popout | - -Whether it is showing this conference is decided by reading the window's actual `location.pathname`, not the URL we last passed — those differ in string form between the start and join paths. - -External provider URLs (persistent chat off) get their own popout each time, unnamed. - -## The preflight screen - -Opening the call window and being in the call are two different things, and the window opens first. What it shows -until the user says otherwise is `ConferencePreflight`: what the call is called, the devices they will arrive -with, and — for whoever started a group call — a field to name it. - -### Nothing exists until it is confirmed - -Clicking *call* in a room used to create the conference: a message in the room, a ring, a call in everyone's -history — for a call the user might still walk away from. Now the click only opens the window, at -`/conference/new?rid=…`, and `ConferenceStartPage` runs the preflight against the *room*: the name to offer comes -from the reader's own subscription (which is what names a DM after the other person), the devices from -`video-conference.capabilities`. Confirming posts `start` and then `join`, hands the join result to the conference -page through the query cache, and replaces the URL with `/conference/:callId` — so a reload lands on the call -rather than starting a second one, and the page doesn't ask the same questions again. - -**Cancel** sits beside the confirm button and closes the window. On the start screen that leaves no trace at all, -because nothing was created; on a call that already exists it reports leaving first. - -### Why the join waits - -The window has to open on the click, as above. The *join*, though, is what turns mic and camera into the -provider's URL and what marks the user as present in the call — so it waits here instead: - -- `VideoConfManager.joinCall` posts nothing when persistent chat is on — it only opens the window. Posting there - would throw away the URL it returns and count the user as present in a call they have not chosen to enter yet. -- `useConferenceEmbedded` joins as a mutation, from the preflight's confirmation, carrying the preferences it was - given. - -Devices are configured **only** here. The room's start-call and incoming-call popups used to ask, seconds before -a window opened, and then the conference page joined with a hardcoded `{ mic: true, cam: false }` regardless — -so the popups now leave the question alone whenever persistent chat is on. With it off there is no preflight to -ask, so those controls stay exactly as they were. - -What is on offer is what the provider can be told: today the pair it takes, on or off. They sit in `CallBar`, the -same bar the call's own controls occupy, so the control that mutes the mic doesn't move between deciding to join -and being in the call. A native provider will put input and output selection in the same place. - -### What the screen says it is - -A title, because the same screen serves four situations and they are not interchangeable: *Start a new -conference* / *Start conference with Alice* when nothing exists yet, *Join the conference* / *Join conference with -Alice* when it does. The confirm button follows suit — **Start call**, **Call Alice**, or **Join call**. - -The name field sits above the tile, because it is the one thing here that is about the *call* rather than about -how the user shows up in it, and it carries no label: the field is its own label, prefilled with *Meeting in -<room>* for a conference that doesn't exist yet. The room's name is not repeated anywhere else on the screen — -it is either in the title or in that field. - -### No self-view, on purpose - -Where a preview would sit, the screen states what will happen: *your camera is turned off*, or *your camera will -be on* plus where the devices themselves are chosen. There is no `getUserMedia`, so no permission prompt and no -camera held open while the provider is about to ask for the same one. - -That is not a shortcut — a preview would be a lie about the control on offer. All a provider can be told is -whether to start with camera and microphone on; *which* camera, which microphone, which speaker is settled inside -the provider's own UI. A self-view would promise a choice this screen cannot make, and could show a camera the -call never uses. A native provider, able to take a device per stream, is what makes a real preview honest — and -the same tile is where it will go. - -### Naming the call - -A group conference is named on the way in: the field is prefilled with the room's name, and confirming carries it -to `start` as the conference's title. For a call that already exists — its creator opening the preflight again — -the same field goes to `POST /v1/video-conference.rename`, which sets the title of a running **group** -conference, for the person who started it. A direct call has no title of its own — it is named after the other person, per viewer — and a title everyone -in the call could rewrite is a title nobody can rely on. - -The name matters beyond the label: it is what the provider is told to call the meeting (`customCallTitle`, read -at join time — which is *after* the preflight), and what the call is listed as in the sidebar. -The field is prefilled with what the call is called today, which for a fresh conference is the room it was started -in. Renaming is not worth failing a join over: if it doesn't take, the error is surfaced and the user goes into -the call anyway, which is what they actually asked for. - -## Layout - -The conference renders **standalone**, without the app's navigation chrome. - -`LayoutWithSidebar` (NavBar + Sidebar + `MainContent`) is applied by `MainLayout`, not by the authentication chain. This matters: `AuthenticationCheck → LoggedInArea → UsernameCheck → PasswordChangeCheck → TwoFactorAuthSetupCheck` is shared by every authenticated route, so anything it renders would also appear on the conference page. `TwoFactorAuthSetupCheck` therefore returns `children` directly. - -The conference route is the only consumer of `AuthenticationCheck` outside `MainLayout`; every other route (including dynamic admin/account/room/audit groups) wraps in `MainLayout` and keeps the chrome. - -`AuthenticationCheck` also had to learn the difference between "not logged in" and "not logged in *yet*": it -decided from `useUser()` alone, which is null while a stored session is still being resumed, so a window opening -with a session already in hand — a call popout above all — flashed a login form for as long as that took. It now -waits when a stored login token says a resume is coming; a stale token is cleared when the resume fails, landing as -an ordinary logged-out visitor, and a forced login still goes straight to the form. - -The stored token is deliberately the whole of that test. `isLoggingIn` reads as the more direct question and was -asked alongside it at first, but it is true of *any* login in flight — including one someone is making at the form -right now. That unmounted the form mid-attempt, so a rejected password came back to a blank form with neither field -marked invalid, and iframe login could never show its own form at all, since the flow that fetches its URL runs -from inside `LoginPage`. The token covers the resume from end to end on its own: it is written before the window -loads and removed only on an explicit logout or a failed resume. - -The chain's *loading placeholder* needed the same treatment. `UsernameCheck` shows `HomeSkeleton` — a whole fake -app shell — while it resolves the user, so `AuthenticationCheck` and `UsernameCheck` take an optional `loading` -node, defaulting to `HomeSkeleton` so no existing route changes. The conference route passes `PageLoading`, which -is also what the conference shows while joining, making startup one continuous state rather than two. - -Because it has no `MainContent` ancestor to inherit height from, `ConferenceRoute` establishes the `100dvh`/`100%` box the conference fills. The route is also wrapped with `appLayout.wrap(..., { embedded: true })`, which drops the global banner and cloud-announcement regions. - -### Call chrome - -The conference is a column: a row holding the call and the chat panel, then `CallBar` beneath it. - -`CallBar` is the in-call control bar pinned along the bottom — the position third-party providers put their own toolbar in, so an embedded provider and the future native conference read the same. Its actions sit at the inline end, away from wherever the provider puts its own. Today that is the members and chat toggles (the chat one carrying an unread badge while its panel is closed). When the native conference brings mic, camera and hang-up of its own they will want the centre of the bar, which is the point at which what the centre needs will be known rather than guessed at. - -`CallPanel` is the product's own `Contextualbar`, so a panel beside a call has the same edges and elevation as one beside a room; it is a **sibling of the call area, not a child of the bar**. That is what makes toggling the chat animate its own width without ever reflowing the bar — the bar stays full width and fixed in place by construction, not by careful sizing. Its inner box keeps full width while the outer collapses, so content slides instead of reflowing mid-animation. On viewports narrower than `md` it floats over the call instead of taking width from it. - -The panel is docked to the inline end, so its close button sits at the far end of its header — matching every other closable surface in the product. Both panels share that header (`CallPanelHeader`, the contextual bar's own header/title/close), so two docked side by side can't disagree about where their own edges are. - -### Stage layout - -The call stage (`CallStage`) supports three layouts, cycled by a button in the control bar: - -- **Grid** (default) — all participants in equal-sized tiles, rows/cols computed by `useTileGridLayout` to fill the stage within a [3:4 .. 16:9] aspect band. When there are more than 9 participants, only 8 tiles are shown plus a "+N" overflow placeholder; tiles with camera enabled and the active speaker are prioritised for the visible slots, and the local participant always stays visible. To simulate many participants for testing, set `localStorage.setItem('videoconf-simulate-tiles', '20')` in the browser console before joining a call. -- **Spotlight** — the active speaker fills the stage; the local user's self-view floats as a small PiP in the bottom-right corner. When the local user *is* the active speaker, the first remote participant is shown large instead. -- **Sidebar** — the active speaker is large on the left, other participants are shown in a thumb column on the right (or row at the bottom on narrow stages). The number of visible thumbs is dynamically limited to what fits without scrolling: the capacity is computed from the stage size and thumb dimensions (column: 200 px wide, 16:9 aspect; row: 140 px wide, 96 px strip). When there are more participants than fit, the last slot shows a "+N" overflow placeholder (camera-on and local participant are prioritised for the visible slots). The thumb container never scrolls. - -Active speaker detection runs in `useActiveSpeakerId`: a single `AudioContext` with one `AnalyserNode` per participant, sampling at ~12 Hz. A 1.5 s hold prevents flickering between speakers during conversational pauses. When nobody is speaking, the fallback is the first remote participant. - -When a screen share is active, the existing screen-share spotlight takes over regardless of the selected layout — the screen always wins. - -The bar carries two counts: how many people are in the call, and what is unread in the chat while its panel is -closed. The unread one goes through `useUnreadDisplay`, the sidebar's own rules, so a mention reads as urgent in -both places and a muted room stays quiet in both. The members count is deliberately `secondary` — a count of who is -here is information, and a red badge would read as a problem. - -Knowing what is unread needs the room's subscription, and that is the **page's** business rather than the chat -panel's: the badge exists precisely when that panel is closed, and a panel that isn't mounted can't keep anything -fresh. `useConferenceSubscription` seeds it and follows `subscriptions-changed` for the life of the page. Nothing -else would — the conference renders outside the main app, so the sidebar's own watcher never starts. - -## Route Behavior (`/conference/:id`) - -| Condition | Renders | Auth | -|-----------|---------|------| -| `?callUrl=` present | `ConferencePage` — hands off to the provider's external URL | `guest` allowed | -| `:id` is `new`, with `?rid=` | `ConferenceStartPage` — the preflight for a conference that doesn't exist yet | authentication required (`guest={false}`) | -| `:id` present | `ConferenceEmbeddedPage` — call + chat split view | authentication required (`guest={false}`) | -| neither | `ConferencePageError` | — | - -Guests can't be members of the conference's room, so the embedded page requires a real account. A user without access to the conference's room gets `ConferenceUnauthorizedPage`, which logs out **without navigating away**, so re-login returns to the same conference. It and `ConferencePageError` are the same `ConferenceStatePage` with different words: the window is all the user has, so both keep the conference header and carry whatever way out they have. - -## Chat Panel - -The conference page renders one room outside the main app, so the cached stores the room UI reads from are never populated by the sidebar's subscriptions: - -- `ConferenceStoresReady` marks the cached stores ready. That is all it does: the room UI waits on them being - *ready*, not on them being full, and the one room in play is fetched by `useOpenRoomById` below. It used to - fetch that room here as well, which meant two `rooms.info` for the same room a moment apart. -- In **main room mode**, `ConferenceRoom` opens the room by id (`useOpenRoomById`), forces `isEmbedded` layout, and subscribes to `notify-user/…/subscriptions-changed` to keep unread counts fresh (no sidebar watcher is running). -- In **thread mode**, `ConferenceThread` opens the original channel via `RoomProvider`, then mounts a `ChatProvider` with `tmid` (the conference message's `_id`) and renders `ConferenceThreadChat` — a thread message list and composer scoped to the conference message. Access is governed by the parent channel; no discussion is created. Participants are auto-followed on the thread when they join the call (see [Thread Auto-Follow](#thread-auto-follow)). -- `useOpenRoomById` is the by-rid counterpart to the router-driven `useOpenRoom`. It fetches via `GET /v1/rooms.info` (hence `mapRoomFromApi` to deserialize dates) and falls back to fetching the subscription directly, since `Subscriptions.state` may be empty here. - -`LegacyRoomManager.open` is what starts the message stream the composer waits on. It resolves rooms by **name** for channels/groups but by **rid** for DMs — passing the wrong identifier leaves the composer stuck loading. - -`ConferenceRoom` also carries `narrowRoomStyle`, which reclaims horizontal space for the 400px panel: it restores the composer's inline padding (the embedded layout zeroes it, sized for the tiny `?layout=embedded` iframe) and trims the message start padding and avatar gutter margin. It is scoped to that subtree, so the room's normal full-width appearance and every external embed are untouched. Only the *start* padding is trimmed — the message toolbar and timestamp column sit against the end padding and need the room. - -The call iframe is named with `aria-label` rather than `title`: a `title` on a full-viewport iframe also renders as a hover tooltip, floating a label over the call for as long as the pointer is inside it. - -Video conference message blocks inside the panel have their join/call-back actions disabled (`videoConfJoinDisabled`, set when the current route is `conference`) — joining another conference from inside a conference would replace the call the user is in. - -When the preflight opens for a conference that has already ended (`endedAt` is set on the info response), a "Call ended" state page is shown instead of the preflight — with a Close button that tears down the window. The real-time `updated` subscription also catches a call ending while the user is still on the preflight. - -### Thread Auto-Follow - -When persistent chat is in **thread** mode, participants are automatically subscribed to the call's chat thread -(`messages.started`) when they join the call, so they receive thread notifications for messages posted during the -conference without having to manually follow the thread. - -Two hooks in `VideoConfService` implement this: - -- `autoFollowCallThread` — called from `addUserToCall` after a participant is successfully added. If persistent - chat is enabled, mode is `thread`, and `messages.started` exists, the user is followed on the thread via the - same `follow()` function that the manual "Follow message" action uses. The underlying `$addToSet` is - idempotent, so re-joining a call does not create duplicates. - -- `autoFollowCallThreadForAllParticipants` — called from `startDirect`, `startGroup` and `startLivechat` right - after `messages.started` is first set. It retroactively follows every user already in `call.users`, covering - the edge case where a participant joined between call creation and the started message being persisted. - -Both methods are no-ops when persistent chat is disabled or when the mode is `main_room`. - -## Members Panel - -Who is on the call and where each of them stands. It shares the side panel with the chat — **one at a time**, -since two side panels would leave the call a sliver — and it is the one open by default: on arriving in a call -the useful question is who else is here, and for the caller of a call still ringing it is the only place that -answers it. A bar button switches between the two, and the provider bridge's chat commands act on the chat -specifically rather than closing whatever happens to be open. - -It is split in two — **In call** and **Not in the call** — because the halves answer different questions: who is -here, and who still isn't. A section nobody is in isn't shown. Rows are shaped like the room's own members list -(avatar, name, `@username`, presence) so the two read the same way, and members in the call need no label beyond -the section they are in. - -For the rest, one status from `getConferenceMemberStatus`: - -| Status | Meaning | -|---|---| -| **Ringing** | rung within the last 15s, and hasn't answered yet | -| **Waiting for answer** | rung longer ago than that, and never answered | -| **Declined** | dismissed the ring | -| **Left** | joined and left since | - -The entry accumulates rather than replaces — `joined` never goes back to false, and a decline stays recorded -after the person changes their mind — so the fields are read in order of what happened *last*. Being in the call -beats everything; having left beats an earlier decline, since they did answer. - -Members who can't read the chat carry an icon beside their name — beside, because it qualifies who that person -is in the call, and a second line pushed every row apart for something most members never have. It is the one -thing about a member the other participants can act on (from the notice above the call). Anyone not currently in the call can be **rung individually**, -including someone who declined or left — "call them back" is exactly that case. - -The ring button is offered only when there is something to ask for: not while they are in the call, and not while -their phone is *already* ringing (`canRingConferenceMember`). `ringingAt` on the entry is what makes that knowable -to everyone rather than only to whoever pressed the button — every ring records itself, including the one that -starts a direct call. A ring stops on its own with nothing to announce it, so the row wakes itself when its window -is up, through the same `useRingingExpiry` the calls list uses. - -This panel is where the membership model becomes visible at all: before it, a decline was recorded and an outside -member counted in aggregate, with nowhere to see either against a name. - -## Confined Navigation - -The chat panel is a full room UI, so a link, channel reference or user mention would navigate the conference window away from `/conference/:id` and **tear down the call**. `useConfinedNavigation` pins the window to the conference, covering both interaction paths: - -- **`` clicks** — intercepted on the *capture* phase, so it runs before React/router handlers. Left alone: modified/non-primary clicks, `target` other than self/top/parent, `download`, non-http(s) protocols, and same-path URLs (`?jump=`, `#hash`) which the app handles in place. -- **Programmatic `router.navigate`** — mentions and room links don't go through an anchor, so the shared `navigate` is monkey-patched. The patch is idempotent (`_confined` marker) and cleanup only restores when its own wrapper is still installed, so a newer patch is never clobbered and a stale one never reinstated. Numeric deltas and same-pathname navigations pass through untouched. - -Anything that would leave the conference opens in a **`noopener` new tab**, internal or external alike. - -In **main room** chat mode the panel renders the full room, where thread indicators are visible but the conference route has no `tab`/`context` params to open them. The same wrapper detects a thread navigation (`params.tab === 'thread'`) and calls an `onOpenThread` callback instead of navigating, which opens the thread in a `ConferenceThreadModal` — a Fuselage `Modal` wrapping `ConferenceThread`. The callback is only wired when the chat mode is main room (no `tmid`); in thread mode the panel renders the thread directly and there are no indicators to click. - -Handing internal routes to the window that launched the call would read better — the link would land in the app -the user already has open, as a client-side navigation rather than a fresh tab — but it needs a desktop bridge and -a `postMessage` handshake with the opener. That is [deferred](#deferred-to-follow-ups); a tab is the honest -one-line version until it earns its own change. - -## Adding Participants - -Adding someone to a conference makes them a **member of the conference**. It puts them in no room: membership is what authorizes joining the call, and being able to read the chat is a separate concern, surfaced afterwards rather than decided here. See [Chat Access](#chat-access). - -`AddParticipantsModal` picks users with `UserAutoCompleteMultiple`, the same component the room's own "add users" flow uses, given an `exceptions` list. The conference's room members are excluded — they can already join, so adding them would be a no-op — and everyone else is offerable, which is the point. The exclusion list is best-effort: a member who can't read the chat has no room to enumerate, and the modal still works for them, offering everyone. - -> Dial-out (typing a raw phone/SIP destination into the same field) is **not** wired up. No provider on this branch exposes a dial-out channel, so the affordance would have silently discarded the input; it was removed rather than left as dead UI. Restoring it means passing a provider-supplied `onDialOut` down to the modal. - -`POST /v1/video-conference.add-participants` takes `{ callId, users }` — no `keepHistory`, no room choice — and calls `addMembers`: - -- Each user who isn't already associated with the call gets a `users[]` entry with `joined: false`. Users who already have an entry are skipped, so an existing member's `joinedAt` (or `declined`) is never overwritten. -- Everyone actually added is **rung** (`notifyUser(…, 'ring', …)`). The endpoint caps a single add at `VIDEO_CONF_RINGING_LIMIT` (10) — the same constant the server rings by, which is what guarantees an add always rings, unlike starting a call in a large room where the subscriber count can exceed the cap and nobody is rung. -- They also get a desktop notification, because the ring only reaches a client that is on screen and is one-shot. It deliberately carries **no room name**, which is what stops its click from navigating: the room behind the call may be one they can't open. Clicking focuses the app, where the ring is; the "Join call" action joins the conference itself. - -Nothing about the conference's rooms changes, so `discussionRid` is untouched and no discussion is created. - -### What the member sees - -| | | -|---|---| -| Rung, app on screen | The incoming-call popup, describing the call. It renders without the room — see [the popup note](#the-incoming-call-popup-assumed-the-callee-was-in-the-room). | -| Accepts | Joins the conference outright; no handshake with whoever added them — see [Accepting a server ring](#accepting-a-server-ring-joins-it-doesnt-negotiate). | -| Declines | Recorded on their `users[]` entry. It never ends the call for anyone else, and they can still join afterwards. | -| Misses the ring | The conference is joinable from the sidebar's ongoing calls list. The ring itself doesn't repeat. | -| Opens the chat panel without room access | An explanation, not an error — see [A member who can't read the chat](#a-member-who-cant-read-the-chat-is-told-so-not-shown-an-error). | - -## Busy While In A Call - -Being in a call is being busy, and saying so is what stops someone ringing a person mid-conversation. Joining sets a -presence **claim** — `Presence.setActiveState` with `statusDefault: busy`, the *On a call* status text, and -`statusId: 'video-conference'` — and every way out of a call ends it by that id. - -A claim rather than a status, because the point is getting the old one back. `internal` is the strongest source the -presence engine has, so busy is what shows for as long as the call lasts; whatever it displaced is stashed in -`previousState` and handed back when the claim ends. Someone who set themselves away before the call is away again -after it. Someone who sets a status *during* the call has it queued the same way rather than displayed — the call is -not overruled while it is happening, and their latest intent is what they are left with once it ends. Ending by id -is what lets a voice call's claim and this one end in either order: two `internal` claims stash for each other. - -All three departures release it, which is the same list as everywhere else in this feature: - -| Departure | Where | -|---|---| -| reported | `leaveCall` | -| inferred, when renewals stop | the [presence-lease sweep](#knowing-who-is-still-in-the-call) | -| the call itself ending | `endCall`, for everyone still in it — no leave is coming for them | - -Nothing here is allowed to break a call. Both calls are wrapped: a presence service that is down, slow, or -unlicensed logs a warning and the join carries on. Presence is a courtesy; joining is not. - - -## Leaving a Call - -A conference has no natural end when the provider doesn't report one, so closing the call window is the signal. -`useLeaveConferenceOnClose` posts `POST /v1/video-conference.leave` on `pagehide`, and `leaveCall` decides what -it means: - -- The member's entry gets a `leftAt`. Leaving is neither declining nor un-joining — membership and `joined` both - stand, so they keep their history entry and can rejoin, which clears `leftAt`. -- If nobody is left in the call, the conference **ends** after `EMPTY_CALL_GRACE_MS` (10s) with nobody having come - back. The grace period is what makes a **reload** survivable: the - page unloading reports a leave, and for a moment the call is empty because its only participant is on their way - back into it. - -"Left in the call" is `isInVideoConference` — joined, and not left since. `joined` never goes back to false -(it records that they were there), so presence has to be that pair. A member who was added and never joined -doesn't hold a call open, so an unanswered ring can't keep one alive forever. - -Ending is a consequence of the call being empty, never of one participant asking for it — the same rule -declining follows. What covers the cases nobody can report is **presence leases**, below. - -`pagehide` rather than `beforeunload`: it fires for the bfcache case too and doesn't suppress the cache. The -request needs `keepalive`, because the document is being torn down and an ordinary `fetch` dies with it; -`sendBeacon` would be the usual tool but can't carry the auth headers the REST API needs. - -### Knowing who is still in the call - -A reported departure is the accurate path and it usually works, but it can only be sent by a live client to a live -server — and the call does not depend on either. The provider is a separate service, so **the workspace can be -down while the call carries on**: people leave during the outage, nothing reaches us, and when we come back the -call still lists them as present. The same hole swallows a crashed tab, a killed browser, a dead battery and a -`keepalive` fetch that didn't make it out. - -So presence is a **lease** rather than a report. The conference window renews it every -`PRESENCE_HEARTBEAT_MS` (30s) with `POST /v1/video-conference.heartbeat`, which stamps `lastSeenAt` on the -member's entry. A cron sweeps every minute: anyone whose lease is older than `PRESENCE_LEASE_MS` (3min) is marked -as having left, and a call that empties as a result **ends**, which is what settles everyone's history. Nothing has -to arrive at the moment someone goes; what matters is that nothing arrives afterwards. - -Three details carry most of the weight: - -- **The departure is dated from the last evidence, never from the sweep.** Stamping "now" on a call recovered - twenty minutes after an outage would misreport the call duration. `leftAt` is `lastSeenAt` — - which, during an outage, lands at about the moment the lights went out. `leftReason: 'timeout'` records that it - was inferred, so nothing has to pretend the precision of a reported leave. -- **A restart waits out a full lease before evicting anyone** (`isPresenceSweepDue`). From the database, - "everyone left" and "we weren't here to be told" are the same picture — every lease is expired either way — so - the only honest move is to give whoever is still there a chance to renew. Their window heartbeats every 30s, so - three minutes is generous. In a multi-instance workspace this costs nothing: the instances that stayed up were - never absent and keep sweeping throughout. -- **A renewal undoes an inferred departure, and only an inferred one.** A lease given up on while the window was - in fact alive was simply wrong, and the window still talking to us is the correction. A member who *reported* - leaving is never revived this way — the guard is in `renewUserPresenceById`'s query, so a heartbeat still in - flight behind someone who left matches nothing. - -This is deliberately **provider-agnostic**: the renewing window is ours whether the call renders inside it or is -handed to an iframe, so it needs no cooperation from Pexip, Jitsi or anyone else. Where a provider *can* be asked -who is in a room it may register a **presence probe** (`videoConfPresence`), whose answer renews the same leases -from the server side — which matters because browsers throttle a background window's timers to roughly one a -minute, and a call is usually something you listen to while looking at something else. LiveKit registers one; a -provider reached by URL registers nothing and loses nothing but that. A probe returning `undefined` means "no -answer", which is what an unreachable provider says, and it is never read as "nobody is there" — our own network -trouble must not empty someone else's call. - -**Known limitation.** For a provider with no probe, presence means *"still has the conference window open on this -call"*. Hang up inside the iframe and leave the tab open and you stay listed until the window closes. Closing that -gap needs the provider to report it (the `postMessage` bridge described in [Deferred to -follow-ups](#deferred-to-follow-ups)) or a management API to ask — both per-provider, which is why the lease is the -floor rather than the ceiling. - -### The window that opened the call watches it - -A page can only report its own departure once it is running, and the user counts as being in the call before -that: `video-conference.join` is posted by the **main app**, before the call window is even opened. Accept a call -and close the window while it is still loading and nothing ever reported the leave — the user sat listed as -present in a call they never saw, holding it open. - -So the opener watches the window it opened. `useLeaveCallOnWindowClose` polls `closed` once a second and posts -the leave when the window goes, which covers the whole gap: closed while loading, and closed without `pagehide` -firing at all. One call is watched at a time, since a user is in one call at a time and the window is shared — -opening the next call replaces the watch. Leaving twice is harmless, so it makes no attempt to work out whether -the page got there first, and the watch is dropped rather than fired when the main app itself goes away: that is -not the call window closing, and the call window is meant to outlive it. - -Two gaps this leaves, both ending in the same place — the next join, which reconciles presence server-side (see -[One call at a time](#one-call-at-a-time)) — or the expiry cron: a popup the browser blocked outright (the user -is joined with no window at all), and the main app being closed alongside the call window. - -## Being called makes you a member - -Starting a direct call registers the callee with `joined: false`, exactly as being added to a group conference -does. That is what lets anything tell "still ringing" from "nobody was called", and what gives a missed 1:1 call a -`not-answered` row in the callee's history rather than no trace at all. - -## Chat Access - -`video-conference.info` carries a `chatAccess` descriptor: the room the chat lives in (`discussionRid || rid`), its display name and type, which members can't read it, and whether that room can take new members (`canInvite`). - -Access isn't always a subscription question — a plain public channel is readable by anyone — so a plain public channel and a plain private room (group or DM) are each answered from one `Subscriptions` query for all the member ids at once: a public channel is free for everyone except anyone explicitly banned from it, a private room needs an actual (non-invited) subscription. A room that belongs to a team, is a discussion, or carries ABAC attributes can grant access through paths a subscription read doesn't see (team membership, the parent room's own rules, an ABAC decision), so those still ask `canAccessRoomIdAsync` once per member, exactly as before. - -`ChatAccessNotice` surfaces the situation to participants who *can* read the chat, and hides itself from the members it is about — they can't resolve it for themselves. It counts only members who have **joined**: someone merely invited may never turn up, and a banner about a person who isn't there asks everyone else to fix a situation that hasn't happened. - -It sits above the call and both panels, not inside either. The situation is about the call rather than about whichever panel happens to be open, and a banner that moved as panels changed would read as a different message each time. - -`POST /v1/video-conference.share-chat` applies the remedy, taking a `mode`: - -| `mode` | Effect | `discussionRid` | -|---|---|---| -| `'invite'` | The missing members are added to the chat's room, exposing its whole history | unchanged | -| `'discussion'` | The chat moves to a fresh discussion carrying the union of the room's members and the conference's | the new discussion | -| omitted | The room's own rules decide: `invite` when it can take members, otherwise `discussion` | as above | - -`invite` is refused for a room that can't take new members, re-derived server-side rather than trusted from the client. The room is asked with `allowMemberAction(room, RoomMemberActions.INVITE, uid)` rather than tested for `t === 'd'`: the room type owns that rule, and it covers cases the type check misses, such as a federated DM that *can* grow. - -Which action leads in the modal is a privacy judgement — see [Resolving chat access is the user's call](#resolving-chat-access-is-the-users-call). - -Discussion type comes from `roomCoordinator.getRoomDirectives(parent.t).getDiscussionType(parent)`: `'c'` for a public channel (`'p'` if it belongs to a private team), `'p'` for everything else including DMs. Nesting is always flattened — `getRoomForDiscussion` walks `prid` up to the top-level room, so discussions never nest inside discussions. - -Whichever way it goes, the chat is built from `discussionRid || rid` — the room the chat is *currently* in. -Building from the room the call started in instead is how a second discussion used to drop everyone added since -the first one. - -## Realtime Updates - -Several things can change a conference under a participant: its chat moves to another room, the same room becomes -readable by members who couldn't read it, or its membership shifts — someone joins, declines, leaves or is added. - -All of them are answered the same way: read the conference again, which carries the room, who can see it, and who -is in it. So there is **one** signal, `video-conference.updated` on the `/updated` stream key, and one -subscription that invalidates one query. It started as three events with a payload on one of them; the subscriber -registered the same callback for all three and never read the payload. - -`assignDiscussionToConference` also broadcasts `notify-room/…/videoconf`, so the in-room conference message block -refreshes its "Join discussion" button. The participant who *asked* for a change invalidates locally rather than -waiting on the round trip. - -The stream's `allowRead` accepts **conference membership or access to the chat's room**, the same pair `video-conference.info` accepts. Both halves matter: members may have no access to the room the call originated in, and membership alone would refuse a room member who opens the conference before their join lands — a refused subscription is never retried. - -## Access Control - -Every conference endpoint authorizes through one `canAccessConference` check, which accepts, in order: - -1. **Conference membership** — a `users[]` entry. This is the point of the membership model: it authorizes joining the call without granting any room access. -2. Access to `call.rid`, the room the call was started in. -3. Access to `call.discussionRid`, the room the chat moved to. Someone who belongs only to the discussion has no access to the parent room, so checking only `rid` would lock them out of the call. - -Because all of them share that check, `add-participants` no longer disagrees with `join` and `info` about who is allowed in. `loadAccessibleConference` is the shared prologue: it reads the call, applies the check, and answers both failures the same way — `invalid-params`, deliberately vague about which of the two it was, so a stranger can't use an endpoint to learn that a call id is real. - -The check lives in `server/lib/videoConfAccess.ts` rather than beside these endpoints, because a provider's own endpoints need it too and two versions of "may this person be here" drift into two answers for the same person. That is not hypothetical: the LiveKit transport endpoint originally checked room access instead, so a member added to a call in a DM was refused the credentials for the very call they had just joined — a window showing them alone, with inert controls, because a refused token looks exactly like one that hasn't arrived yet. - -## Reaching a call without a ring - -Ringing is a poor only-route into a call: it is one-shot, it lasts seconds, and a conference started in a room with -more than ten subscribers rings **nobody at all**. So a call is also reachable from a list of the calls running -now — docked at the top of the sidebar, and behind a navbar button when there is no sidebar to dock it in. - -### What the list shows - -Every row is something to act on: **join** it with the ✓, or turn it down with the ✕ so it stops asking. The call -the reader is *already in* is left out entirely — they are in it, there is nothing to reach, and a row reading "in -call" left them with something they could do nothing about. Rows are newest first, and all of them: being a group of -the sidebar's list means the list's own scrolling covers it, so there is no cap and no *show all* toggle to reach -past. - -The calls are **a group of the sidebar's own list**, not a card above it: *Ongoing calls*, always first, collapsing -and scrolling exactly as Discussions or Channels do (`useRoomList` prepends it; `RoomList` renders a call row where -a room row would go). Prepended rather than placed by `sidebarSectionsOrder`, because that order is a user -preference saved before this group existed and a stored copy of it has no place for calls. - -A row **is** the room item — `sidebar/Item/Extended`, the same component every channel renders — with a call's -things in its slots: a camera icon in front of the name, the name in the item's own title tokens, when the call -started in the timestamp corner, and the faces on the second line where a room puts its last message. The actions -sit at the end of that second line. The one slot it never fills is the avatar: a call has no single face to show, -its faces are on the second line, and the avatar column would indent every call by an avatar's width to say -nothing. - -A **ringing** call is the same row again, said by its buttons rather than by a colour behind it: a green phone in -place of the window, and a third action, since a ring can be silenced without being answered. - -**Clicking the row opens the call window on its preflight** — the same thing the row's own button does, and the same -bargain the rooms under it offer, where clicking a row opens what it describes. It is deliberately not a join: the -preflight describes the call and chooses the devices, so a mis-click costs a window rather than putting someone into -a call with their camera on. A press on one of the row's *buttons* is not a press on the row; the buttons sit inside -it, so their clicks arrive there too, and without asking the event where it came from, declining a call also opened -it. The row also has to `preventDefault`: the item renders as an anchor with nowhere to go, and an unhandled click -reloaded the page out from under the call list. - -**A call the reader has joined stays listed**, as one simply running. It used to drop out of the list on being -joined, on the grounds that there was nothing left to offer — but leaving a call is easy to do by accident, and a -call that vanished the moment it was joined left no way back into it. Joining also stops the row asking anything: -it is listed as running even while the record of the ring is still on the call, and it offers no decline, because -the way out of a call you are in is to leave it (`canDeclineCall`). - -Each row says who is in the call as **faces, then how many more** — `[][][] + 3 joined` — which is exactly how the -call's own message block puts it in the room, down to the phrases (`plus__usersCount__joined`, or `joined` when -they are all shown). A call met in the sidebar and met again in its room should read the same both times. Faces -answer the question the reader actually has, which is whether this is a call worth walking into; a number never -did. - -`CallParticipants` draws one avatar per person the payload carries, capped at `CALL_FACES_SHOWN` (3), since a row -has a name to fit beside them. They overlap slightly, each stacked above the one before it, with a `drop-shadow` on -each so a row of faces reads as several people rather than one smudge — `drop-shadow` rather than `box-shadow` -because it follows the avatar's own rounded shape. The full count stays as the group's label, for anyone who cannot -see the avatars and because "+3" means nothing without a total. With the `displayAvatars` preference off there is -nobody to show, so it says the count in words instead, again as the message block does. - -The same component appears on the [preflight](#the-preflight-screen) when joining, under a *Participants in the -call* label and five faces at a time, since a screen has more room than a sidebar row. Those come from the call -window's own copy of the members, so nothing extra travels for them. - -Each row is named by `conferenceNameFor` (`lib/videoConference/conferenceName.ts`), shared with the call window so -the two can't disagree: a group conference's own title; otherwise the reader's own subscription, since a DM is -named per side; and for a **direct** call with no subscription to read, whoever started it. That last case is the -member added from outside a DM, and it is not a nicety — a DM room carries neither `name` nor `fname`, so falling -back to the room reached `getRoomName`'s last resort and showed them the raw room id. - -### A ringing call is listed, not popped - -An incoming call used to take over the screen with a popup that had to be answered before anything else could -happen. It is now the first row of the *Ongoing calls* group — the same row as any other call, in primary blue, with -**accept**, **decline** and **silence** where the running calls carry join and dismiss. The ring still sounds. When -it stops, the row settles into an ordinary one: the call is still there, it just isn't asking any more. - -**Silencing** is not answering. The bell button stops this client's ring and leaves the call exactly where it is, -so the user can decide in their own time. It only appears while there is a sound to stop — a ring this client never -heard, because the page was reloaded, has nothing to silence — and once used becomes a plain bell-off icon, which -is what says why the room went quiet. Silenced ids are remembered by `useOngoingCalls`, because the manager forgets -a dismissed call entirely and "silenced" would otherwise be indistinguishable from "never heard". - -Whether a ring is still ringing is the reader's own judgement (`isRingingVideoConferenceMember` over the `ringingAt` -the joinable list carries), with `useRingingExpiry` waking the list when the earliest one is due to stop — nothing -announces that a ring *ended*, so nothing can be waited for. The list also refreshes on the ring itself rather than -on the poll: a ring *is* announced to the person being rung, and waiting up to twenty seconds to show a call that is -ringing right now would miss it entirely. - -### Where the list lives - -`components/OngoingCalls` holds the two rows and the data behind them. `useOngoingCallItems` says what the list -*is* — ringing first, then the running ones, then the declined behind a toggle — and both places that show calls -walk the same items so they cannot drift into different orders: - -- the sidebar's `RoomList` renders them as the first group of its own list, one row at a time, because that list is - virtualised and this is a group of it; -- `NavBarItemOngoingCalls` renders `OngoingCallsList` in a dropdown, which wants the whole thing at once. - -A collapsed sidebar hides the group, so the navbar button stands in for it whenever `sidebar.isCollapsed`: red while -something is ringing, and it opens itself when a ring starts, because a ringing call the user has to go looking for -is a missed call. It counts what is being offered — the declined ones stay behind their toggle rather than being -counted at someone who already turned them down. - - - -### What the server answers with - -`GET /v1/video-conference.joinable`, via `listJoinableCalls`. Nothing new is stored to support it: the conference -records already hold membership (`users[]`), liveness (`endedAt`) and the room. The scan is over *running* -conferences rather than over the user's rooms, so its cost follows how many calls are in progress — few — rather -than how many rooms the user is in. A sparse index on `{endedAt, createdAt}` keeps it to the calls that are live, -since a conference carries `endedAt` only once it has stopped. - -A call is offered when the user is a **member** of it, or is **in the room** it belongs to. Room membership rather -than room *access*: a public channel is readable by anyone, and a call in a channel the user never joined has no -business in their sidebar. That is narrower than `canAccessConference` on purpose — the endpoints still authorize -with the broader rule, so nobody is refused a call they can reach. - -Calls nobody is in are left out. A conference only stops when someone ends it or the expiry cron reaches it, so -without that filter an abandoned call would be advertised as joinable for a day. - -The name comes from the conference's title, or — for a direct message, which has no name of its own — from the -reader's **own subscription**, since a DM is named after the other person and that name is per-viewer. Both fall -back to the room. One subscription query answers this and the room-membership question together. The payload -carries nothing else: a list needs enough to decide whether to walk in, and joining goes by `callId`. - -### One call at a time - -Joining a call while in another leaves the first, and says so before it does. `useJoinCall` is the shared entry -point for both lists: it asks for confirmation, naming the call being left, then **posts the leave explicitly** -before joining. - -That explicit leave matters and is easy to miss. The call window is shared, so joining a second call already -replaces the first one's page — but replacing a page is not leaving a call. Without the leave, the abandoned -call keeps counting its participant, which keeps it listed as occupied and stops it ever emptying out. With it, -the call empties, ends after the grace period, and drops out of both lists on its own. - -The server enforces the same rule rather than trusting the client to have asked: `addUserToCall` first runs -`leaveOtherCalls`, leaving every *other* running call this user is still counted as being in. A window that dies -without reporting its departure — a crash, a killed tab, a client that never sent the leave — otherwise leaves its -user counted as present forever, which both misreports them and keeps a finished call listed as occupied. Joining -anything is the moment that can be put right, and it costs one indexed read that usually finds nothing. - -### Liveness is polled, and why - -A call appearing does **not** reach these lists over a stream. Announcing a call to everyone who could join it -means a broadcast to every subscriber of its room, which is the same fan-out that makes ringing a large room -impossible in the first place — the problem this feature exists to work around. So `useJoinableCalls` polls, -every 20 seconds, and anything the user does themselves invalidates the query at once. - -That is a deliberate trade. This list is not latency-critical: it exists precisely for the calls whose ring never -arrived, where the alternative today is no route at all. A per-user signal remains the better answer if it can be -made cheap — see [Improvement suggestions](#improvement-suggestions). - -## Provider Requirements - -A provider must declare the **`persistentChat` capability** for `maybeCreateDiscussion` to create a discussion for its conferences. - -Providers that don't declare it still work in the split view — the chat panel falls back to the conference's `rid`, showing the room the call was started in — but get no dedicated per-call discussion. The bundled **Jitsi app (v2.1.1) declares only `{ mic, cam, title }`**, so it falls into this case; adding `persistentChat` is an app-side change. - -The provider's URL is embedded in an iframe, so it must permit framing (no restrictive `X-Frame-Options` / `frame-ancestors`). Rocket.Chat's own CSP allows `frame-src *`. Note the public `meet.jit.si` server disconnects embedded calls after 5 minutes and asks you to use a self-hosted instance or JaaS. - -## Settings - -| Setting | Notes | -|---------|-------| -| `VideoConf_Enable_Persistent_Chat` | EE. Gates whether joining opens the in-product conference page. | -| `VideoConf_Persistent_Chat_Mode` | `thread` (default) or `main_room`. Thread opens a thread from the call message; main room shows the channel itself in the chat panel. | -| `VideoConf_Persistent_Chat_Discussion_Name` | Discussion name (only in discussion mode); `[date]` is substituted, or the date is prefixed when absent. Requires `Discussion_enabled`. | - -## REST Endpoints - -| Method | Endpoint | Description | -|--------|----------|-------------| -| POST | `/v1/video-conference.add-participants` | Register users as conference members and ring them; touches no room. Capped at 10 per call | -| POST | `/v1/video-conference.decline` | Record that the caller dismissed the call, without ending it | -| POST | `/v1/video-conference.leave` | Record that the caller left; ends the conference when nobody is left in it | -| POST | `/v1/video-conference.heartbeat` | Renew the caller's presence lease on a call, so they aren't treated as gone | -| POST | `/v1/video-conference.ring` | Ring the members who aren't in the call again | -| GET | `/v1/video-conference.joinable` | The running calls the caller may join — the sidebar and history lists | -| POST | `/v1/video-conference.join` | Join a conference — accepts `discussionRid` members | -| POST | `/v1/video-conference.rename` | Name a running group conference; the creator only | -| POST | `/v1/video-conference.share-chat` | Give the members who can't read the chat access to it (`mode: 'invite' \| 'discussion'`) | -| GET | `/v1/video-conference.info` | Conference info — accepts `discussionRid` members; carries `chatAccess` | -| GET | `/v1/video-conference.list` | Paginated history, with discussion title / last message | - -## Streams - -| Stream | Event | Payload | Authorized for | -|--------|-------|---------|----------------| -| `video-conference` | `/updated` | — | conference members, or anyone who can read the chat's room | - -## Why membership exists - -The prose above describes the shipped behaviour. This section is the record of *why* it is shaped that way, -plus the follow-up work deliberately left out. The phase-by-phase plan it was built from lives in the git -history and is not repeated here. - -Before it, adding someone to a conference *put them in a room* — the conference's own, or a fresh discussion — -and authorization to join was derived from room membership. That conflated two separate things: being in the call, -and being able to read the chat. What replaced it is described under [Adding Participants](#adding-participants) -and [Access Control](#access-control). - -### Decisions on record - -| # | Decision | -|---|---| -| 1 | Conference membership lives on the existing `users[]`, with a per-entry `joined` flag — not a second array. Keeps one list of "who is associated with this call" and leaves room for future participant kinds. | -| 2 | `ts` keeps its current meaning (added to the conference). A separate `joinedAt` records when they actually joined. | -| 3 | Ringing is decided **per call event** against the list being rung, capped at 10. At start the list is the room's subscribers (so a >10-person room still rings nobody). On add, the list is the added users, capped at 10 per action — so an add always rings. | -| 4 | A decline is recorded as a flag on the member's `users[]` entry. It must never end the call for anyone else. | -| 5 | Membership never expires, and is additive-only. Leaving, declining and rejoining all annotate the entry rather than removing it, which is what makes the call log and the members list possible after the fact. | -| 6 | `assignDiscussionToConference` subscribes the **union of the original room's members and the conference's members**, so a newly created discussion contains everyone involved rather than only those who joined the call. | -| 7 | "External" (a member with no access to the chat) is **derived**, not stored, so it stays true as access changes. It is surfaced per member in the members panel and in aggregate by the chat-access notice. | -| 8 | An incoming call is an item in the list of calls, not a popup over the screen. Answering it, turning it down and leaving it ringing are all things the user can do without the rest of the app being blocked — see [A ringing call is listed, not popped](#a-ringing-call-is-listed-not-popped). | - -### Future work (not in scope) - -- **Non-user participants.** Members are registered Rocket.Chat users only, for now. Representing SIP - extensions, phone numbers, external email addresses, or participants derived from a calendar event is - wanted later. `IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'>` — required - `username` *and* `name` — so that constraint has to relax when it happens. Adding a nullable `source` - discriminator to the entry while Phase 1 is being written costs nothing and avoids a migration later. - -## Implementation notes - -Things worth knowing that aren't visible from the code alone. - -### Group ringing was dead code before this work - -The server had long broadcast an `action: 'ring'` to each room member of a group conference, and **no client ever -handled it** — 1:1 ringing is driven entirely client-side, by the caller's own `VideoConfManager` republishing -`'call'` while it waits. `VideoConfManager` now handles `'ring'`, which is what makes ringing-on-add work; the side -effect is that group conferences which had been silently not ringing **will now ring**, which the EE code always -intended but is a visible change beyond "ring on add". - -A server-originated ring is one-shot: nothing refreshes the 10s abort timeout a 1:1 caller keeps alive, so it rings -once and gives up. That suits an already-running conference, where there is no caller waiting. - -### Declining makes you a member - -There is nowhere to record a decline except on a `users[]` entry, so declining creates one for someone who -was rung as a room member. Since membership authorizes joining, a member who declines can still join -afterwards — which is intended, but is a consequence of where the flag is stored rather than a separate -decision. - -### Resolving chat access is the user's call - -Both ways out give something away, in different directions — see the mode table under -[Chat Access](#chat-access) — so the choice is the user's, and `ChatAccessModal` spells out each consequence next -to its button, naming the room in bold. That name is the context the decision turns on. - -Which one *leads* is a privacy judgement: opening a private room's history is the bigger step, so private rooms -and DMs lead with the discussion, and public rooms — whose history is already open — lead with the invite. -`chatAccessLeadsWithDiscussion` is shared with the server's own default so the two can't disagree. - -The notice itself is `AnnouncementBanner` — the same banner rooms use for announcements — so it inherits -readable contrast instead of hand-rolled colours. It is passed no `onClick`: the Review button is the only -control, which keeps one interactive element rather than nesting a button inside a `role='button'` bar. - -### A member who can't read the chat is told so, not shown an error - -The server already works out who can't read the chat, so `ConferenceChat` asks `hasConferenceChatAccess` about the -current user and renders the not-shared state rather than attempting a fetch that is known to fail — which -would land on *"The page does not exist or you may not have access permission"* and read as something being broken. - -`ChatAccessNotice` hides itself from those same members for the same reason: it offers to share the chat, and -they are the ones it would be shared with — `share-chat` would fail for them anyway, since they can't add -anyone to a room they can't see. - -### The incoming-call popup assumed the callee was in the room - -Ringing on add is the first case where a call rings someone with no access to the room it belongs to, and the popup -was built entirely around that room: it read it with `useUserRoom(rid)` and returned `null` when it wasn't there, -while still calling `focusManager.focusFirst()` — which then crashed looking for the parent of a node the focus -scope never got. Incoming popups therefore render **without** a room, describing the call from the conference's own -record (`VideoConfPopupCallerInfo`). The popups that act *on* a room — starting or placing a call — still need one. - -### Accepting a server ring joins; it doesn't negotiate - -1:1 accept is a handshake: the callee publishes `accepted` and waits for the caller's client to reply -`confirmed` with the go-ahead, giving up after 5s. A server-originated ring has no caller waiting, so running -that handshake left the added user staring at *"No response from remote user after notifying the call was -accepted"*. Incoming calls now carry a `handshake` flag; without it, accepting joins the conference outright — -membership is what authorizes joining — and declining records the decline without publishing `rejected` to -whoever added them, which their client would read as their own call being turned down. - -### Test coverage and where it lives - -The cheap runners were used deliberately: mocha under `apps/meteor/tests/unit/**` (~2s for the whole config) and -package-level jest. The specs sit beside what they test, so the file names say where to look; enumerating them -here only produced a list that went stale on its own. - -Two things about the arrangement are worth knowing. `apps/meteor/tests/unit/server/services/video-conference/testHarness.ts` -is what makes the service testable at all: `createService` proxyquires it with ~25 inert module stubs (one of -which would otherwise open a Mongo driver at import time) and a models map each spec narrows to the collections -it exercises. And the decisions worth pinning down were deliberately moved *out* of the service into pure -functions — `resolveChatAccessMode`, `chatAccessLeadsWithDiscussion` -(`apps/meteor/lib/videoConference/chatAccess.ts`), the member predicates in -`memberStatus.ts` — each shared by the server and the client that has to agree with it, so a rule is tested once -and the two can't drift. - -`packages/models/src/models/VideoConference.spec.ts` stubs `BaseRaw`, which participates in a circular import -that leaves it uninitialized when the module is loaded directly by jest. - -An end-to-end REST suite covering these endpoints against a real server and real Mongo — membership without room -access, authorization by membership, decline, leave, ring, `chatAccess`, and both `share-chat` modes — is written -and held back for a PR of its own; see [Deferred to follow-ups](#deferred-to-follow-ups). It follows the -provider-app harness in `apps/meteor/tests/end-to-end/apps/video-conferences.ts` and is EE-gated, since a private -app is never enabled outside EE. - -### Nothing else "ends" a Jitsi conference - -`endCall` runs when something tells Rocket.Chat the call is over. For a third-party provider, nothing does: -the Jitsi app never reports an end, so before closing the window became a signal, conferences sat at `STARTED` -until `videoConferencesCron` expired them a day later — and the expire path wrote no history at all. Both gaps -are closed: leaving ends the call when nobody is left, and expiry writes history as a backstop. - -Conferences expired *before* this landed have `endedAt` set already, so they are permanently invisible to -history — the duplicate guard can't distinguish them from ones already written. Only conferences that stop from -now on appear. - -### Verified against live data - -The premise — membership without room access — was confirmed by reading a development workspace's Mongo directly, -not only by test: - -- A conference on a **DM** between two users carried a third, `alice`, as a `users[]` entry with `joined: false` - and **no subscription to that DM**. She is authorized to join the call and cannot read its chat, which is - exactly the state the model exists to represent. -- Entries mixed both shapes as designed: joined members carry `joined: true` and a `joinedAt`; added members carry - `joined: false` and no `joinedAt`. Every entry carries `ts`. Declining from the sidebar wrote `declined` and - `declinedAt` on the decliner's entry alone, leaving the conference's own status untouched. - -Two flows were also walked end to end against that workspace. Placing a DM call opened the call window at -`/conference/:id` immediately, with the callee a member at `joined: false` while still ringing and the room showing -no outgoing popup; after the ring window the call window reported "Nobody answered", naming the callee, and ringing -again restarted the wait. Closing the window then ended the call and settled both history rows — `ended`/`outbound` -for the caller, `not-answered`/`inbound` for the callee — while leaving a call someone else was still in only set -`leftAt`. - -## Deferred to follow-ups - -Eight things were built, reviewed and then held back from the first release to keep it reviewable. Each is a -complete improvement on its own, which is what makes it a good follow-up rather than a gap. All of them are in -git — `git show 5ab58858d7d:` restores any of them intact. - -| Deferred | Why it can wait | What ships instead | -|---|---|---| -| **Telling the caller nobody picked up** (`CallOutcomeModal`, `useCallOutcome`) | the caller is in the call either way; this only names what already happened | the members panel shows each member still ringing, waiting, or declined | -| **The provider → parent bridge** (`useProviderCallBridge`) | **no provider implements it** — not the bundled Jitsi app, which declares only `{ mic, cam, title }` | our own bar owns the panels; a provider showing its own toolbar shows two | -| **Handing internal links to the opener** (the desktop bridge and the `postMessage` handshake) | needs a bridge on both sides for a nicer landing | a `noopener` new tab — see [Confined Navigation](#confined-navigation) | -| **Regrouping the room's call list** into Ongoing/Past, named after the discussion | a redesign of a list that already works, and one every workspace sees | the existing flat list, with the fix that it no longer counts members who never joined | -| ~~**Disabling join on message blocks inside the call window**~~ | done — `videoConfJoinDisabled` on `UiKitContext`, set when `useCurrentRoutePath` starts with `/conference/` | join and call-back buttons are disabled inside the call window | - -The end-to-end REST suite (`tests/end-to-end/apps/video-conference-membership.ts`) is held back for a different -reason: it has never been run locally — the API suite authenticates as a fixture admin a dev workspace does not -have — so its first CI run is its real first run, and that belongs in a PR of its own rather than reddening a -feature PR. Until it lands, the endpoints are covered by unit tests only. - -### The design worth keeping for the provider bridge - -If a provider ever asks for it: an embedded provider posts to the parent window to hide our bar and drive our chat -panel, rather than showing two competing sets of controls. - -```js -parent.postMessage({ type: 'rocketchat:conference', command: 'set-call-bar-visible', visible: false }, '*'); -parent.postMessage({ type: 'rocketchat:conference', command: 'set-chat-visible', visible: true }, '*'); -parent.postMessage({ type: 'rocketchat:conference', command: 'toggle-chat' }, '*'); -``` - -The trust model is the part worth preserving: the iframe is cross-origin, so `event.origin` cannot be allow-listed -against our own. Every message must instead have come from `iframeRef.current.contentWindow` — the exact window we -embedded, which no other frame or tab can forge. - -## Improvement suggestions - -What is still thinner than it looks. Everything previously listed here that has since been built — the members -panel, ringing a member again, reporting what an add actually did, the reload grace period, cheaper chat-access -reads — is described in the sections above instead. - -### The ring can still be missed entirely - -A server ring fires once and gives up after 10s, and the caller's own repeats stop at 30s. Being added is also a -desktop notification, which covers a backgrounded tab, but someone with notifications denied and no client on -screen still has no signal in the moment. - -The docked list closes most of this — the call stays reachable long after its ring stopped, which is the point of -it. What remains is the case where nothing was on screen *at all* while it rang: the call is then found in the -list or the history afterwards, rather than being announced. Repeating the server ring for a bounded window is the -cheap answer if that is not enough. - -### `share-chat`'s invite path is all-or-nothing - -`addUsersToConferenceRoom` hands every missing member to `addUsersToRoomMethod` in one call. If it throws for -one of them, the caller gets an error toast; the others may or may not have gone through. It is not silent — the -notice re-reads and shows whoever is still missing — but the toast says less than it could. Per-user results -would make a partial outcome legible at the moment it happens. - -### Joinable calls are polled rather than pushed - -The sidebar's list refreshes on a 20-second timer, because announcing a new call to every subscriber of its room -is the fan-out this feature exists to avoid. A cheaper push would be better: a signal per *room* that the client -already subscribes to would reach exactly the people who need it, without the server enumerating them. - -### The members panel has no search - -Fine at conference scale, and the list is split into sections that keep it readable. A room's own members list has -a search box and a role filter; if conferences ever carry dozens of members, that is the shape to copy. - -### The autocomplete's exclusion list is capped - -`AddParticipantsModal` excludes the room's existing members from its suggestions, reading at most 100 of them. In -a bigger room existing members can therefore be offered; selecting one is harmless — the server skips them and -the modal now says so — but it is a suggestion that shouldn't have been there. - -### Access lost mid-call falls back to "not found" - -The chat panel decides between the room and the not-shared explanation from `chatAccess`, which is only as fresh -as the last read. A member removed from the room *during* a call still has the room attempted, and gets -`ConferenceRoom`'s not-found fallback until the next read. Rare, and self-correcting. - -## Key Files - -| Layer | File | -|-------|------| -| Conference service | `apps/meteor/server/services/video-conference/service.ts` | -| Busy while in a call | `claimBusyForCall` / `releaseBusyForCall` in the conference service, over `Presence` claims (`ee/packages/presence`) | -| API routes | `apps/meteor/server/api/v1/videoConference.ts` | -| Stream wiring | `apps/meteor/server/modules/notifications/notifications.module.ts`, `modules/listeners/listeners.module.ts` | -| Event signature | `packages/core-services/src/events/Events.ts` | -| Stream typings | `packages/ddp-client/src/types/streams.ts` | -| Conference model | `packages/models/src/models/VideoConference.ts` | -| Route + viewport | `apps/meteor/client/views/conference/ConferenceRoute.tsx`, `ConferenceViewport.tsx` | -| Call chrome | `apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx`, `ConferenceIframe.tsx`, `components/CallBar/`, `components/CallPanel/` | -| Stage layout + active speaker | `packages/ui-voip/src/views/MediaCallRoomSection/CallStage.tsx`, `MediaCallRoomSection.tsx`, `providers/useActiveSpeakerId.ts` | -| Chat panel | `apps/meteor/client/views/conference/ConferenceChat.tsx`, `ConferenceRoom.tsx`, `ConferenceThread.tsx`, `ConferenceThreadChat.tsx`, `ConferenceThreadModal.tsx`, `ConferenceStoresReady.tsx`, `CallPanelHeader.tsx`, `ConferenceChatNotShared.tsx` | -| Nothing to show | `apps/meteor/client/views/conference/ConferenceStatePage.tsx`, `ConferencePageError.tsx`, `ConferenceUnauthorizedPage.tsx` | -| Conference data | `apps/meteor/client/views/conference/hooks/useConferenceEmbedded.tsx` | -| Confined navigation | `apps/meteor/client/views/conference/hooks/useConfinedNavigation.ts` (+ `.spec.ts`) | -| Add participants | `apps/meteor/client/views/conference/AddParticipantsModal.tsx` | -| Chat access | `apps/meteor/client/views/conference/ChatAccessNotice.tsx`, `ChatAccessModal.tsx` | -| Preflight | `apps/meteor/client/views/conference/ConferencePreflight.tsx`, `ConferenceStartPage.tsx`, `hooks/useStartConference.ts`, `hooks/useCallPreferences.ts` | -| Members panel | `apps/meteor/client/views/conference/CallMembersPanel.tsx`, `CallMemberItem.tsx`, `client/hooks/useRingingExpiry.ts` | -| Membership rules (shared) | `apps/meteor/lib/videoConference/memberStatus.ts`, `callHistory.ts`, `chatAccess.ts`, `constants.ts` | -| Reaching a call | `apps/meteor/client/components/OngoingCalls/` (`CallListItem` over the sidebar's own room item, its two rows, `OngoingCallsList` and `useOngoingCalls`), `client/sidebar/hooks/useRoomList.ts` and `RoomList/RoomList.tsx` (where the group is), `client/navbar/NavBarItemOngoingCalls.tsx` (the stand-in), `client/views/conference/hooks/useJoinableCalls.ts`, `hooks/useJoinCall.tsx` | -| Leaving | `apps/meteor/client/views/conference/hooks/useLeaveConferenceOnClose.ts` | -| Presence leases | `apps/meteor/lib/videoConference/presence.ts`, `client/views/conference/hooks/useConferencePresenceLease.ts`, `server/lib/videoConfPresence.ts`, `server/cron/videoConferences.ts` | -| Ringing popups | `apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/` | -| Join routing | `apps/meteor/client/providers/VideoConfProvider.tsx`, `client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.tsx` | -| Room opening | `apps/meteor/client/views/room/hooks/useOpenRoomById.tsx`, `client/lib/utils/mapRoomFromApi.ts` | -| Ongoing banner | `apps/meteor/client/views/room/OngoingConferenceBanner/OngoingConferenceBanner.tsx` | -| Room-scoped call history | `apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/` | -| Join guard | `apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts`, `packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx` | -| Layout | `apps/meteor/client/views/root/MainLayout/MainLayout.tsx`, `TwoFactorAuthSetupCheck.tsx`, `client/lib/appLayout.tsx` | -| Notifications | `apps/meteor/client/hooks/notification/useNotification.ts`, `packages/core-typings/src/INotification.ts` | diff --git a/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg b/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg deleted file mode 100644 index 4b00af3a09cab..0000000000000 --- a/docs/features/video-conference-persistent-chat/adding-people-and-chat-access.svg +++ /dev/null @@ -1,59 +0,0 @@ - - Adding people to a call, and whether they can read its chat - Adding someone makes them a member of the conference, not of any room. Someone already in the room can read the chat; anyone from outside cannot, and is marked no chat access. Once such a member actually joins, a notice offers a remedy: a public channel leads with adding them to the room, a private group leads with moving the chat to a discussion, and a direct message offers only the discussion. - - - - - - - - Add people from the members panel - up to 10 at once, and every one of them rings - - - - - - Already in the room - they can read the chat - - - From outside the room - marked no chat access - - - - - Notice, once they join - only shown to people who can fix it - - - - - - - Public channel - leads with the invite - - - Private group - leads with a discussion - - - Direct message - discussion only - - - - - - - - Added to the room - its whole history opens to them - - - Chat moves to a discussion - everyone in the call follows it - diff --git a/docs/features/video-conference-persistent-chat/being-called.svg b/docs/features/video-conference-persistent-chat/being-called.svg deleted file mode 100644 index b6a4195fcb15e..0000000000000 --- a/docs/features/video-conference-persistent-chat/being-called.svg +++ /dev/null @@ -1,56 +0,0 @@ - - What happens when a call rings you - A ring reaches you as the first item of your ongoing-calls list, and as a desktop notification when the app is not on screen. You can accept, decline, silence it, or ignore it — each with a different outcome. Declining is recorded against your own membership only and never ends the call for anyone else. - - - - - - - - A call rings you - top of your call list, - or a notification - - - - - - - - Accept - joins outright - - - Decline - your entry only - - - Silence - stops the sound - - - Ignore - 15 seconds pass - - - - - - - - In the call - after the preflight - - - The call goes on - you can still join later - - - Still listed - decide in your own time - - - An ordinary row - it stops asking - diff --git a/docs/features/video-conference-persistent-chat/ending-a-call.svg b/docs/features/video-conference-persistent-chat/ending-a-call.svg deleted file mode 100644 index 0b73050cdd0b2..0000000000000 --- a/docs/features/video-conference-persistent-chat/ending-a-call.svg +++ /dev/null @@ -1,47 +0,0 @@ - - How a video call ends, and what the call history records - A call is in everyone's history from the moment it starts, as ongoing. It ends when the last participant closes their window, when the only participant joins another call, or — if nothing ever reports an end — when the expiry cron reaches it after 24 hours. A reload also reports leaving, which is why an emptied call waits ten seconds before ending. When it ends, each member's row settles to ended or not-answered. - - - - - - - - A call is running - already a row in everyone's history, as ongoing - - - - - - The last person leaves - window closed, or navigated away - - - They join another call - the server leaves this one - - - Someone reloads - leaving is reported either way - - - Nothing reports an end - expiry reaches it after 24h - - - - - - - - Empty for ten seconds - a reload gets back inside the grace - - - - - Every member's row settles - ended for whoever joined, not-answered for whoever did not - diff --git a/docs/features/video-conference-persistent-chat/matrix-comparison.md b/docs/features/video-conference-persistent-chat/matrix-comparison.md deleted file mode 100644 index 3a204c4765daf..0000000000000 --- a/docs/features/video-conference-persistent-chat/matrix-comparison.md +++ /dev/null @@ -1,186 +0,0 @@ -# How this compares to MatrixRTC - -A comparison of how Rocket.Chat's conference membership work and Matrix's MatrixRTC each answer the same two -questions: **who is in this call right now**, and **who is allowed in it**. - -Matrix side sourced from the current MSCs (August 2026 — all still open, and MatrixRTC has changed shape more than -once, so treat specifics as a moving target): - -- [MSC4143: MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC/proposals/4143-matrix-rtc.md) — sessions, slots, membership -- [MSC4075: MatrixRTC notifications & call ringing](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixrtc-call-ringing/proposals/4075-rtc-notification-event.md) -- [MSC4310: MatrixRTC decline](https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC-call-decline/proposals/4310-matrixRTC-call-decline.md) -- MSC4140 (delayed events), MSC4354 (sticky events), MSC4195 (LiveKit backend) — referenced by the above - -## The one difference everything else follows from - -**Matrix has no call object.** A MatrixRTC session "only exist[s] indirectly through the temporal overlap of -`m.rtc.member` events" — a session is the span of time during which one or more members are continuously joined to -the same slot. There is no record to create, no record to end, and no server that decides either. Clients read the -room's events and compute the answer. - -**We have a call object.** `VideoConference` is a document with `_id`, `status`, `endedAt`, and a `users[]` -membership array. The server decides when a call starts, who is in it, and when it ends. - -Everything below is downstream of that. - -| | MatrixRTC | Rocket.Chat | -|---|---|---| -| Call identity | derived — a slot id plus overlapping membership | a `VideoConference` document with an `_id` | -| "Who is in it" | each client publishes its own `m.rtc.member`; readers aggregate | server-owned `users[]` with `joined` / `leftAt` | -| Presence liveness | a dead-man switch: a delayed leave event the client keeps resetting | reported departures plus server-side reconciliation | -| Call end | when the last membership lapses — nothing is written | `endCall` sets `endedAt` and `status: ENDED` | -| Access to the call | room membership, enforced at the media transport | conference membership **or** room access, enforced at the API | -| Chat access | identical to call access, always | a separate question, surfaced and resolvable | -| Ringing | `m.rtc.notification` with `lifetime`, targeted by `m.mentions` | server rings, capped at 10 recipients, one-shot | -| Decline | `m.rtc.decline` event referencing the notification | `declined` / `declinedAt` on the member's own entry | - -## Keeping "who is in the call" honest - -This is the hardest part of any call system, and the two designs solve it in opposite directions. - -**Matrix — the client is the source of truth, and it must keep proving it.** A membership counts only while its -sticky event has not expired (default 4 hours). Because a browser that crashes never sends a leave, the MSC tells -clients to schedule the *leave* as a **delayed event** (MSC4140) with a 15–30 second delay *before* joining, then -periodically reset its timer. If the client stops resetting it, the homeserver fires the leave on its behalf. The -recovery is automatic and needs no server-side knowledge of calls at all. - -**We — the server is the source of truth, and it reconciles.** Our equivalents accumulated one incident at a time: - -| Failure | Our answer | Where | -|---|---|---| -| The tab closes | `pagehide` posts `leave` with `keepalive` | `useLeaveConferenceOnClose` | -| The window closes before the page ever ran | the opener polls `window.closed` and posts the leave | `useLeaveCallOnWindowClose` | -| The window died without reporting anything | the next join anywhere runs `leaveOtherCalls` | `service.addUserToCall` | -| Reload looks exactly like leaving | an emptied call waits `EMPTY_CALL_GRACE_MS` (10s) before ending | `lib/videoConference/callHistory.ts` | -| Nothing ever reports an end | expiry cron closes it after 24h | `videoConferencesCron` | - -Four mechanisms and a cron where Matrix has one. The difference in kind: **Matrix's recovery is time-based and -runs without us; ours is event-based and only fires when something happens.** A user whose laptop sleeps mid-call -is corrected by Matrix within 30 seconds; with us they stay "in the call" until they, or someone else, next join -something — or for up to 24 hours. - -> **Worth stealing.** A server-side lease is the single most valuable idea here. If joining recorded -> `presenceExpiresAt = now + 60s` and the call window refreshed it while alive, `isInVideoConference` would become -> "joined, not left, and not expired" — and the four mechanisms above would collapse into one that also covers the -> cases none of them do. It fits our model without adopting Matrix's: the field is already per-member, and -> `hasActiveParticipants` is already the one place that asks. - -## Access control - -**Matrix gates at two layers, neither of them the call.** Joining a slot requires the sender's room membership to -be `join` — so *room membership is call membership*, and the room's own join rules (public, invite, knock, -restricted) are the whole access story. Creating or modifying a slot needs power level. But the MSC is explicit -that "slots don't provide access control": a malicious client can ignore them and form a shadow session, so the -real enforcement is at the media transport — for LiveKit (MSC4195), a JWT the client can only obtain by proving -room membership. - -**We gate at the API, and we deliberately split call access from chat access.** `canAccessConference` accepts -conference membership **or** access to `rid` **or** access to `discussionRid`. That first clause is the whole -point of the membership model: you can be in a call without being in any room. Matrix cannot express this — there, -being in the call *is* being in the room. - -That split is our genuinely distinct idea, and it is also our extra work: it creates the "member who can't read -the chat" state, which needs surfacing (`chatAccess` on `video-conference.info`), a notice, and two remedies -(`share-chat`: invite, or move the chat to a discussion). Matrix gets chat access for free because it never -separated the two — and pays for it by having no way to pull an outsider into a call without giving them the -room. - -Neither is strictly better. Ours suits "add the vendor to this call without showing them the channel"; Matrix's -suits "the room is the unit of trust, and nothing escapes it". - -> **Worth noting against us.** Our enforcement is at the REST API only. The provider URL we hand out is a -> capability: anyone holding it can join the conference at the provider, membership or not. Matrix pushes -> enforcement down to the SFU precisely so that the media plane can't be reached by leaking a link. With Jitsi we -> could close this with a signed JWT per participant (`moderator`, `room`, `exp`), which is the same shape as -> MSC4195's LiveKit token. Today that gap exists. - -## Ringing - -| | MatrixRTC (MSC4075) | Rocket.Chat | -|---|---|---| -| Mechanism | `m.rtc.notification` event in the room | `api.broadcast('user.video-conference', { action: 'ring' })` per user | -| Who is rung | whoever is in `m.mentions` (a user list, or `room: true`) | every member being added, or the room's subscribers at start | -| Scale limit | none in the MSC; room-wide notification is gated by the `notifications.room` power level | hard cap: `VIDEO_CONF_RINGING_LIMIT` = 10, else nobody rings | -| Duration | `lifetime` on the event — 30s recommended, clients cap at 2 minutes | one-shot; the callee's client aborts after 10s, `ringingAt` is treated as live for 15s | -| Stops when | `sender_ts + lifetime` elapses, the sender disconnects, or all recipients join | the window lapses; nothing announces the end | -| Ring vs. notify | explicit `notification_type: "ring" \| "notification"` | implicit — a ring is a ring; a desktop notification accompanies it | - -Two observations. - -**Matrix's `lifetime` is better than our implicit windows.** We encode "how long is this still ringing" in three -places that must agree: a 10s client abort, a 15s `VIDEO_CONF_RINGING_WINDOW_MS` that every reader re-derives, and -a 40s outcome timeout. Matrix puts one number on the event and every reader obeys it. If we ever revisit ringing, -carrying an explicit expiry on the ring — rather than a constant compiled into clients — removes a whole class of -disagreement. - -**Their ring stops when someone answers; ours doesn't.** MSC4075 stops the ring when all recipients join *or the -sender disconnects*. Ours has no such signal — a rung client discovers the call is over only by the 15s window -lapsing. That is the mechanism behind our "the ring can still be missed entirely" limitation. - -The cap is ours alone, and it is a real product difference: a Rocket.Chat conference started in an 11-person -channel rings **nobody**, which is exactly why the ongoing-calls list had to exist. Matrix has no such cliff -because a notification is one event in a room the clients are already syncing — the fan-out we avoid is fan-out -they never have. - -## Declining - -Nearly convergent designs, arrived at separately. - -MSC4310 adds `m.rtc.decline`, an event with an `m.reference` relation to the notification it answers. It does not -terminate the call for others — "on receipt of a decline from a participant, update that participant's state" — -it is visible to the room, it deliberately raises no push, and it is kept so that clients can "render when a -person tried to start a call and if that got declined". - -Ours: `POST /v1/video-conference.decline` writes `declined` and `declinedAt` **on the decliner's own entry**, -never touches the conference's status, so the call stays reachable -afterwards. Same three properties: personal, non-terminating, persisted. - -One difference worth keeping: because our decline is a field on the member rather than an event referencing a -particular ring, "did they decline *this* ring?" needs `declinedAt` compared against `ringingAt` — the comparison -`useCallOutcome` makes. Matrix gets that for free from the reference relation. Ours is the cheaper storage; theirs -is the cheaper question. - -## Discovering an ongoing call - -**Matrix:** free. The membership events are in the room; any client synced to the room already has them, so "is -there a call in this room" and "who is in it" need no request. That is also why Element Call can render a -participant list with no server support. - -**Ours:** the expensive one. Announcing a call to everyone who could join it means a broadcast per room -subscriber — the same fan-out that makes ringing a large room impossible — so `GET /v1/video-conference.joinable` -is **polled every 20 seconds** by every client. The scan is over running conferences rather than the user's rooms, -which keeps it cheap server-side, but it is still a poll where Matrix has a push. - -The asymmetry is not a design failure on our side; it is the cost of not having the call in a stream every client -is already subscribed to. The cheaper fix — noted in the feature doc's improvement suggestions — is a per-*room* -signal clients already subscribe to, rather than enumerating recipients. - -## What each design buys - -**MatrixRTC's strengths, honestly stated** -- Liveness is self-healing and time-bounded; a crashed client corrects itself in ~30s with no server involvement. -- No call record means no call record to get wrong: no stale `endedAt`, no expiry cron, no "ended twice". -- Discovery and the participant list are free, because state is already replicated to every client. -- The ring carries its own expiry, so no two readers disagree about whether it is still ringing. -- Enforcement reaches the media plane, not just the API. - -**Ours** -- Membership independent of room membership — someone can be in a call without being given the room. Matrix - structurally cannot do this. -- A durable per-member outcome (`ended`, `not-answered`, `ongoing`) written from the start. Matrix - reconstructs history by replaying notification/decline events, and a call nobody answered - leaves only a notification to interpret. -- One authoritative answer to "is this call still running", which is what lets the sidebar list and - the room's message block agree without each client computing it. -- Chat that outlives the call, with an explicit, resolvable access model. - -## If we were to borrow three things - -1. **A presence lease** (`presenceExpiresAt`, refreshed by the call window). Replaces four recovery mechanisms - with one, and covers the sleeping-laptop case none of them cover. Highest value, smallest change. -2. **An explicit expiry on the ring**, carried in the ring itself rather than compiled into clients as - `VIDEO_CONF_RINGING_WINDOW_MS`, plus a "stop ringing" signal when someone answers or the caller gives up. -3. **Provider-level enforcement** — a per-participant signed token so the media plane checks membership too, and - a leaked conference URL stops being a capability. - -None of these require adopting Matrix's model. They are the parts of it that survive being separated from it. diff --git a/docs/features/video-conference-persistent-chat/starting-a-call.svg b/docs/features/video-conference-persistent-chat/starting-a-call.svg deleted file mode 100644 index 86bc1f54751c5..0000000000000 --- a/docs/features/video-conference-persistent-chat/starting-a-call.svg +++ /dev/null @@ -1,55 +0,0 @@ - - Starting a video call with persistent chat enabled - The camera button in any room opens a call window at /conference/new showing a preflight screen. Nothing is created until the user confirms. Cancelling leaves no trace; confirming creates the conference, posts a message in the room, and rings the other side — the callee of a direct call, or up to ten room members. - - - - - - - - Camera button in any room - DM, channel, group or discussion - - - - - Call window opens - nothing has been created yet - - - - - Preflight - mic and camera are chosen here - a room call gets a name; a DM says who you are calling - - - - - - Cancel - nothing was created - - - Confirm - the call is created now - - - - - You are in the call - a message appears in the room - and a history row says ongoing - - - - - - Direct call - they ring once you arrive - - - Room call - rings 10 members, or none - diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 7a0415d20f63a..04b1b6491103a 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -67,10 +67,3 @@ export interface IRocketChatDesktop { getE2ePdfPreviewSizeLimit: () => number; openInBrowser: (url: string) => void; } - -export interface IVideoCallWindow { - openInMainWindow: (path: string) => void; - close: () => void; - requestScreenSharing: () => Promise; - getAuthCredentials: () => Promise<{ userId: string; authToken: string; serverUrl: string } | null>; -} diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index 632f505f5146b..3508471f9d81a 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -27,43 +27,3 @@ export async function getPairs(): Promise<[string, string]> { return [spki, pkcs8]; } - -// ---- HS256 (shared-secret) JWTs ---- -// Used for systems like LiveKit that authenticate with an API key/secret pair. - -export type HS256SignOptions = { - secret: string; - issuer?: string; - subject?: string; - // Accepts a duration string like '6h' or '30s', a Date, or seconds since epoch. - expiresIn?: string | number | Date; - // Same accepted forms as expiresIn. Pass 0 for "immediately valid". - notBefore?: string | number | Date; -}; - -export async function signHS256(payload: JWTPayload, options: HS256SignOptions): Promise { - const secretBytes = new TextEncoder().encode(options.secret); - const builder = new SignJWT(payload).setProtectedHeader({ alg: 'HS256', typ: 'JWT' }).setIssuedAt(); - - if (options.issuer) { - builder.setIssuer(options.issuer); - } - if (options.subject) { - builder.setSubject(options.subject); - } - if (options.expiresIn !== undefined) { - builder.setExpirationTime(options.expiresIn as Parameters[0]); - } - if (options.notBefore !== undefined) { - builder.setNotBefore(options.notBefore as Parameters[0]); - } - - return builder.sign(secretBytes); -} - -export async function verifyHS256(jwt: string, secret: string, options?: { issuer?: string }): Promise { - const { payload } = await jwtVerify(jwt, new TextEncoder().encode(secret), { - ...(options?.issuer ? { issuer: options.issuer } : {}), - }); - return payload; -} From 30deae3602e26ac237ee515506574afc1f79288c Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:31:52 -0300 Subject: [PATCH 04/31] fix(video-conf): keep embedded call lifecycle away from non-embedded providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For providers without the embedded capability (Jitsi, Meet, BBB, Pexip), joining a call must have the same observable effects as before the embedded flow existed: the user is added to the call, and nothing else. - addUserToCall: leaving other calls, claiming busy presence and the ring-callee-on-caller-arrival flow now only run for embedded providers. A non-embedded call has no leave/heartbeat/sweep path, so a busy claim would never be released and the user would be stuck Busy forever. - endCall/leaveCall: the busy release mirrors the claim — embedded only. - startDirect: the callee only joins users[] at creation for embedded calls (non-embedded callees enter by answering, as always), and the callee push notification is sent at call start again for non-embedded providers, since ringCalleeOnCallerArrival no longer fires for them. - endDirectCall: skip the 'end' notification based on the member having actually joined, not on mere roster presence — an embedded callee is on the roster from the moment they are called. - addMembers: refuse to add (and ring) members on a call that already ended, same answer ringMembers gives. - shareChatWithMembers: the discussion branch now enforces the same rules regular discussion creation does (Discussion_enabled + start-discussion permission), since it calls createRoom directly. - autoFollowCallThread(ForAllParticipants): only act when the provider declares the persistentChat capability, mirroring maybeCreateDiscussion, so thread auto-follow can't fire for providers that never opted in. Tests: the harness exposes a mutable providerCapabilities holder so specs can flip embedded/persistentChat per test; existing suites pin the embedded behavior explicitly and new cases pin the non-embedded no-op (no sweep, no busy claim, no follow, no ring). The harness also stubs the threads functions module the service now imports, which would otherwise drag the real server settings (top-level await) into the mocha CJS transform. Co-Authored-By: Claude Fable 5 --- .../services/video-conference/service.ts | 102 +++++++++--- .../video-conference/addUserToCall.spec.ts | 150 ++++++++++++++++++ .../video-conference/busyStatus.spec.ts | 43 ++++- .../video-conference/leaveCall.spec.ts | 6 +- .../services/video-conference/ringing.spec.ts | 40 ++++- .../services/video-conference/testHarness.ts | 23 ++- 6 files changed, 338 insertions(+), 26 deletions(-) create mode 100644 apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 5fa223078e7b3..4d95ef2864fdf 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -54,6 +54,7 @@ import { isUnaskedConferenceMember } from '../../../lib/videoConference/memberSt import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; +import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; import { callbacks } from '../../lib/callbacks'; import { i18n } from '../../lib/i18n'; import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging'; @@ -541,11 +542,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf rid: call.rid, uid: call.createdBy._id, }); - } - // Ending the call ends it for whoever was still in it, and each of them is owed their status back. Nobody - // else reports their departure: the call is over, so there is no leave left to arrive. - await Promise.all(call.users.filter(isInVideoConference).map(({ _id }) => this.releaseBusyForCall(_id))); + // Ending the call ends it for whoever was still in it, and each of them is owed their status back. Nobody + // else reports their departure: the call is over, so there is no leave left to arrive. Only embedded joins + // claim busy in the first place, so only they have anything to give back. + await Promise.all(call.users.filter(isInVideoConference).map(({ _id }) => this.releaseBusyForCall(_id))); + } if (call.type === 'direct') { return this.endDirectCall(call); @@ -573,8 +575,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }).toArray(); for (const subscription of subscriptions) { - // Skip notifying users that already joined the call - if (call.users.find(({ _id }) => _id === subscription.u._id)) { + // Skip notifying users that already joined the call. Actually joined: an embedded callee is on the + // roster from the moment they are called, so mere membership would swallow the 'end' that is meant + // to stop their ringing. + const member = call.users.find(({ _id }) => _id === subscription.u._id); + if (member && hasJoinedVideoConference(member)) { continue; } @@ -802,10 +807,16 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runNewVideoConferenceEvent(callId); + const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + // Being called makes you a member, exactly as being added to a group conference does. Without this the // callee only appears once they answer, so nothing can tell "still ringing" from "nobody was called", - // and a call they missed leaves them no history entry. - await this.addAbsentMember(callId, calleeId); + // and a call they missed leaves them no history entry. Embedded only: a non-embedded callee has always + // entered `users` by answering, and putting them there earlier would rewrite the call history their + // clients build from it. + if (isEmbedded) { + await this.addAbsentMember(callId, calleeId); + } await this.maybeCreateDiscussion(callId, user); @@ -815,7 +826,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } // Embedded providers (LiveKit) don't have an external URL to open — // the call is rendered inline. Skip URL generation for them. - const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; if (!isEmbedded) { const url = await this.generateNewUrl(call); await VideoConferenceModel.setUrlById(callId, url); @@ -826,7 +836,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setMessageById(callId, 'started', messageId); // Auto-follow the thread for anyone who joined between call creation and message creation. - await this.autoFollowCallThreadForAllParticipants(call as IDirectVideoConference); + await this.autoFollowCallThreadForAllParticipants(call); // After 40 seconds if the status is still "calling", we cancel the call automatically. setTimeout(async () => { @@ -846,6 +856,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } }, 40000); + // A non-embedded call rings the callee's phone now, at creation, as it always has. Embedded calls hold + // the push back until the caller actually enters the call — see `ringCalleeOnCallerArrival`. + if (!isEmbedded) { + await this.sendPushNotification(call, calleeId); + } + return { type: 'direct', callId, @@ -911,7 +927,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setMessageById(callId, 'started', messageId); // Auto-follow the thread for anyone who joined between call creation and message creation. - await this.autoFollowCallThreadForAllParticipants(call as IGroupVideoConference); + await this.autoFollowCallThreadForAllParticipants(call); if (call.ringing && !isEmbedded) { await this.notifyUsersOfRoom(rid, user._id, 'ring', { callId, rid, uid: call.createdBy._id }); @@ -950,7 +966,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setMessageById(callId, 'started', messageId); // Auto-follow the thread for anyone who joined between call creation and message creation. - await this.autoFollowCallThreadForAllParticipants(call as ILivechatVideoConference); + await this.autoFollowCallThreadForAllParticipants(call); return { type: 'livechat', @@ -1188,10 +1204,17 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.addUserToDiscussion(call.discussionRid, _id); } + // The whole join-side lifecycle below only exists for embedded providers. A non-embedded call (Jitsi, + // Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever undo what gets claimed here — + // so for those a join must do what it always did: record the member, and nothing else. + const isEmbedded = videoConfProviders.getProviderCapabilities(call.providerName)?.embedded === true; + // A user is in one call at a time, and this is where that becomes true rather than hoped for. A window that // dies without reporting its departure — a crash, a killed tab — otherwise leaves its user counted as // present forever, which both misreports them and keeps a finished call listed as occupied. - await this.leaveOtherCalls(call._id, _id); + if (isEmbedded) { + await this.leaveOtherCalls(call._id, _id); + } // Already in the call — nothing to record. This asks about presence, not about having joined at some // point: a member who joined and left is joined-ever but absent, and returning here would leave their @@ -1208,8 +1231,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.setUserJoinedById(call._id, _id, ts); this.notifyConferenceUpdate(call._id); - // In a call is busy, for as long as it lasts. - await this.claimBusyForCall(_id); + // In a call is busy, for as long as it lasts. Embedded only: the claim is released by leaving, by the + // heartbeat sweep or by the call ending, and a non-embedded call has none of those — the claim would + // outrank whatever status the user sets by hand, leaving them busy forever. + if (isEmbedded) { + await this.claimBusyForCall(_id); + } // When persistent chat is in "thread" mode, auto-follow the call's chat // thread so the participant receives thread notifications for messages @@ -1217,7 +1244,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.autoFollowCallThread(call, _id); if (call.type === 'direct') { - await this.ringCalleeOnCallerArrival(call, _id); + // The ring-on-arrival dance belongs to the embedded flow, where the caller sits on a preflight screen + // first; a non-embedded direct call already rang its callee (and pushed) when it was created. + if (isEmbedded) { + await this.ringCalleeOnCallerArrival(call, _id); + } return this.updateDirectCall(call, _id); } @@ -1235,11 +1266,17 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf usernames: NonNullable[], { ring = true }: { ring?: boolean } = {}, ): Promise { - const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1 } }); + const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); if (!call) { throw new Error('invalid-video-conference'); } + // A finished call is not something to add people to — and certainly not something to ring them into. + // Same answer `ringMembers` gives: nobody was added. + if (call.endedAt) { + return []; + } + const users = await Users.find>>( { username: { $in: usernames } }, { projection: { username: 1, name: 1, avatarETag: 1 } }, @@ -1520,10 +1557,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { await this.notifyUsersOfRoom(call.rid, uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); this.notifyUser(uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); - } - // Out of the call, so back to whatever status they had before it. - await this.releaseBusyForCall(uid); + // Out of the call, so back to whatever status they had before it. Only embedded joins claim busy, + // so only they have a claim to end. + await this.releaseBusyForCall(uid); + } // Decide on the state we just wrote rather than the one we read, so the member who is leaving is counted // as gone. Reading again would be a second round trip for the same answer. @@ -1815,6 +1853,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } if (resolved === 'discussion') { + // The same rules regular discussion creation enforces: this path calls `createRoom` directly, so + // nothing downstream would ask. A refusal, not a fallback to inviting — see `resolveChatAccessMode`. + if ( + !settings.get('Discussion_enabled') || + !(await hasAtLeastOnePermissionAsync(uid, ['start-discussion', 'start-discussion-other-user'], rid)) + ) { + throw new Error('error-not-allowed'); + } + // Moving the chat to a discussion announces the conference itself changed, which is what makes every // participant's panel follow the chat to its new room. return this.createConferenceDiscussionWithParticipants(uid, callId, usernames); @@ -1865,7 +1912,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private getPersistentChatMode(): 'thread' | 'main_room' { - return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'thread'; + // 'main_room' is the historical behavior — a discussion off the main room — and is what a workspace that + // enabled persistent chat before the mode existed must keep getting. + return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'main_room'; } /** @@ -1880,6 +1929,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return; } + // Same rule as `maybeCreateDiscussion`: persistent chat is only acted on for a provider that declares + // support for it — a Jitsi call must not start following threads because the setting is on. + if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + return; + } + if (!call.messages.started) { return; } @@ -1898,6 +1953,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return; } + // Same rule as `maybeCreateDiscussion`: only for a provider that declares persistent chat support. + if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + return; + } + if (!call.messages.started || !call.users.length) { return; } diff --git a/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts new file mode 100644 index 0000000000000..1dabc289e15c1 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/video-conference/addUserToCall.spec.ts @@ -0,0 +1,150 @@ +import type { VideoConference } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { + buildGroupCall, + buildMember, + cloneFixture, + commonServiceStubs, + providerCapabilities, + resetAll, + ringedUserIds, +} from './testHarness'; + +/** + * What joining a call does *besides* recording the join, and for whom. + * + * The join-side lifecycle — leaving other calls, claiming busy, following the chat thread, ringing the callee — + * exists for embedded providers, which have a leave, a heartbeat and a sweep to undo all of it. A non-embedded + * provider has none of those, so its join must look exactly as it always has: the member is added, and nothing + * else happens. This suite pins both sides of that line; `Presence` and `follow` are observable here because the + * shared harness has no stubs for them. + */ +const proxyquire = require('proxyquire'); + +let fixture: VideoConference; + +const PresenceMock = { + setActiveState: sinon.stub().resolves(true), + endActiveState: sinon.stub().resolves(true), +}; + +const followStub = sinon.stub().resolves(); + +const VideoConferenceModelMock = { + findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), + addMemberById: sinon.stub().resolves(), + setUserJoinedById: sinon.stub().resolves(), + setStatusById: sinon.stub().resolves(), + setRingingById: sinon.stub().resolves(), + setUsersRingingById: sinon.stub().resolves(), + find: sinon.stub().returns({ toArray: async () => [] }), +}; + +const UsersMock = { findOneById: sinon.stub().resolves({ _id: 'joiner', username: 'joiner.user', name: 'Joiner', avatarETag: null }) }; + +const broadcastStub = sinon.stub().resolves(); + +const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { + ...commonServiceStubs, + '@rocket.chat/core-services': { + api: { broadcast: broadcastStub }, + ServiceClassInternal: class { + onEvent() { + /* no-op */ + } + }, + Message: { saveSystemMessage: sinon.stub().resolves() }, + Room: { addUserToRoom: sinon.stub().resolves() }, + Presence: PresenceMock, + }, + '@rocket.chat/models': { + Users: UsersMock, + VideoConference: VideoConferenceModelMock, + Rooms: { findOneById: sinon.stub().resolves(null) }, + Messages: { setBlocksById: sinon.stub().resolves() }, + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + }, + }, + '../../lib/messaging/threads/functions': { follow: followStub }, + // Persistent chat fully on, in thread mode: what proves the *provider* gate below is the gate that held. + // Discussions have to be on for that, and the E2E keys stay off, since enforced encryption on private rooms + // switches persistent chat back off. + '../../settings': { + settings: { + get: (key: string) => + ( + ({ VideoConf_Enable_Persistent_Chat: true, VideoConf_Persistent_Chat_Mode: 'thread', Discussion_enabled: true }) as Record< + string, + unknown + > + )[key], + }, + }, +}); + +describe('VideoConfService.addUserToCall provider gating', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + providerCapabilities.current = undefined; + resetAll( + PresenceMock.setActiveState, + PresenceMock.endActiveState, + followStub, + VideoConferenceModelMock.findOneById, + VideoConferenceModelMock.addMemberById, + VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.find, + broadcastStub, + ); + }); + + afterEach(() => { + providerCapabilities.current = undefined; + }); + + // The invariant the gating exists for: a Jitsi/Meet/BBB join must have exactly the effects it had before the + // embedded lifecycle existed — the member is recorded, and nothing else fires. + it('only records the member for a non-embedded provider: no other-call sweep, no busy claim, no follow, no ring', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.addMemberById.calledWith('call1')).to.be.true; + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + + expect(VideoConferenceModelMock.find.called, 'queried for other calls to leave').to.be.false; + expect(PresenceMock.setActiveState.called, 'claimed busy').to.be.false; + expect(followStub.called, 'followed the call thread').to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // The other side of the line, so a regression can't pass by never firing the lifecycle for anyone. + it('runs the whole lifecycle for an embedded provider that supports persistent chat', async () => { + providerCapabilities.current = { embedded: true, persistentChat: true }; + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + expect(VideoConferenceModelMock.find.called, 'queried for other calls to leave').to.be.true; + expect(PresenceMock.setActiveState.calledWith('joiner'), 'claimed busy').to.be.true; + expect(followStub.calledWith({ tmid: 'msg1', uid: 'joiner' }), 'followed the call thread').to.be.true; + }); + + // Fix for thread auto-follow firing for providers that never declared persistent chat support: the setting + // being on is not enough — the provider has to be able to honor it, same as the discussion path. + it('does not follow the thread for an embedded provider without the persistentChat capability', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'host' })], { messages: { started: 'msg1' } }); + + await service.addUser('call1', 'joiner'); + + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + expect(followStub.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index ef873b01c40fa..7fa406ec9090d 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -3,7 +3,7 @@ import { UserStatus } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import sinon from 'sinon'; -import { buildGroupCall, buildMember, cloneFixture, commonServiceStubs, resetAll } from './testHarness'; +import { buildGroupCall, buildMember, cloneFixture, commonServiceStubs, providerCapabilities, resetAll } from './testHarness'; import { PRESENCE_LEASE_MS } from '../../../../../lib/videoConference/presence'; /** @@ -85,6 +85,12 @@ describe('VideoConfService presence while in a call', () => { VideoConferenceModelMock.setUserJoinedById, ); PresenceMock.setActiveState.resolves(true); + // The busy claim only exists for embedded providers — they are the ones with a leave/sweep to release it. + providerCapabilities.current = { embedded: true }; + }); + + afterEach(() => { + providerCapabilities.current = undefined; }); // Being in a call is being busy, and saying so is what stops people ringing someone mid-conversation. @@ -146,4 +152,39 @@ describe('VideoConfService presence while in a call', () => { expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; }); + + // A non-embedded provider (Jitsi, Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever + // release the claim, so a single Jitsi call would leave the user stuck on Busy forever. + describe('for a non-embedded provider', () => { + beforeEach(() => { + providerCapabilities.current = undefined; + }); + + it('never claims busy on join, but still records the join', async () => { + fixture = buildGroupCall([buildMember({ _id: 'host' })]); + + await service.addUser('call1', 'joiner'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; + }); + + // No claim was ever made, so there is nothing to release — and releasing anyway would end a claim some + // other feature (a voice call) legitimately holds. + it('does not release anything on leave', async () => { + fixture = buildGroupCall([buildMember({ _id: 'other' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(PresenceMock.endActiveState.called).to.be.false; + }); + + it('does not release anything when the call ends', async () => { + fixture = buildGroupCall([buildMember({ _id: 'present' })]); + + await service.endCall('call1'); + + expect(PresenceMock.endActiveState.called).to.be.false; + }); + }); }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index 660f0b154c4ef..79f27d245c38b 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -3,7 +3,7 @@ import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import sinon from 'sinon'; -import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; +import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, providerCapabilities, resetAll } from './testHarness'; /** Must match the constant defined in the service. */ const EMPTY_CALL_GRACE_MS = 10_000; @@ -181,6 +181,9 @@ describe('VideoConfService one call at a time', () => { clock = sinon.useFakeTimers({ shouldAdvanceTime: false }); service = new VideoConfService(); calls = {}; + // Leaving other calls on join is part of the embedded lifecycle — a non-embedded join records the member + // and nothing else. + providerCapabilities.current = { embedded: true }; resetAll( VideoConferenceModelMock.findOneById, VideoConferenceModelMock.setUserLeftById, @@ -195,6 +198,7 @@ describe('VideoConfService one call at a time', () => { afterEach(() => { clock.restore(); + providerCapabilities.current = undefined; VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); UsersMock.findOneById.resolves(null); }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts index 40dbd33f9fef1..ed3b705693659 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -4,7 +4,16 @@ import { expect } from 'chai'; import { beforeEach, describe, it } from 'mocha'; import sinon from 'sinon'; -import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, resetAll, ringedUserIds } from './testHarness'; +import { + buildDirectCall, + buildGroupCall, + buildMember, + cloneFixture, + createService, + providerCapabilities, + resetAll, + ringedUserIds, +} from './testHarness'; /** * Who gets rung, and when. @@ -280,6 +289,18 @@ describe('VideoConfService.addMembers', () => { expect(ringedUserIds(broadcastStub).sort()).to.deep.equal(['newUser1', 'newUser2']); }); + // A finished call is not something to add people to — and certainly not something to ring them into. + it('adds nobody and rings nobody on a conference that has already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })], { endedAt: new Date('2026-01-01T01:00:00.000Z') }); + UsersMock.find.returns({ toArray: sinon.stub().resolves([buildUser('newUser1')]) }); + + const result = await service.addMembers('caller', 'call1', ['newUser1.user']); + + expect(result).to.deep.equal([]); + expect(VideoConferenceModelMock.addMemberById.called).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + // Nobody was actually added (every requested user was already a member) — there is nobody new to ring. it('rings nobody when nobody was added', async () => { fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'already' })]); @@ -321,6 +342,12 @@ describe('VideoConfService: ringing a direct call when its caller arrives', () = beforeEach(() => { fixture = directCall(); UsersMock.findOneById.callsFake(async (uid: string) => ({ _id: uid, username: uid, name: uid, avatarETag: null })); + // Ring-on-arrival only exists for embedded providers, whose caller sits on a preflight screen first. + providerCapabilities.current = { embedded: true }; + }); + + afterEach(() => { + providerCapabilities.current = undefined; }); it('rings the callee when the caller joins', async () => { @@ -360,4 +387,15 @@ describe('VideoConfService: ringing a direct call when its caller arrives', () = expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; }); + + // A non-embedded direct call has no preflight — it already rang the callee when it was created, so the + // caller arriving in it must not ring anyone a second time. + it('rings nobody at all for a non-embedded provider', async () => { + providerCapabilities.current = undefined; + + await service.addUser('call1', 'creator'); + + expect(VideoConferenceModelMock.setUsersRingingById.called).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts index 457222dff9fad..c8a833d309639 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/testHarness.ts @@ -1,8 +1,24 @@ -import type { IGroupVideoConference, IDirectVideoConference, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; +import type { + IGroupVideoConference, + IDirectVideoConference, + IVideoConferenceUser, + VideoConference, + VideoConferenceCapabilities, +} from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; +/** + * What `videoConfProviders.getProviderCapabilities` answers, for every service loaded through this harness. + * + * The service gates most of its join-side lifecycle on the provider being embedded, so a spec has to be able to + * play both kinds. The stub reads this holder at call time: a spec flips `current` in a `beforeEach` (and back + * in an `afterEach`, since the loaded module is shared across files) rather than re-loading the service. + * `undefined` — no capabilities — is the default, and is what a non-embedded provider looks like. + */ +export const providerCapabilities: { current: VideoConferenceCapabilities | undefined } = { current: undefined }; + // The stubs below never vary between specs in this directory — they satisfy imports the service file needs // at load time but that no test here actually exercises. Kept in one place so a new spec doesn't have to // re-list all ~25 of them just to get the module to load; only the modules a spec actually cares about @@ -39,11 +55,14 @@ export const commonServiceStubs = { '../../../lib/videoConference/chatAccess': { resolveChatAccessMode: () => undefined }, '../../database/readSecondaryPreferred': { readSecondaryPreferred: () => undefined }, '../../lib/authorization/canAccessRoom': { canAccessRoomIdAsync: async () => true }, + '../../lib/authorization/hasPermission': { hasAtLeastOnePermissionAsync: async () => true }, '../../lib/callbacks': { callbacks: { runAsync: () => undefined, run: () => undefined } }, '../../lib/i18n': { i18n: { t: (s: string) => s } }, '../../lib/isRoomCompatibleWithVideoConfRinging': { isRoomCompatibleWithVideoConfRinging: () => true }, '../../lib/media/assets': { RocketChatAssets: { getURL: () => '' } }, '../../lib/messages/sendMessage': { sendMessage: async () => ({ _id: 'msg1' }) }, + // Loaded for real this would drag in `server/settings` (top-level await), which tsx-in-CJS cannot transform. + '../../lib/messaging/threads/functions': { follow: async () => undefined }, '../../lib/metrics/lib/metrics': { metrics: { notificationsSent: { inc: () => undefined }, notificationsSentTotal: { inc: () => undefined } }, }, @@ -62,7 +81,7 @@ export const commonServiceStubs = { hasAnyProvider: () => false, getActiveProvider: () => undefined, isProviderAvailable: () => false, - getProviderCapabilities: () => undefined, + getProviderCapabilities: () => providerCapabilities.current, getProviderAppId: () => undefined, getProviderList: () => [], }, From 20795ad52f6d9fe1f524de80a306d8c219218c13 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:32:01 -0300 Subject: [PATCH 05/31] feat(video-conf): register the VideoConf_Persistent_Chat_Mode setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service already reads it, but nothing registered it, so the mode could never be changed from the admin UI. A select between 'main_room' and 'thread', defaulting to 'main_room' — the historical behavior (a discussion off the main room) — so workspaces that already had persistent chat enabled keep getting what they had. The service-side fallback for an unset value now matches that default. Adds the minimal en i18n keys the setting needs to render; full i18n is deferred by the PR. Co-Authored-By: Claude Fable 5 --- apps/meteor/ee/server/settings/video-conference.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/meteor/ee/server/settings/video-conference.ts b/apps/meteor/ee/server/settings/video-conference.ts index 81ff49e49a6d2..740fafc14afc0 100644 --- a/apps/meteor/ee/server/settings/video-conference.ts +++ b/apps/meteor/ee/server/settings/video-conference.ts @@ -44,6 +44,20 @@ export function addSettings(): Promise { const persistentChatEnabled = { _id: 'VideoConf_Enable_Persistent_Chat', value: true }; + // 'main_room' keeps the historical behavior — a discussion created off the main room — for + // workspaces that already had persistent chat enabled before the mode existed. + await this.add('VideoConf_Persistent_Chat_Mode', 'main_room', { + type: 'select', + values: [ + { key: 'main_room', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Main_Room' }, + { key: 'thread', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Thread' }, + ], + public: true, + invalidValue: 'main_room', + i18nDescription: 'VideoConf_Persistent_Chat_Mode_Description', + enableQuery: [persistentChatEnabled], + }); + await this.add('VideoConf_Persistent_Chat_Discussion_Name', 'Video Call Chat', { type: 'string', public: true, From f203f2b1001725da09f727597ddb6ef028cb6bc3 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:32:17 -0300 Subject: [PATCH 06/31] fix(video-conf): make the join endpoint's missing-url failure reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '!url && !call.providerName' could never be true — a call always carries a provider name. Fail with failed-to-get-url when there is no url and the provider is not embedded; embedded providers legitimately return an empty url because the call renders inline. Co-Authored-By: Claude Fable 5 --- apps/meteor/server/api/v1/videoConference.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index e4afeaa1462f7..841bb1936aa24 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -274,7 +274,9 @@ API.v1.post( // they're rendered inline rather than opened as an external popup. // Include rid so the client can route the join into its embedded // provider context without an extra round-trip to look it up. - if (!url && !call.providerName) { + // For every other provider the url is the whole point of joining, + // so coming back without one is a failure. + if (!url && !videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { return API.v1.failure('failed-to-get-url'); } From 9ec7924ad550e951ee645c1297fbe6d6978574eb Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:32:17 -0300 Subject: [PATCH 07/31] fix(video-conf): measure the presence-sweep grace from job registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.uptime() counts the whole boot, and a Meteor boot can outlast the presence lease — the very first sweep would then evict everyone before any client had a chance to heartbeat. Count readiness from the moment the cron job is registered instead; isPresenceSweepDue itself is unchanged. Co-Authored-By: Claude Fable 5 --- apps/meteor/server/cron/videoConferences.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/meteor/server/cron/videoConferences.ts b/apps/meteor/server/cron/videoConferences.ts index 50d4accb0dc64..abb5be88ecf0e 100644 --- a/apps/meteor/server/cron/videoConferences.ts +++ b/apps/meteor/server/cron/videoConferences.ts @@ -25,10 +25,12 @@ async function runVideoConferences(): Promise { * Frequent because it is what recovers a call after an outage, and cheap because the work is proportional to the * number of *open* calls, which is normally none. */ -async function runPresenceSweep(): Promise { +async function runPresenceSweep(readyForMs: number): Promise { // A restart cannot tell "everyone left" from "we were not here to be told" — both leave every lease expired. // So a fresh process waits out one full lease, by which time anyone still in a call has renewed theirs. - if (!isPresenceSweepDue(process.uptime() * 1000)) { + // Measured from when the job was registered, not from process launch: a slow boot can eat the whole lease + // before any client had a chance to heartbeat. + if (!isPresenceSweepDue(readyForMs)) { return; } @@ -45,5 +47,6 @@ export async function videoConferencesCron(): Promise { // // Not run here on the way past, unlike the expiry above: at startup the guard inside it would reject it // anyway, and that is exactly the point. - return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep()); + const registeredAt = Date.now(); + return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep(Date.now() - registeredAt)); } From 73df4731ce3b40b65056074bc8aac250b95f70aa Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:32:17 -0300 Subject: [PATCH 08/31] fix(models): correct video-conference index, stale leftReason and participant upsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the sparse { endedAt, createdAt } index: the planner will not use a sparse index for endedAt $exists:false, and since createdAt exists on every document the index was not small either — it served nothing. A partial index on { status, createdAt } filtered to CALLING/STARTED serves findActiveWithMembers and findActiveEmbeddedInRoom and stays tiny ($in in a partialFilterExpression needs MongoDB 6.0; the minimum supported server is 7.0). - setUserLeftById: a reported departure now $unsets a leftover inferred leftReason, so a stale heartbeat can no longer revive a leave the user actually reported (renewUserPresenceById treats an inferred reason as permission to undo the departure). - addEmbeddedParticipant: replace-and-append in one pipeline update instead of a $pull followed by a $push, so concurrent joins cannot interleave into a duplicate entry. Co-Authored-By: Claude Fable 5 --- .../models/src/models/VideoConference.spec.ts | 34 +++++++++++++- packages/models/src/models/VideoConference.ts | 47 +++++++++++++++---- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts index 01d827a6bc881..c0be3373fc44c 100644 --- a/packages/models/src/models/VideoConference.spec.ts +++ b/packages/models/src/models/VideoConference.spec.ts @@ -168,7 +168,7 @@ describe('VideoConferenceRaw.setUserLeftById', () => { const [query, update, options] = updateOne.mock.calls[0]; expect(query).toEqual({ _id: 'call-1' }); - expect(update).toEqual({ $set: { 'users.$[user].leftAt': leftAt } }); + expect(update.$set).toEqual({ 'users.$[user].leftAt': leftAt }); expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); }); @@ -194,6 +194,38 @@ describe('VideoConferenceRaw.setUserLeftById', () => { await model.setUserLeftById('call-1', 'user-1', new Date()); expect(Object.keys(updateOne.mock.calls[1][1].$set)).not.toContain('users.$[user].leftReason'); }); + + // A reported departure has to erase a leftover inferred one, or a stale heartbeat could still revive it: + // `renewUserPresenceById` treats an inferred reason as permission to undo the departure. + it('should clear a previously inferred reason when the departure is reported', async () => { + const { model, updateOne } = setupModel(); + + await model.setUserLeftById('call-1', 'user-1', new Date()); + expect(updateOne.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftReason': 1 }); + + await model.setUserLeftById('call-1', 'user-1', new Date(), 'timeout'); + expect(updateOne.mock.calls[1][1]).not.toHaveProperty('$unset'); + }); +}); + +describe('VideoConferenceRaw.addEmbeddedParticipant', () => { + // Two writes ($pull then $push) let two concurrent joins interleave into a duplicate entry; a single + // pipeline update replaces-and-appends atomically. + it('should drop any prior entry and append the fresh one in one write', async () => { + const { model, updateOne } = setupModel(); + const joinedAt = new Date('2026-08-01T10:00:00Z'); + + await model.addEmbeddedParticipant('call-1', { id: 'user-1', username: 'user.one', displayName: 'User One', joinedAt }); + + expect(updateOne).toHaveBeenCalledTimes(1); + const [query, update] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1' }); + // A pipeline update, which is what makes the replace-and-append a single atomic step. + expect(Array.isArray(update)).toBe(true); + expect(update[0].$set.participants.$concatArrays[1]).toEqual({ + $literal: [{ id: 'user-1', username: 'user.one', displayName: 'User One', joinedAt }], + }); + }); }); describe('VideoConferenceRaw.setUserDeclinedById', () => { diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index 83c4383d57087..77b6e4ddcf694 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -36,9 +36,16 @@ export class VideoConferenceRaw extends BaseRaw implements IVid // `createdAt` is part of the key so the `$or: [{ rid }, { discussionRid }]` listing below can be // served by an index-ordered merge instead of a blocking in-memory sort of the whole room history. { key: { discussionRid: 1, createdAt: 1 }, unique: false }, - // Listing the calls that are running: a sparse index, because a conference carries `endedAt` only once - // it has stopped, so the index holds just the handful that are live. - { key: { endedAt: 1, createdAt: -1 }, unique: false, sparse: true }, + // Listing the calls that are running (`findActiveWithMembers`, `findActiveEmbeddedInRoom`): a partial + // index, so it holds just the handful of conferences that are live. The hot queries match on these + // exact statuses, which is what makes the index eligible for them; `endedAt: { $exists: false }` alone + // could not anchor an index at all. `$in` in a partialFilterExpression needs MongoDB 6.0, and the + // minimum supported server is 7.0. + { + key: { status: 1, createdAt: -1 }, + unique: false, + partialFilterExpression: { status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] } }, + }, ]; } @@ -336,7 +343,12 @@ export class VideoConferenceRaw extends BaseRaw implements IVid public async setUserLeftById(callId: string, uid: IUser['_id'], leftAt = new Date(), reason?: VideoConferenceLeaveReason): Promise { await this.updateOne( { _id: callId }, - { $set: { 'users.$[user].leftAt': leftAt, ...(reason && { 'users.$[user].leftReason': reason }) } }, + { + $set: { 'users.$[user].leftAt': leftAt, ...(reason && { 'users.$[user].leftReason': reason }) }, + // A reported departure must clear a leftover inferred one, or a stale heartbeat could still revive + // it: `renewUserPresenceById` treats an inferred reason as permission to undo the departure. + ...(!reason && { $unset: { 'users.$[user].leftReason': 1 } }), + }, { arrayFilters: [{ 'user._id': uid }] }, ); } @@ -460,12 +472,27 @@ export class VideoConferenceRaw extends BaseRaw implements IVid } public async addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise { - // Pull any prior entry for this user first so a re-join doesn't - // leave a leftAt'd ghost in the array alongside the fresh entry. - await this.updateOne({ _id: callId }, { $pull: { participants: { id: participant.id } } } as any); - await this.updateOne({ _id: callId }, { - $push: { participants: { ...participant, joinedAt: participant.joinedAt ?? new Date() } }, - } as any); + // One atomic update: drop any prior entry for this user (so a re-join doesn't leave a leftAt'd ghost + // alongside the fresh one) and append the new entry in the same write — two separate writes would let + // two concurrent joins interleave into a duplicate. `$literal` keeps the entry's values as data even if + // one happens to look like an aggregation expression. + await this.updateOne({ _id: callId }, [ + { + $set: { + participants: { + $concatArrays: [ + { + $filter: { + input: { $ifNull: ['$participants', []] }, + cond: { $ne: ['$$this.id', participant.id] }, + }, + }, + { $literal: [{ ...participant, joinedAt: participant.joinedAt ?? new Date() }] }, + ], + }, + }, + }, + ] as any); } public async markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt = new Date()): Promise { From d64f661be5a099e90a5f3338ebf8612e09aa92ce Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:58:36 -0300 Subject: [PATCH 09/31] fix(video-conf): scope leave notifications to the leaver and survive probe failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leaveCall no longer broadcasts 'end' to the whole room when a single member leaves. One member leaving is not the call ending — a reload fires a leave too — and the room-wide 'end' was dismissing everyone else's ringing popup and silencing the caller's outgoing ring while the call still ran. The leaver's own devices still get 'end' so their other windows drop the call UI; the room-wide 'end' stays with endCall, which the grace period reaches once the call has actually emptied. - expirePresenceLeases treats a rejecting presence probe as silence (undefined, logged as a warning) instead of letting it throw into the per-call catch — an unreachable provider was skipping that call's lease expiry entirely, keeping its crashed members present forever. Leases are now judged on their own evidence, and the sweep is proven to carry on across calls by a two-call cursor fixture. - EMPTY_CALL_GRACE_MS moves to lib/videoConference/constants so the leaveCall spec imports the real value instead of duplicating the 10s literal (client code can plausibly want the grace period too). Co-Authored-By: Claude Fable 5 --- apps/meteor/lib/videoConference/constants.ts | 6 ++ .../services/video-conference/service.ts | 28 +++++++--- .../expirePresenceLeases.spec.ts | 36 +++++++++++- .../video-conference/leaveCall.spec.ts | 55 +++++++++++++++++-- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/apps/meteor/lib/videoConference/constants.ts b/apps/meteor/lib/videoConference/constants.ts index 56ce12e7c421e..bea29ad0feb41 100644 --- a/apps/meteor/lib/videoConference/constants.ts +++ b/apps/meteor/lib/videoConference/constants.ts @@ -20,6 +20,12 @@ export const CALL_FACES_SHOWN = 2; */ export const PREFLIGHT_FACES_SHOWN = 10; +/** + * How long a conference is kept alive after the last participant leaves, before it is ended. + * Long enough for a reload to land and cancel it, short enough that a call really over doesn't linger. + */ +export const EMPTY_CALL_GRACE_MS = 10_000; + /** Whether this many recipients is a set worth ringing. See `VIDEO_CONF_RINGING_LIMIT` for why there is a cap. */ export const shouldRingVideoConference = (recipientCount: number): boolean => recipientCount > 0 && recipientCount <= VIDEO_CONF_RINGING_LIMIT; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 4d95ef2864fdf..bec4dbd7f0619 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -49,7 +49,12 @@ import { MongoInternals } from 'meteor/mongo'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { resolveChatAccessMode } from '../../../lib/videoConference/chatAccess'; import { conferenceNameFor } from '../../../lib/videoConference/conferenceName'; -import { availabilityErrors, CALL_FACES_SHOWN, shouldRingVideoConference } from '../../../lib/videoConference/constants'; +import { + availabilityErrors, + CALL_FACES_SHOWN, + EMPTY_CALL_GRACE_MS, + shouldRingVideoConference, +} from '../../../lib/videoConference/constants'; import { isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -80,12 +85,6 @@ const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; const logger = new Logger('VideoConference'); -/** - * How long a conference is kept alive after the last participant leaves, before it is ended. - * Long enough for a reload to land and cancel it, short enough that a call really over doesn't linger. - */ -const EMPTY_CALL_GRACE_MS = 10_000; - export class VideoConfService extends ServiceClassInternal implements IVideoConfService { protected name = 'video-conference'; @@ -1555,7 +1554,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf this.notifyConferenceUpdate(callId); if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { - await this.notifyUsersOfRoom(call.rid, uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); + // Only the leaver's own devices are told 'end', so their other windows stop showing a call they are no + // longer in. Never the room: one member leaving is not the call ending — a reload fires a leave too — + // and a room-wide 'end' from here would dismiss everyone else's ringing popup and silence the caller's + // outgoing ring while the call still runs. The room-wide 'end' belongs to `endCall`, which the grace + // period below reaches once the call has actually emptied. this.notifyUser(uid, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id }); // Out of the call, so back to whatever status they had before it. Only embedded joins claim busy, @@ -1651,7 +1654,14 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // A provider that can say who is in its room is asked first, and its answer renews leases the same // way a client's heartbeat does. Silence is not absence: `undefined` leaves the leases as they are. - const present = await videoConfPresence.getProbe(call.providerName)?.(call); + // A probe that fails is silence too — per the probe contract it should already answer `undefined`, + // but an unreachable provider must not stop this call's leases being judged on their own evidence. + const present = await videoConfPresence + .getProbe(call.providerName)?.(call) + .catch((err) => { + logger.warn({ msg: 'Video conference presence probe failed', callId: call._id, providerName: call.providerName, err }); + return undefined; + }); const users = present ? call.users.map((user) => (present.includes(user._id) ? { ...user, lastSeenAt: now } : user)) : call.users; if (present?.length) { diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts index af14ae5d2eef5..94aa4ce7eaf3f 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -73,6 +73,12 @@ describe('VideoConfService.expirePresenceLeases', () => { VideoConferenceModelMock.setDataById, VideoConferenceModelMock.setStatusById, ); + // `resetAll` only clears history — restore the single-call cursor for the tests that replace it. + VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + }, + })); }); // The case this exists for: the workspace was down while the call carried on in the provider, so the leave @@ -173,14 +179,38 @@ describe('VideoConfService.expirePresenceLeases', () => { expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; }); - // One unreachable provider, or one malformed call, must not stop the sweep for every other call. - it('carries on when the probe throws', async () => { + // A probe that fails is silence, same as a provider with no probe at all: the leases still get judged on + // their own evidence. Anything else lets an unreachable provider keep its crashed members present forever — + // the exact situation the sweep exists to clean up. + it('still expires the leases when the probe fails', async () => { + probe = sinon.stub().rejects(new Error('LiveKit is unreachable')); + fixture = buildGroupCall([ + buildMember({ _id: 'staying', lastSeenAt: at(0) }), + buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), + ]); + + await service.expirePresenceLeases(at(0)); + + expect(VideoConferenceModelMock.setUserLeftById.calledWith('call1', 'gone')).to.be.true; + expect(VideoConferenceModelMock.setUserLeftById.calledOnce, 'a failed probe must not evict the fresh lease').to.be.true; + }); + + // And a failing probe on one call must not stop the sweep before it reaches the next one. + it('carries on to the next call when a probe fails', async () => { probe = sinon.stub().rejects(new Error('LiveKit is unreachable')); fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + const second = buildGroupCall([buildMember({ _id: 'gone2', lastSeenAt: at(-PRESENCE_LEASE_MS) })], { _id: 'call2' }); + VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + yield cloneFixture(second); + }, + })); await service.expirePresenceLeases(at(0)); - expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; + expect(VideoConferenceModelMock.setUserLeftById.calledWith('call1', 'gone')).to.be.true; + expect(VideoConferenceModelMock.setUserLeftById.calledWith('call2', 'gone2')).to.be.true; }); }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index 79f27d245c38b..f9a680c635bce 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -4,9 +4,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { buildDirectCall, buildGroupCall, buildMember, cloneFixture, createService, providerCapabilities, resetAll } from './testHarness'; - -/** Must match the constant defined in the service. */ -const EMPTY_CALL_GRACE_MS = 10_000; +import { EMPTY_CALL_GRACE_MS } from '../../../../../lib/videoConference/constants'; // `VideoConference.findOneById` is hit more than once per `leaveCall` → `endCall` flow, with different // projections (`leaveCall` reads `{ rid, users, endedAt }`, `endCall`'s `getUnfiltered` reads everything). A @@ -39,18 +37,38 @@ const UsersMock = { findOneById: sinon.stub().resolves(null), }; +const broadcastStub = sinon.stub().resolves(); + const VideoConfService = createService({ + broadcast: broadcastStub, models: { Users: UsersMock, VideoConference: VideoConferenceModelMock, + // A room with another member in it, so a broadcast the service should NOT send room-wide has somebody + // it would demonstrably reach. + Subscriptions: { + findByRoomIdAndNotUserId: sinon.stub().returns({ + toArray: sinon.stub().resolves([{ u: { _id: 'other' } }]), + forEach: (cb: (subscription: { u: { _id: string } }) => void) => { + cb({ u: { _id: 'other' } }); + return Promise.resolve(); + }, + }), + }, }, // This suite is about what happens when a call empties, so the ringing the service would otherwise do on a - // join is stubbed out of the way. + // join is stubbed out of the way. The grace period must stay the real one — it is what the suite measures. overrides: { - '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false }, + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false, EMPTY_CALL_GRACE_MS }, }, }); +/** Who was told 'end' through `notifyUser` — the per-user broadcast, as opposed to the room-wide channel. */ +const endNotifiedUserIds = (): string[] => + broadcastStub.args + .filter(([channel, payload]) => channel === 'user.video-conference' && (payload as { action: string }).action === 'end') + .map(([, payload]) => (payload as { userId: string }).userId); + describe('VideoConfService.leaveCall', () => { let service: any; @@ -70,12 +88,15 @@ describe('VideoConfService.leaveCall', () => { VideoConferenceModelMock.setUserLeftById, VideoConferenceModelMock.setDataById, VideoConferenceModelMock.setStatusById, + broadcastStub, ); VideoConferenceModelMock.findOneById.callsFake(async () => cloneFixture(fixture)); + providerCapabilities.current = undefined; }); afterEach(() => { clock.restore(); + providerCapabilities.current = undefined; }); // The reported bug: leaving the last-standing spot in a call must end it and leave every member a @@ -168,6 +189,30 @@ describe('VideoConfService.leaveCall', () => { expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); }); + + // One member leaving is not the call ending — a reload fires a leave too — so the room at large must not + // hear 'end': that would dismiss everyone else's ringing popup and silence the caller's outgoing ring while + // the call still runs. Only the leaver's own devices are told, so their other windows drop the call UI. + it('tells only the leaver about their own leave, never the room, for an embedded provider', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'leaver' })]); + + await service.leaveCall('leaver', 'call1'); + + expect(endNotifiedUserIds()).to.deep.equal(['leaver']); + }); + + // The room-wide 'end' belongs to the call actually ending: once the grace period confirms the call emptied, + // everyone still holding a popup for it is told. + it('tells the room once the call actually ends', async () => { + providerCapabilities.current = { embedded: true }; + fixture = buildGroupCall([buildMember({ _id: 'creator' })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(endNotifiedUserIds()).to.include('other'); + }); }); describe('VideoConfService one call at a time', () => { From 069c761caab2122545d2683be94307bb9ce5be36 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:58:47 -0300 Subject: [PATCH 10/31] fix(video-conf): share canAccessConference with the conference stream gate The stream's allowRead re-implemented conference access as membership OR canReadRoom, which is stricter than the canAccessConference rule the REST endpoints apply (membership OR room access on rid/discussionRid). Reuse the shared helper so the stream and the endpoints cannot drift into different answers for the same person. Co-Authored-By: Claude Fable 5 --- .../notifications/notifications.module.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 54277ab4c6f76..b28dd332f9860 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -6,6 +6,7 @@ import { Rooms, Subscriptions, Users, VideoConference } from '@rocket.chat/model import type { ImporterProgress } from '../../lib/import/classes/ImporterProgress'; import { SystemLogger } from '../../lib/logger/system'; import { emit, StreamPresence } from '../../lib/notifications/core/lib/Presence'; +import { canAccessConference } from '../../lib/videoConfAccess'; import { getCachedUserForPublication } from '../streamer/publication-user-cache'; import { Streamer as StreamerModule } from '../streamer/streamer.module'; import type { IStreamer, IStreamerConstructor } from '../streamer/types'; @@ -464,9 +465,10 @@ export class NotificationsModule { this.streamVideoConference.allowWrite('none'); // Conference membership authorizes following the call — members may have no access to the room it - // originated in — and so does access to the room the chat lives in. That is the same pair - // `video-conference.info` accepts, and both halves are needed: membership alone refuses a room member - // who opens the conference before their join lands, and a refused subscription is never retried. + // originated in — and so does access to a room the chat lives in. `canAccessConference` is the same rule + // the REST endpoints apply, shared so the stream and the endpoints cannot drift into different answers + // for the same person: membership alone would refuse a room member who opens the conference before their + // join lands, and a refused subscription is never retried. this.streamVideoConference.allowRead(async function (eventName) { const user = await getCachedUserForPublication(this); if (!user) { @@ -479,14 +481,7 @@ export class NotificationsModule { return false; } - if (call.users.some(({ _id }) => _id === user._id)) { - return true; - } - - const chatRids = [call.rid, call.discussionRid].filter((rid): rid is string => !!rid); - const rooms = await Rooms.findByIds(chatRids).toArray(); - - return (await Promise.all(rooms.map((room) => Authorization.canReadRoom(room, user)))).some(Boolean); + return canAccessConference(call, user._id); }); this.streamLocal.serverOnly = true; From fdd32e1e6ace6af2632775cecc0945f14742d171 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:58:47 -0300 Subject: [PATCH 11/31] chore(video-conf): drop the unused CORE_PROVIDER_APP_ID export Nothing consumes it in this branch or in the follow-up client work; the future LiveKit PR can introduce it together with its consumer. Co-Authored-By: Claude Fable 5 --- apps/meteor/server/lib/videoConfProviders.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/meteor/server/lib/videoConfProviders.ts b/apps/meteor/server/lib/videoConfProviders.ts index df09fd27c8c77..4c753c15fe2bf 100644 --- a/apps/meteor/server/lib/videoConfProviders.ts +++ b/apps/meteor/server/lib/videoConfProviders.ts @@ -2,12 +2,6 @@ import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings'; import { settings } from '../settings'; -// `appId === 'core'` marks a built-in provider (e.g. LiveKit) — registered -// directly from server bootstrap rather than via an apps-engine app. The -// only behavioural impact is when callers look up the owning app to dispatch -// provider hooks; built-ins have no app to dispatch to. -export const CORE_PROVIDER_APP_ID = 'core'; - const providers = new Map(); export const videoConfProviders = { From 8c87fab3b3e238f71ee4f591825884d2a5316968 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 11:58:47 -0300 Subject: [PATCH 12/31] docs(rest-typings): say which identifier the video-conference users fields carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit video-conference.addParticipants takes usernames (adding can invite people into the room, and that machinery speaks usernames) while video-conference.ring takes user ids (it targets existing conference members, which are tracked by id). Both endpoints answer with user ids. Deliberate, but undocumented — spell it out on the types and in the schemas' descriptions rather than renaming fields clients already send. Co-Authored-By: Claude Fable 5 --- .../v1/videoConference/VideoConfAddParticipantsProps.ts | 7 +++++++ .../src/v1/videoConference/VideoConfRingProps.ts | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts index 49d1f0871f6fc..b5ed455bcd0b3 100644 --- a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts +++ b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts @@ -5,6 +5,12 @@ import { ajv } from '../Ajv'; export type VideoConfAddParticipantsProps = { callId: string; + /** + * The *usernames* of the people to add — not user ids. Adding someone can end with them invited into the + * room the chat lives in, and that machinery speaks usernames; `video-conference.ring` speaks ids instead, + * because it targets people who are already conference members. The endpoint answers with the user *ids* + * of the members it actually added. + */ users: string[]; /** * Whether to ring the people being added. Defaults to ringing: someone added to a call in progress is being @@ -22,6 +28,7 @@ const videoConfAddParticipantsPropsSchema: JSONSchemaType = { }, users: { type: 'array', + description: 'User ids of the members to ring — not usernames. The endpoint returns the user ids it actually rang.', items: { type: 'string' }, minItems: 1, nullable: true, From 86cf00d09904aa2187ad14d3f37eb85e687e2454 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 16:19:13 -0300 Subject: [PATCH 13/31] fix(video-conf): enforce the ring permission on the new endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit video-conference.start only rings when the caller holds videoconf-ring-users, but the new ring and add-participants endpoints let any conference-accessible user ring people — a way around the permission. - video-conference.ring now requires videoconf-ring-users and answers 403 (API.v1.forbidden, declared in the response schema) without it, the same shape start uses for its permission refusals. - video-conference.add-participants keeps adding open to anyone with access to the conference, but the accompanying ring degrades silently without the permission — mirroring start's allowRinging behavior. - The rename and share-chat endpoints map the service's error-not-allowed (non-creator rename; refused share mode; disallowed discussion) to a declared 403 instead of letting it surface as an internal error. Only that known refusal is caught — anything else still propagates. - video-conference.ring's users list is capped at VIDEO_CONF_RINGING_LIMIT in the schema: a named list beyond what a ring may reach is a malformed request, not a set to be trimmed. (The no-list "ring everyone absent" form keeps its silent server-side cap by design.) Also fixes the doc comment naming the add-participants endpoint. There is no unit-level harness for these typed endpoints, so the permission gates are covered by the schema/service specs around them and asserted here by review; the schema cap was verified against the ajv validator directly. Co-Authored-By: Claude Fable 5 --- apps/meteor/server/api/v1/videoConference.ts | 40 ++++++++++++++++--- .../v1/videoConference/VideoConfRingProps.ts | 6 ++- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index 841bb1936aa24..337c5dcfe45bd 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -422,11 +422,18 @@ API.v1.post( 200: ringResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, async function action() { const { callId, users } = this.bodyParams; + // The same permission `video-conference.start` demands before ringing anyone — having access to a + // conference must not be a way around it. + if (!(await hasPermissionAsync(this.userId, 'videoconf-ring-users'))) { + return API.v1.forbidden('Not allowed'); + } + const conference = await loadAccessibleConference(callId, this.userId); if (!conference) { return API.v1.failure('invalid-params'); @@ -460,7 +467,11 @@ API.v1.post( // Registers the users as conference members — it deliberately does not put them in any room. Being a // member authorizes joining the call; whether they can read the chat is surfaced separately. - const added = await VideoConf.addMembers(conference.userId, callId, users, { ring: ring ?? true }); + // Adding is open to anyone with access to the conference; the ring that usually accompanies it needs the + // same permission `video-conference.start` demands, and degrades silently without it — same as `start`. + const added = await VideoConf.addMembers(conference.userId, callId, users, { + ring: (ring ?? true) && (await hasPermissionAsync(this.userId, 'videoconf-ring-users')), + }); return API.v1.success({ added }); }, @@ -476,6 +487,7 @@ API.v1.post( 200: cancelResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, async function action() { @@ -487,8 +499,16 @@ API.v1.post( } // Whether this particular user may *name* the call is the service's call to make — access is only the - // question of whether they may be here at all. - await VideoConf.renameCall(conference.userId, callId, title); + // question of whether they may be here at all. Its refusal is an authorization answer, not a failure, + // so it maps to 403 rather than surfacing as an internal error. + try { + await VideoConf.renameCall(conference.userId, callId, title); + } catch (e) { + if (e instanceof Error && e.message === 'error-not-allowed') { + return API.v1.forbidden('Not allowed'); + } + throw e; + } return API.v1.success(); }, @@ -504,6 +524,7 @@ API.v1.post( 200: shareChatResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, async function action() { @@ -514,9 +535,16 @@ API.v1.post( return API.v1.failure('invalid-params'); } - const rid = await VideoConf.shareChatWithMembers(conference.userId, callId, mode); - - return API.v1.success({ rid }); + // The service refuses a mode the room can't do, and discussion creation the caller isn't permitted — + // authorization answers, not failures, so they map to 403 rather than surfacing as internal errors. + try { + return API.v1.success({ rid: await VideoConf.shareChatWithMembers(conference.userId, callId, mode) }); + } catch (e) { + if (e instanceof Error && e.message === 'error-not-allowed') { + return API.v1.forbidden('Not allowed'); + } + throw e; + } }, ); diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts index ea523ea3a3f11..ff8754d205af3 100644 --- a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts @@ -1,3 +1,4 @@ +import { VIDEO_CONF_RINGING_LIMIT } from '@rocket.chat/core-typings'; import type { JSONSchemaType } from 'ajv'; import { ajv } from '../Ajv'; @@ -6,7 +7,7 @@ export type VideoConfRingProps = { callId: string; /** * Ring only these members, by user *id* — not username. Ringing targets people who are already conference - * members, and members are tracked by id; `video-conference.addParticipants` speaks usernames instead, + * members, and members are tracked by id; `video-conference.add-participants` speaks usernames instead, * because it may also invite people into a room. Omitted, everyone who isn't in the call is rung. The * endpoint answers with the user ids it actually rang. */ @@ -25,6 +26,9 @@ const videoConfRingPropsSchema: JSONSchemaType = { description: 'User ids of the members to ring — not usernames. The endpoint returns the user ids it actually rang.', items: { type: 'string' }, minItems: 1, + // A named list may never exceed what a ring is allowed to reach; asking for more is a malformed + // request, not a set to be trimmed. + maxItems: VIDEO_CONF_RINGING_LIMIT, nullable: true, }, }, From 89f40c96fba9806e9ae99ba2b92c4cfbc58100f2 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 16:19:25 -0300 Subject: [PATCH 14/31] fix(video-conf): presence and ringing correctness from the re-review - listJoinableCalls and leaveOtherCalls now name the exact statuses the partial index is filtered on, which is what makes the planner able to use it; the endedAt predicate stays, because it is the actual liveness rule and the semantics must not hang on an index filter. - ringMembers filters through the shared canRingConferenceMember, so a member whose phone is still inside its ringing window is not re-rung; once the window passes, ring-again works exactly as before. - renewPresence detects when a heartbeat revives an inferred departure (the entry carried an inferred leftReason before the renewal cleared it) and undoes what the eviction did: re-claims busy (embedded only, as on join) and announces the roster to watchers. Ordinary renewals stay free of extra writes and notifications. - The presence sweep's startup grace is measured with performance.now() instead of the wall clock, so an NTP correction or manual clock change can neither age the process past the grace period in an instant nor hold it under it forever. - findActiveEmbeddedInRoom is removed from the model and its interface: no callers on this branch or the follow-up client branch; the partial- index comment now names only what actually uses it. Co-Authored-By: Claude Fable 5 --- apps/meteor/server/cron/videoConferences.ts | 7 ++- .../services/video-conference/service.ts | 40 +++++++++++-- .../video-conference/busyStatus.spec.ts | 56 ++++++++++++++++++- .../video-conference/leaveCall.spec.ts | 2 + .../services/video-conference/ringing.spec.ts | 34 ++++++++++- .../src/models/IVideoConferenceModel.ts | 2 - packages/models/src/models/VideoConference.ts | 13 +---- 7 files changed, 133 insertions(+), 21 deletions(-) diff --git a/apps/meteor/server/cron/videoConferences.ts b/apps/meteor/server/cron/videoConferences.ts index abb5be88ecf0e..021176df3ae00 100644 --- a/apps/meteor/server/cron/videoConferences.ts +++ b/apps/meteor/server/cron/videoConferences.ts @@ -47,6 +47,9 @@ export async function videoConferencesCron(): Promise { // // Not run here on the way past, unlike the expiry above: at startup the guard inside it would reject it // anyway, and that is exactly the point. - const registeredAt = Date.now(); - return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep(Date.now() - registeredAt)); + // + // Monotonic time, not the wall clock: an NTP correction or a manual clock change must not be able to age the + // process past the grace period in an instant — or hold it forever under it. + const registeredAt = performance.now(); + return cronJobs.add('VideoConferencePresence', '* * * * *', async () => runPresenceSweep(performance.now() - registeredAt)); } diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index bec4dbd7f0619..c3c03a4af9092 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -55,7 +55,7 @@ import { EMPTY_CALL_GRACE_MS, shouldRingVideoConference, } from '../../../lib/videoConference/constants'; -import { isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; +import { canRingConferenceMember, isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom'; @@ -1340,7 +1340,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * same person a second time won't do it, since they are already a member. Returns who was rung. * * Members who already left are rung too: they were there and are not now, which is exactly the case - * "call them back" is for. Anyone already in the call is never rung, whether or not they were asked for. + * "call them back" is for. Anyone already in the call is never rung, whether or not they were asked for — + * and neither is anyone whose phone is ringing right now: there is nothing more to ask of them. */ public async ringMembers(uid: IUser['_id'], callId: VideoConference['_id'], userIds?: IUser['_id'][]): Promise { const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); @@ -1354,7 +1355,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf const requested = userIds?.length ? new Set(userIds) : undefined; const absent = call.users - .filter((member) => member._id !== uid && !isInVideoConference(member) && (!requested || requested.has(member._id))) + .filter((member) => member._id !== uid && canRingConferenceMember(member) && (!requested || requested.has(member._id))) .map(({ _id }) => _id); if (!shouldRingVideoConference(absent.length)) { @@ -1388,9 +1389,13 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf /** Leaves every other call this user is still counted as being in. See `addUserToCall`. */ private async leaveOtherCalls(callId: VideoConference['_id'], uid: IUser['_id']): Promise { // Asking the database for "still in it" rather than reading every membership and sifting in memory. + // The status predicate names the exact statuses the partial index is filtered on, which is what lets the + // planner use it; `endedAt` stays because it is the actual liveness rule (everything that ends a call sets + // both), so the semantics don't hang on the index's filter. const others = await VideoConferenceModel.find( { _id: { $ne: callId }, + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, endedAt: { $exists: false }, users: { $elemMatch: { _id: uid, joined: { $ne: false }, leftAt: { $exists: false } } }, }, @@ -1419,8 +1424,10 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * so without this an abandoned one would be advertised as joinable for a day. */ public async listJoinableCalls(uid: IUser['_id']): Promise { + // The status predicate matches the partial index's filter so the scan can be served by it; `endedAt` is + // still the liveness rule itself. const running = await VideoConferenceModel.find( - { endedAt: { $exists: false } }, + { status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, endedAt: { $exists: false } }, // `createdBy` is here because naming a direct call needs it — a call is named after a person, and for a // member with no subscription that person is whoever started it. { projection: { rid: 1, discussionRid: 1, users: 1, title: 1, type: 1, createdAt: 1, createdBy: 1 }, sort: { createdAt: -1 } }, @@ -1623,9 +1630,34 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * Provider-agnostic by construction — the conference window is ours whoever runs the media, so this is the one * presence signal that exists for every provider. See `lib/videoConference/presence` for why presence is a * lease rather than a reported departure. + * + * A renewal can also *revive* an inferred departure — the sweep gave up on this window while it was in fact + * alive, and this heartbeat is the correction. The revival has to undo what the eviction did: the sweep + * released their busy claim and told every watcher the roster shrank, so coming back re-claims busy (embedded + * only, exactly as joining does) and announces the roster again. An ordinary renewal changes nothing anyone + * can see, so it stays free of extra writes and notifications. */ public async renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise { + // Whether this renewal is a revival is decided by what the entry says *before* the renewal clears it: an + // inferred departure is exactly what `renewUserPresenceById` is allowed to undo. A read rather than asking + // the write to report back, because the write's matched/modified counts can't tell the two cases apart. + const call = await VideoConferenceModel.findOneById>(callId, { + projection: { rid: 1, users: 1, providerName: 1 }, + }); + const member = call?.users.find(({ _id }) => _id === uid); + const reviving = !!member?.leftAt && !!member.leftReason && INFERRED_LEAVE_REASONS.includes(member.leftReason); + await VideoConferenceModel.renewUserPresenceById(callId, uid, new Date(), INFERRED_LEAVE_REASONS); + + if (!call || !reviving) { + return; + } + + if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + await this.claimBusyForCall(uid); + } + this.notifyConferenceUpdate(call._id); + this.notifyVideoConfUpdate(call.rid, call._id); } /** diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index 7fa406ec9090d..1b73650dfd05d 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -34,6 +34,7 @@ const VideoConferenceModelMock = { (member as IVideoConferenceUser).leftAt = leftAt; } }), + renewUserPresenceById: sinon.stub().resolves(), renewUsersPresenceById: sinon.stub().resolves(), markEmbeddedParticipantLeft: sinon.stub().resolves(), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { @@ -45,10 +46,12 @@ const VideoConferenceModelMock = { const UsersMock = { findOneById: sinon.stub().resolves({ _id: 'joiner', language: 'en' }) }; +const broadcastStub = sinon.stub().resolves(); + const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server/services/video-conference/service', { ...commonServiceStubs, '@rocket.chat/core-services': { - api: { broadcast: sinon.stub().resolves() }, + api: { broadcast: broadcastStub }, ServiceClassInternal: class { onEvent() { /* no-op */ @@ -83,6 +86,8 @@ describe('VideoConfService presence while in a call', () => { PresenceMock.endActiveState, VideoConferenceModelMock.setUserLeftById, VideoConferenceModelMock.setUserJoinedById, + VideoConferenceModelMock.renewUserPresenceById, + broadcastStub, ); PresenceMock.setActiveState.resolves(true); // The busy claim only exists for embedded providers — they are the ones with a leave/sweep to release it. @@ -153,6 +158,55 @@ describe('VideoConfService presence while in a call', () => { expect(VideoConferenceModelMock.setUserJoinedById.calledWith('call1', 'joiner')).to.be.true; }); + // The sweep can be wrong: it gave up on a window that was only throttled, released the busy claim and told + // everyone the roster shrank. The heartbeat that revives the member is the correction, so it has to undo + // both — an ordinary renewal, by contrast, changes nothing anyone can see and must stay silent. + describe('when a heartbeat revives an inferred departure', () => { + const conferenceUpdates = (): number => broadcastStub.args.filter((args: unknown[]) => args[0] === 'video-conference.updated').length; + + it('re-claims busy for the revived member', async () => { + fixture = buildGroupCall([buildMember({ _id: 'reviver', leftAt: at(-60_000), leftReason: 'timeout' })]); + + await service.renewPresence('reviver', 'call1'); + + expect(VideoConferenceModelMock.renewUserPresenceById.calledOnce).to.be.true; + expect(PresenceMock.setActiveState.calledWith('reviver')).to.be.true; + expect(conferenceUpdates()).to.be.greaterThan(0); + }); + + it('changes nothing visible on an ordinary renewal', async () => { + fixture = buildGroupCall([buildMember({ _id: 'present', lastSeenAt: at(0) })]); + + await service.renewPresence('present', 'call1'); + + expect(VideoConferenceModelMock.renewUserPresenceById.calledOnce).to.be.true; + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + }); + + // A reported departure is the member's own word, and a stale heartbeat behind it must not carry any of + // the revival's side effects — the model refuses the revival itself for the same reason. + it('treats a renewal behind a reported leave as ordinary', async () => { + fixture = buildGroupCall([buildMember({ _id: 'choseToLeave', leftAt: at(-60_000) })]); + + await service.renewPresence('choseToLeave', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + }); + + // The busy claim is the embedded lifecycle's; the roster correction is everyone's. + it('announces the revival without claiming busy for a non-embedded provider', async () => { + providerCapabilities.current = undefined; + fixture = buildGroupCall([buildMember({ _id: 'reviver', leftAt: at(-60_000), leftReason: 'timeout' })]); + + await service.renewPresence('reviver', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.be.greaterThan(0); + }); + }); + // A non-embedded provider (Jitsi, Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever // release the claim, so a single Jitsi call would leave the user stuck on Busy forever. describe('for a non-embedded provider', () => { diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index f9a680c635bce..3a03c7edb9bec 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -280,6 +280,8 @@ describe('VideoConfService one call at a time', () => { const [query] = VideoConferenceModelMock.find.firstCall.args; expect(query).to.deep.equal({ _id: { $ne: 'wanted' }, + // The statuses name what the partial index is filtered on; `endedAt` remains the liveness rule itself. + status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, endedAt: { $exists: false }, users: { $elemMatch: { _id: 'joiner', joined: { $ne: false }, leftAt: { $exists: false } } }, }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts index ed3b705693659..bb4e21a7d5ffa 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -1,5 +1,5 @@ import type { IDirectVideoConference, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings'; -import { isInVideoConference } from '@rocket.chat/core-typings'; +import { isInVideoConference, VIDEO_CONF_RINGING_WINDOW_MS } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { beforeEach, describe, it } from 'mocha'; import sinon from 'sinon'; @@ -161,6 +161,38 @@ describe('VideoConfService.ringMembers', () => { expect(ringedUserIds(broadcastStub)).to.not.include('caller'); }); + // A phone that is ringing right now has nothing more to ask of it — re-ringing would just restart the + // sound under the callee's finger. The window is what separates "ringing" from "was rung and ignored". + it('does not ring a member whose phone is still ringing from the last attempt', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ _id: 'stillRinging', joined: false, joinedAt: undefined, ringingAt: new Date() }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal([]); + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); + }); + + // Once the window has passed, an unanswered ring is exactly what "ring again" exists for. + it('rings a member whose previous ring already went unanswered', async () => { + fixture = buildGroupCall([ + buildMember({ _id: 'caller' }), + buildMember({ + _id: 'ignoredIt', + joined: false, + joinedAt: undefined, + ringingAt: new Date(Date.now() - VIDEO_CONF_RINGING_WINDOW_MS - 1_000), + }), + ]); + + const result = await service.ringMembers('caller', 'call1'); + + expect(result).to.deep.equal(['ignoredIt']); + expect(ringedUserIds(broadcastStub)).to.deep.equal(['ignoredIt']); + }); + // Nobody absent means nothing to do — this is also what a call with a full house looks like after // everyone's already answered. it('returns an empty array when nobody is absent', async () => { diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index e6ab3bfd1a8f7..184fa65ae83ef 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -102,8 +102,6 @@ export interface IVideoConferenceModel extends IBaseModel { // that URL-based providers don't need. URL providers (Jitsi/Meet/Zoom) // never call these. - findActiveEmbeddedInRoom(rid: IRoom['_id'], providerName: string): Promise; - addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise; markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt?: Date): Promise; diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index 77b6e4ddcf694..9a7a86d7203ad 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -36,7 +36,8 @@ export class VideoConferenceRaw extends BaseRaw implements IVid // `createdAt` is part of the key so the `$or: [{ rid }, { discussionRid }]` listing below can be // served by an index-ordered merge instead of a blocking in-memory sort of the whole room history. { key: { discussionRid: 1, createdAt: 1 }, unique: false }, - // Listing the calls that are running (`findActiveWithMembers`, `findActiveEmbeddedInRoom`): a partial + // Listing the calls that are running (`findActiveWithMembers` and the service's own scans over open + // calls): a partial // index, so it holds just the handful of conferences that are live. The hot queries match on these // exact statuses, which is what makes the index eligible for them; `endedAt: { $exists: false }` alone // could not anchor an index at all. `$in` in a partialFilterExpression needs MongoDB 6.0, and the @@ -444,16 +445,6 @@ export class VideoConferenceRaw extends BaseRaw implements IVid // URL-based providers (Jitsi/Meet/Zoom) never call these. The data shape // is described in the IVideoConferenceParticipant type in core-typings. - public async findActiveEmbeddedInRoom(rid: IRoom['_id'], providerName: string): Promise { - // "active" means the call is open (not ENDED/EXPIRED/DECLINED). Embedded - // providers use the standard VideoConferenceStatus lifecycle. - return this.findOne({ - rid, - providerName, - status: { $in: [VideoConferenceStatus.CALLING, VideoConferenceStatus.STARTED] }, - }); - } - /** * Every call that is still open, with what the presence sweep needs to judge it: who is on the roster, and * which provider is running the media — the one that may be able to say who is in the room. From 8376e2bbff32eb18e349e824e7c773836dc58380 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 25 Aug 2026 16:39:22 -0300 Subject: [PATCH 15/31] fix(video-conf): decide presence revival atomically with the renewal write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revival detection read the member's entry and then wrote the renewal, and the gap between the two could lie twice over: - A leave reported between the read and the write meant the guarded update matched nothing — reported departures are not revivable by design — yet the service still claimed busy and broadcast a revival for someone gone. - Worse, revival ignored endedAt. When the last member's lease expiry is what emptied and ended the call, endCall's busy release is the final one there will ever be; a last throttled heartbeat then read leftAt + leftReason 'timeout', "revived" the member inside the ENDED conference, and claimed busy with no release path left — permanently BUSY, the exact failure class this work exists to prevent. The model now decides and the service reacts to what actually happened: renewUserPresenceById guards the query on `endedAt: { $exists: false }` (so a heartbeat against an ended call matches nothing at the database) and runs as a findOneAndUpdate returning the before-document, answering atomically with `null` on no match or `{ revived, rid, providerName }` — judged from the entry as it stood before the write, with what the caller needs to react without a second, racy read. renewPresence drops its pre-read entirely: no match or no revival means no side effects at all. renewUsersPresenceById (the sweep's bulk renewal) never revives anyone — it only stamps lastSeenAt for probe-confirmed members on calls the sweep just read as open — but it gains the same endedAt guard as belt and braces. Interface, model spec (new filter, before-document contract) and service specs updated; new cases pin the ended-call heartbeat and the reported-leave race doing nothing. Co-Authored-By: Claude Fable 5 --- .../services/video-conference/service.ts | 27 +++++----- .../video-conference/busyStatus.spec.ts | 42 +++++++++++++-- .../src/models/IVideoConferenceModel.ts | 8 ++- .../models/src/models/VideoConference.spec.ts | 51 ++++++++++++++----- packages/models/src/models/VideoConference.ts | 36 +++++++++++-- 5 files changed, 125 insertions(+), 39 deletions(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index c3c03a4af9092..ea5d25f68fe74 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1638,26 +1638,23 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * can see, so it stays free of extra writes and notifications. */ public async renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise { - // Whether this renewal is a revival is decided by what the entry says *before* the renewal clears it: an - // inferred departure is exactly what `renewUserPresenceById` is allowed to undo. A read rather than asking - // the write to report back, because the write's matched/modified counts can't tell the two cases apart. - const call = await VideoConferenceModel.findOneById>(callId, { - projection: { rid: 1, users: 1, providerName: 1 }, - }); - const member = call?.users.find(({ _id }) => _id === uid); - const reviving = !!member?.leftAt && !!member.leftReason && INFERRED_LEAVE_REASONS.includes(member.leftReason); - - await VideoConferenceModel.renewUserPresenceById(callId, uid, new Date(), INFERRED_LEAVE_REASONS); - - if (!call || !reviving) { + // Whether this renewal revived anything is the model's answer, decided in the same atomic step as the + // write itself — a separate read would race the member reporting a leave in between, and would happily + // call a heartbeat against an *ended* call a revival: the final throttled heartbeat of the very window + // whose expiry ended the call would then re-claim busy with no release path left to ever undo it. + const renewal = await VideoConferenceModel.renewUserPresenceById(callId, uid, new Date(), INFERRED_LEAVE_REASONS); + + // Nothing matched (the call ended, the member is unknown, or they reported leaving) or nothing was + // revived: nothing to undo, so no side effects at all. + if (!renewal?.revived) { return; } - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (videoConfProviders.getProviderCapabilities(renewal.providerName)?.embedded) { await this.claimBusyForCall(uid); } - this.notifyConferenceUpdate(call._id); - this.notifyVideoConfUpdate(call.rid, call._id); + this.notifyConferenceUpdate(callId); + this.notifyVideoConfUpdate(renewal.rid, callId); } /** diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index 1b73650dfd05d..2343610cb963b 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -34,7 +34,22 @@ const VideoConferenceModelMock = { (member as IVideoConferenceUser).leftAt = leftAt; } }), - renewUserPresenceById: sinon.stub().resolves(), + // Mirrors the model's atomic contract: no match (ended call, unknown member, reported leave) answers `null`; + // a match renews the lease and says whether it revived an inferred departure — all in one step. + renewUserPresenceById: sinon.stub().callsFake(async (_callId: string, uid: string) => { + if (fixture.endedAt) { + return null; + } + const member = fixture.users.find((user) => user._id === uid); + if (!member || (member.leftAt && member.leftReason !== 'timeout')) { + return null; + } + const revived = !!member.leftAt && member.leftReason === 'timeout'; + delete member.leftAt; + delete member.leftReason; + member.lastSeenAt = new Date(); + return { revived, rid: fixture.rid, providerName: fixture.providerName }; + }), renewUsersPresenceById: sinon.stub().resolves(), markEmbeddedParticipantLeft: sinon.stub().resolves(), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { @@ -184,15 +199,34 @@ describe('VideoConfService presence while in a call', () => { expect(conferenceUpdates()).to.equal(0); }); - // A reported departure is the member's own word, and a stale heartbeat behind it must not carry any of - // the revival's side effects — the model refuses the revival itself for the same reason. - it('treats a renewal behind a reported leave as ordinary', async () => { + // A reported departure is the member's own word, and a stale heartbeat racing in behind it must not carry + // any of the revival's side effects. The model's answer is atomic with the write, so even a leave reported + // between the heartbeat arriving and the write landing answers "no match" — never a revival. + it('does nothing when the departure was reported, however the heartbeat races it', async () => { fixture = buildGroupCall([buildMember({ _id: 'choseToLeave', leftAt: at(-60_000) })]); await service.renewPresence('choseToLeave', 'call1'); expect(PresenceMock.setActiveState.called).to.be.false; expect(conferenceUpdates()).to.equal(0); + const member = fixture.users.find((user) => user._id === 'choseToLeave'); + expect(member?.leftAt, 'a reported departure must never be cleared by a heartbeat').to.not.be.undefined; + }); + + // The worst shape of a late heartbeat: the lease expiry that evicted this member is what emptied and ended + // the call, and the busy release that came with ending it was the last one there would ever be. Reviving + // them now would regenerate a member inside an ENDED conference and claim busy with no release path left. + it('does nothing at all for a heartbeat against a call that already ended', async () => { + fixture = buildGroupCall([buildMember({ _id: 'tooLate', leftAt: at(-60_000), leftReason: 'timeout' })], { + endedAt: at(-30_000), + }); + + await service.renewPresence('tooLate', 'call1'); + + expect(PresenceMock.setActiveState.called).to.be.false; + expect(conferenceUpdates()).to.equal(0); + const member = fixture.users.find((user) => user._id === 'tooLate'); + expect(member?.leftAt, 'the departure must stand — the call is over').to.not.be.undefined; }); // The busy claim is the embedded lifecycle's; the roster correction is everyone's. diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 184fa65ae83ef..281a33ad379a8 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -69,13 +69,17 @@ export interface IVideoConferenceModel extends IBaseModel { setUserLeftById(callId: string, uid: IUser['_id'], leftAt?: Date, reason?: VideoConferenceLeaveReason): Promise; setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt?: Date): Promise; - /** Renews one member's presence lease, and with it any departure that was inferred rather than reported. */ + /** + * Renews one member's presence lease, and with it any departure that was inferred rather than reported. + * Answers atomically with what the write found: `null` when nothing matched (ended call, unknown member, + * reported leave), otherwise whether an inferred departure was revived, with the call's room and provider. + */ renewUserPresenceById( callId: string, uid: IUser['_id'], lastSeenAt?: Date, inferredReasons?: VideoConferenceLeaveReason[], - ): Promise; + ): Promise<{ revived: boolean; rid: IRoom['_id']; providerName: string } | null>; /** Renews several leases at once, as a provider reporting who is in its room does. */ renewUsersPresenceById(callId: string, uids: IUser['_id'][], lastSeenAt?: Date): Promise; diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts index c0be3373fc44c..198c34aad9622 100644 --- a/packages/models/src/models/VideoConference.spec.ts +++ b/packages/models/src/models/VideoConference.spec.ts @@ -21,10 +21,12 @@ const member = { _id: 'user-1', username: 'user.one', name: 'User One', avatarET */ const setupModel = () => { const updateOne = jest.fn().mockResolvedValue({}); + const findOneAndUpdate = jest.fn().mockResolvedValue(null); const model = new VideoConferenceRaw({ collection: () => ({}) } as never); Object.defineProperty(model, 'updateOne', { value: updateOne }); + Object.defineProperty(model, 'findOneAndUpdate', { value: findOneAndUpdate }); - return { model, updateOne }; + return { model, updateOne, findOneAndUpdate }; }; describe('VideoConferenceRaw.addMemberById', () => { @@ -98,40 +100,62 @@ describe('VideoConferenceRaw.setUserJoinedById', () => { }); describe('VideoConferenceRaw.renewUserPresenceById', () => { - it('should stamp the lease on the matching entry via arrayFilters', async () => { - const { model, updateOne } = setupModel(); + it('should stamp the lease on the matching entry via arrayFilters, reading the entry as it stood before', async () => { + const { model, findOneAndUpdate } = setupModel(); const lastSeenAt = new Date('2026-08-01T10:00:00Z'); await model.renewUserPresenceById('call-1', 'user-1', lastSeenAt); - const [, update, options] = updateOne.mock.calls[0]; + const [, update, options] = findOneAndUpdate.mock.calls[0]; expect(update.$set).toEqual({ 'users.$[user].lastSeenAt': lastSeenAt }); - expect(options).toEqual({ arrayFilters: [{ 'user._id': 'user-1' }] }); + expect(options).toMatchObject({ arrayFilters: [{ 'user._id': 'user-1' }], returnDocument: 'before' }); }); // A lease we gave up on while the window was in fact alive was simply wrong, and the window still talking to // us is the correction — otherwise a member evicted during an outage would stay evicted for the whole call. it('should undo a departure that was only inferred', async () => { - const { model, updateOne } = setupModel(); + const { model, findOneAndUpdate } = setupModel(); await model.renewUserPresenceById('call-1', 'user-1'); - expect(updateOne.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }); + expect(findOneAndUpdate.mock.calls[0][1].$unset).toEqual({ 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }); }); - // The guard has to be in the query, because that is the only part of an update that can be conditional: a - // heartbeat still in flight behind someone who chose to leave must not put them back in the call. - it('should refuse to revive a member who reported leaving, in the query', async () => { - const { model, updateOne } = setupModel(); + // The guards have to be in the query, because that is the only part of an update that can be conditional: a + // heartbeat still in flight behind someone who chose to leave must not put them back in the call, and the + // final heartbeat of a window whose lease expiry ended the call must not regenerate a member inside an ENDED + // conference. + it('should refuse a reported leave and an ended call, in the query', async () => { + const { model, findOneAndUpdate } = setupModel(); await model.renewUserPresenceById('call-1', 'user-1', new Date(), ['timeout']); - const [query] = updateOne.mock.calls[0]; + const [query] = findOneAndUpdate.mock.calls[0]; expect(query).toEqual({ _id: 'call-1', + endedAt: { $exists: false }, users: { $elemMatch: { _id: 'user-1', $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: ['timeout'] } }] } }, }); }); + + // The answer is decided in the same atomic step as the write: what the entry said *before* the renewal + // cleared it is the only evidence of whether anything was revived. + it('should report a revival from the before-document, and null when nothing matched', async () => { + const { model, findOneAndUpdate } = setupModel(); + + findOneAndUpdate.mockResolvedValueOnce({ + rid: 'room-1', + providerName: 'test', + users: [{ _id: 'user-1', leftAt: new Date('2026-08-01T10:00:00Z'), leftReason: 'timeout' }], + }); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toEqual({ revived: true, rid: 'room-1', providerName: 'test' }); + + findOneAndUpdate.mockResolvedValueOnce({ rid: 'room-1', providerName: 'test', users: [{ _id: 'user-1' }] }); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toEqual({ revived: false, rid: 'room-1', providerName: 'test' }); + + findOneAndUpdate.mockResolvedValueOnce(null); + expect(await model.renewUserPresenceById('call-1', 'user-1')).toBeNull(); + }); }); describe('VideoConferenceRaw.renewUsersPresenceById', () => { @@ -141,7 +165,8 @@ describe('VideoConferenceRaw.renewUsersPresenceById', () => { await model.renewUsersPresenceById('call-1', ['user-1', 'user-2'], lastSeenAt); - const [, update, options] = updateOne.mock.calls[0]; + const [query, update, options] = updateOne.mock.calls[0]; + expect(query).toEqual({ _id: 'call-1', endedAt: { $exists: false } }); expect(update).toEqual({ $set: { 'users.$[user].lastSeenAt': lastSeenAt } }); expect(options).toEqual({ arrayFilters: [{ 'user._id': { $in: ['user-1', 'user-2'] } }] }); }); diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index 9a7a86d7203ad..ba9bd4c0d5f63 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -286,25 +286,48 @@ export class VideoConferenceRaw extends BaseRaw implements IVid * A renewal also undoes a departure that was *inferred*: a lease we gave up on while the window was in fact * alive was simply wrong, and the window saying so is the correction. A departure the member reported is * never undone this way — they left, and a heartbeat still in flight behind them must not put them back in - * the call. That is the condition in the query, which is why a stale renewal matches nothing at all. + * the call. Neither is anything undone on a call that has ended: the final heartbeat of a window whose lease + * expiry emptied the call would otherwise regenerate a member inside an ENDED conference. Both conditions + * live in the query, which is why a stale renewal matches nothing at all. + * + * Answers with what the write found, decided in the same atomic step as the write itself: `null` when nothing + * matched (the call ended, the member is unknown, or their departure was reported), and otherwise whether this + * renewal *revived* an inferred departure — judged from the entry as it stood before the write, along with the + * call's room and provider so the caller can react without a second, racy read. */ public async renewUserPresenceById( callId: string, uid: IUser['_id'], lastSeenAt = new Date(), inferredReasons: VideoConferenceLeaveReason[] = ['timeout'], - ): Promise { - await this.updateOne( + ): Promise<{ revived: boolean; rid: IRoom['_id']; providerName: string } | null> { + const before = await this.findOneAndUpdate( { _id: callId, + endedAt: { $exists: false }, users: { $elemMatch: { _id: uid, $or: [{ leftAt: { $exists: false } }, { leftReason: { $in: inferredReasons } }] } }, }, { $set: { 'users.$[user].lastSeenAt': lastSeenAt }, $unset: { 'users.$[user].leftAt': 1, 'users.$[user].leftReason': 1 }, }, - { arrayFilters: [{ 'user._id': uid }] }, + { + arrayFilters: [{ 'user._id': uid }], + returnDocument: 'before', + projection: { users: 1, rid: 1, providerName: 1 }, + }, ); + + if (!before) { + return null; + } + + const member = before.users.find(({ _id }) => _id === uid); + return { + revived: !!member?.leftAt && !!member.leftReason && inferredReasons.includes(member.leftReason), + rid: before.rid, + providerName: before.providerName, + }; } /** @@ -317,8 +340,11 @@ export class VideoConferenceRaw extends BaseRaw implements IVid return; } + // Guarded against ended calls for the same reason the single renewal is — though this one only stamps + // `lastSeenAt` and never revives anyone, so the guard is belt rather than braces: the sweep calls it for + // calls it just read as open. await this.updateOne( - { _id: callId }, + { _id: callId, endedAt: { $exists: false } }, { $set: { 'users.$[user].lastSeenAt': lastSeenAt } }, { arrayFilters: [{ 'user._id': { $in: uids } }] }, ); From ffa58bbf109a3a7f07bb32f686f751bbdbdd8ee9 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 13:47:22 -0300 Subject: [PATCH 16/31] test(video-conf): keep the ringing e2e as develop has it The rewritten version asserts the caller's own call window opening on the click, and the outgoing 'Calling user2' popup being gone with it. Both are client behaviour that this branch does not ship, so on its own the spec waited for a window that never opens while dropping an assertion that still holds here. It belongs with the client change that earns it. Co-Authored-By: Claude Fable 5 --- apps/meteor/tests/e2e/video-conference-ring.spec.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/meteor/tests/e2e/video-conference-ring.spec.ts b/apps/meteor/tests/e2e/video-conference-ring.spec.ts index fbf05b1d4434f..1901c592abd5e 100644 --- a/apps/meteor/tests/e2e/video-conference-ring.spec.ts +++ b/apps/meteor/tests/e2e/video-conference-ring.spec.ts @@ -29,25 +29,18 @@ test.describe('video conference ringing', () => { await auxContext.page.close(); }); - test('should display call ringing in direct message', async ({ page }) => { + test('should display call ringing in direct message', async () => { await poHomeChannel.navbar.openChat('user2'); await auxContext.poHomeChannel.navbar.openChat('user1'); await test.step('should user1 calls user2', async () => { - // The caller's own window opens on the click that asked for it, which is what gives `window.open` the user - // activation browsers are entitled to demand. So the caller is in the call from that moment and the room - // is no longer "calling": what used to be a "Calling user2" popup in the room is that window now. - const callWindow = page.context().waitForEvent('page'); - await poHomeChannel.content.btnVideoCall.click(); await poHomeChannel.content.btnStartVideoCall.click(); - // Ringing runs from the caller's room page, not from that window, so the callee is rung either way. + await expect(poHomeChannel.content.getVideoConfPopup('Calling user2')).toBeVisible(); await expect(auxContext.poHomeChannel.content.getVideoConfPopup('Incoming call from user1')).toBeVisible(); - await auxContext.poHomeChannel.content.btnDeclineVideoCall.click(); - // Closing it is the caller giving up, which is what leaves the call behind them for the step below. - await (await callWindow).close(); + await auxContext.poHomeChannel.content.btnDeclineVideoCall.click(); }); await test.step('should user1 be able to call user2 again ', async () => { From f021b56411a9a6af3f5d915081b3dc5867817e39 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 14:52:39 -0300 Subject: [PATCH 17/31] fix(i18n): add the conference invite notification string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `notifyUsersAboutConference` translates this key, which existed nowhere — the desktop notification would have shown the key itself. Co-Authored-By: Claude Fable 5 --- packages/i18n/src/locales/en.i18n.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2ef6b03d8b6fa..6c647c874418b 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7464,6 +7464,7 @@ "You_should_name_it_to_easily_manage_your_integrations": "You should name it to easily manage your integrations.", "You_unfollowed_this_message": "You unfollowed this message.", "You_users_and_more_Reacted_with": "You, {{users}} and {{counter}} more reacted with {{emoji}}", + "You_were_invited_to_a_conference": "You were invited to a conference", "You_will_be_asked_for_permissions": "You will be asked for permissions", "You_will_not_be_able_to_recover": "You will not be able to recover this message!", "You_will_not_be_able_to_recover_email_inbox": "You will not be able to recover this email inbox", From 16072c33dd8611e5b097a716f2f8365a7d3696ec Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 17:10:30 -0300 Subject: [PATCH 18/31] chore(video-conf): add the changeset for the conference data model Patch across the packages it touches, and worded as groundwork: the server work is inert until a provider registers the embedded capability, so it delivers no feature on its own. Co-Authored-By: Claude Fable 5 --- .changeset/videoconf-conference-data-model.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/videoconf-conference-data-model.md diff --git a/.changeset/videoconf-conference-data-model.md b/.changeset/videoconf-conference-data-model.md new file mode 100644 index 0000000000000..39e4e31c527ce --- /dev/null +++ b/.changeset/videoconf-conference-data-model.md @@ -0,0 +1,16 @@ +--- +'@rocket.chat/core-services': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/ddp-client': patch +'@rocket.chat/models': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Groundwork for the video conference window: no user-facing change. + +Conference records gain per-member lifecycle fields (joined, declined, left, last seen, ringing) and the service gains the operations a call window needs — leaving, heartbeats, ringing again, declining, adding participants, renaming, resolving where the call's chat lives — behind new REST endpoints. A cron sweeps presence leases so a call whose participants vanish is closed rather than left running. + +All of the new behaviour is reserved for providers whose call renders inside Rocket.Chat, identified by an `embedded` capability. No provider registers that capability yet, so on any existing workspace every one of these paths is skipped and calls placed through Jitsi, Google Meet, BBB or Pexip behave exactly as before. The endpoints are additive and no client calls them yet. From 35d8b939e9b24cbf3c8b35543352e718ea3f7389 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 17:33:05 -0300 Subject: [PATCH 19/31] chore(video-conf): defer the embedded participants record to LiveKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `participants` was a second per-participant join/leave record, written only behind the embedded-provider gate and read by nothing — not the client, not the server, not the API. It came over with the data-model extraction from the native-video-conference branch, where LiveKit tracked SFU presence separately, before the roster in `users` grew `joined`, `leftAt` and `lastSeenAt`. Two records of who is in a call is a correctness hazard rather than a convenience: the presence sweep had to write both, with a comment explaining that the two disagreeing is how a call ends up counted as occupied by one half of the code and empty by the other. Nothing here needs the second one, and by the time LiveKit lands the roster is a superset of what it held — so it can come back then, if it still has to. Removes the type, the two model methods, both call sites and their tests. The `findActiveWithMembers` header claiming to introduce embedded-only helpers goes too: that query is the presence sweep's, and runs for every provider. --- .../VideoConfList/useVideoConfList.ts | 9 +---- .../services/video-conference/service.ts | 17 ++-------- .../video-conference/busyStatus.spec.ts | 1 - .../expirePresenceLeases.spec.ts | 12 ------- packages/core-typings/src/IVideoConference.ts | 20 ----------- .../src/models/IVideoConferenceModel.ts | 10 ------ .../models/src/models/VideoConference.spec.ts | 20 ----------- packages/models/src/models/VideoConference.ts | 33 ------------------- 8 files changed, 4 insertions(+), 118 deletions(-) diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts index 1a9cf1d44b75b..8c897a589e10c 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts @@ -20,7 +20,7 @@ export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { return { items: data.map( - ({ _updatedAt, createdAt, endedAt, users, participants, ...rest }): VideoConference => ({ + ({ _updatedAt, createdAt, endedAt, users, ...rest }): VideoConference => ({ ...rest, _updatedAt: new Date(_updatedAt), createdAt: new Date(createdAt), @@ -34,13 +34,6 @@ export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { lastSeenAt: lastSeenAt ? new Date(lastSeenAt) : undefined, ringingAt: ringingAt ? new Date(ringingAt) : undefined, })), - ...(participants && { - participants: participants.map(({ joinedAt, leftAt, ...pRest }) => ({ - ...pRest, - joinedAt: joinedAt ? new Date(joinedAt) : undefined, - leftAt: leftAt ? new Date(leftAt) : undefined, - })), - }), }), ), itemCount: total, diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index ea5d25f68fe74..586fbd2256a3e 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -983,19 +983,11 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser); // Embedded providers (LiveKit) don't return a URL — the client mounts - // the call inline via the embedded provider's React tree. We still - // track the per-participant join time so the cleanup cron + the - // raise-hand queue have something to work with. Returning an empty - // string tells the client there's no URL to open. + // the call inline via the embedded provider's React tree. The join is + // already on the roster from `runOnUserJoinEvent` above, so returning an + // empty string is all that's left: it tells the client there's no URL to open. if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { if (user) { - await VideoConferenceModel.addEmbeddedParticipant(call._id, { - id: user._id, - username: user.username, - displayName: user.name, - joinedAt: new Date(), - }); - await this.notifyUsersOfRoom(call.rid, user._id, 'started', { callId: call._id, rid: call.rid, @@ -1708,9 +1700,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Whoever stopped renewing is not in a call any more, whatever their client failed to say — and a // status left on busy by a crashed tab is exactly the kind of thing nobody thinks to fix by hand. await this.releaseBusyForCall(uid); - // Embedded providers keep a second per-participant record, and the two disagreeing is how a - // call ends up counted as occupied by one half of the code and empty by the other. - await VideoConferenceModel.markEmbeddedParticipantLeft(call._id, uid, leftAt); } this.notifyVideoConfUpdate(call.rid, call._id); diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index 2343610cb963b..76deaee937afa 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -51,7 +51,6 @@ const VideoConferenceModelMock = { return { revived, rid: fixture.rid, providerName: fixture.providerName }; }), renewUsersPresenceById: sinon.stub().resolves(), - markEmbeddedParticipantLeft: sinon.stub().resolves(), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { Object.assign(fixture, data); }), diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts index 94aa4ce7eaf3f..6dd483754255d 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -31,7 +31,6 @@ const VideoConferenceModelMock = { } }), renewUsersPresenceById: sinon.stub().resolves(), - markEmbeddedParticipantLeft: sinon.stub().resolves(), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { Object.assign(fixture, data); }), @@ -69,7 +68,6 @@ describe('VideoConfService.expirePresenceLeases', () => { VideoConferenceModelMock.findActiveWithMembers, VideoConferenceModelMock.setUserLeftById, VideoConferenceModelMock.renewUsersPresenceById, - VideoConferenceModelMock.markEmbeddedParticipantLeft, VideoConferenceModelMock.setDataById, VideoConferenceModelMock.setStatusById, ); @@ -97,16 +95,6 @@ describe('VideoConfService.expirePresenceLeases', () => { expect(leftAt).to.deep.equal(at(-PRESENCE_LEASE_MS)); }); - // Both records of who is in the call have to agree, or one half of the code counts the call as occupied while - // the other counts it as empty. - it('records the departure against the embedded participant list too', async () => { - fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); - - await service.expirePresenceLeases(at(0)); - - expect(VideoConferenceModelMock.markEmbeddedParticipantLeft.calledWith('call1', 'gone', at(-PRESENCE_LEASE_MS))).to.be.true; - }); - it('leaves a member who is still renewing alone', async () => { fixture = buildGroupCall([buildMember({ _id: 'staying', lastSeenAt: at(-1_000) })]); diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index e99a3e3f61391..d7a86367bef04 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -149,20 +149,6 @@ export const isRingingVideoConferenceMember = ( return now - user.ringingAt.getTime() < VIDEO_CONF_RINGING_WINDOW_MS; }; -/** - * Per-participant join/leave tracking. Used by embedded-SFU providers - * (e.g. LiveKit) where the room may persist across users joining and leaving - * independently. URL-based providers (Jitsi/Meet/Zoom) leave this undefined - * — they only know if the call is open at all, not who's currently in. - */ -export type IVideoConferenceParticipant = { - id: IUser['_id']; - username?: string; - displayName?: string; - joinedAt?: Date; - leftAt?: Date; -}; - export interface IVideoConference extends IRocketChatRecord { type: VideoConferenceType; rid: string; @@ -185,12 +171,6 @@ export interface IVideoConference extends IRocketChatRecord { ringing?: boolean; discussionRid?: IRoom['_id']; - - /** - * Populated by a provider that runs the call inside Rocket.Chat (LiveKit) rather than handing off to an - * external URL. URL-based providers (Jitsi/Meet/Zoom) leave it undefined. - */ - participants?: IVideoConferenceParticipant[]; } export interface IDirectVideoConference extends IVideoConference { diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index 281a33ad379a8..bca4b3e0b09d0 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -3,7 +3,6 @@ import type { ILivechatVideoConference, IRoom, IUser, - IVideoConferenceParticipant, VideoConference, VideoConferenceLeaveReason, VideoConferenceStatus, @@ -100,13 +99,4 @@ export interface IVideoConferenceModel extends IBaseModel { unsetDiscussionRid(discussionRid: IRoom['_id']): Promise; createVoIP(call: InsertionModel): Promise; - - // --- Embedded SFU (LiveKit) helpers --- - // These mirror the per-participant bookkeeping - // that URL-based providers don't need. URL providers (Jitsi/Meet/Zoom) - // never call these. - - addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise; - - markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt?: Date): Promise; } diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts index 198c34aad9622..0efe11d06777e 100644 --- a/packages/models/src/models/VideoConference.spec.ts +++ b/packages/models/src/models/VideoConference.spec.ts @@ -233,26 +233,6 @@ describe('VideoConferenceRaw.setUserLeftById', () => { }); }); -describe('VideoConferenceRaw.addEmbeddedParticipant', () => { - // Two writes ($pull then $push) let two concurrent joins interleave into a duplicate entry; a single - // pipeline update replaces-and-appends atomically. - it('should drop any prior entry and append the fresh one in one write', async () => { - const { model, updateOne } = setupModel(); - const joinedAt = new Date('2026-08-01T10:00:00Z'); - - await model.addEmbeddedParticipant('call-1', { id: 'user-1', username: 'user.one', displayName: 'User One', joinedAt }); - - expect(updateOne).toHaveBeenCalledTimes(1); - const [query, update] = updateOne.mock.calls[0]; - expect(query).toEqual({ _id: 'call-1' }); - // A pipeline update, which is what makes the replace-and-append a single atomic step. - expect(Array.isArray(update)).toBe(true); - expect(update[0].$set.participants.$concatArrays[1]).toEqual({ - $literal: [{ id: 'user-1', username: 'user.one', displayName: 'User One', joinedAt }], - }); - }); -}); - describe('VideoConferenceRaw.setUserDeclinedById', () => { it('should mutate the matching entry in place via arrayFilters', async () => { const { model, updateOne } = setupModel(); diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index ba9bd4c0d5f63..a1828f88e2430 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -6,7 +6,6 @@ import type { IRoom, RocketChatRecordDeleted, IVoIPVideoConference, - IVideoConferenceParticipant, VideoConferenceLeaveReason, } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; @@ -467,10 +466,6 @@ export class VideoConferenceRaw extends BaseRaw implements IVid ); } - // --- Embedded SFU (LiveKit) helpers --- - // URL-based providers (Jitsi/Meet/Zoom) never call these. The data shape - // is described in the IVideoConferenceParticipant type in core-typings. - /** * Every call that is still open, with what the presence sweep needs to judge it: who is on the roster, and * which provider is running the media — the one that may be able to say who is in the room. @@ -487,32 +482,4 @@ export class VideoConferenceRaw extends BaseRaw implements IVid { projection: { _id: 1, rid: 1, users: 1, providerName: 1 } }, ); } - - public async addEmbeddedParticipant(callId: VideoConference['_id'], participant: IVideoConferenceParticipant): Promise { - // One atomic update: drop any prior entry for this user (so a re-join doesn't leave a leftAt'd ghost - // alongside the fresh one) and append the new entry in the same write — two separate writes would let - // two concurrent joins interleave into a duplicate. `$literal` keeps the entry's values as data even if - // one happens to look like an aggregation expression. - await this.updateOne({ _id: callId }, [ - { - $set: { - participants: { - $concatArrays: [ - { - $filter: { - input: { $ifNull: ['$participants', []] }, - cond: { $ne: ['$$this.id', participant.id] }, - }, - }, - { $literal: [{ ...participant, joinedAt: participant.joinedAt ?? new Date() }] }, - ], - }, - }, - }, - ] as any); - } - - public async markEmbeddedParticipantLeft(callId: VideoConference['_id'], userId: IUser['_id'], leftAt = new Date()): Promise { - await this.updateOne({ '_id': callId, 'participants.id': userId }, { $set: { 'participants.$.leftAt': leftAt } } as any); - } } From ccb56e8b09f960064a1ad42e4158b571e1827b72 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 18:17:52 -0300 Subject: [PATCH 20/31] chore(video-conf): answer the review on ringing, chat mode and API contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default `VideoConf_Persistent_Chat_Mode` to `thread`. It is the mode we recommend and the setting is new, so no workspace had a persistent chat under it to preserve. The service's fallback matches, so unset behaves the same. - Stop ringing by room size. Starting a call rings only where every member is someone who chose a conversation with the caller: a DM (the `direct` type) or a group DM. A channel, private group or team rings nobody; whoever wants to reach a specific person rings them from inside the call. - Remove `videoConfPresence`. The probe registry had no registrants here or on the LiveKit branch, so `getProbe` always answered `undefined` and the whole branch in `expirePresenceLeases` was dead. `renewUsersPresenceById` went with it, having been the probe's only writer. The sweep's resilience to one call failing is kept as a test, now with the write failing instead of a probe. - `video-conference.add-participants` no longer rings by default: adding someone is often so they can join when they can, and an unrequested ring is an interruption. The schema says so too. - `video-conference.ring` requires `users`, and `video-conference.share-chat` requires `mode` — neither endpoint has anything to do with the field missing, and the client always sends both. - Drop `VideoConfCancelProps`/`isVideoConfCancelProps`; the aliases had no consumer but their own spec. --- .../server/configuration/videoConference.ts | 20 ++-- .../ee/server/settings/video-conference.ts | 8 +- apps/meteor/lib/videoConference/presence.ts | 4 +- apps/meteor/server/api/v1/videoConference.ts | 4 +- apps/meteor/server/lib/videoConfPresence.ts | 36 ------ .../services/video-conference/service.ts | 26 +--- .../VideoConfCancelProps.spec.ts | 40 ------- .../VideoConfShareChatProps.spec.ts | 10 +- .../video-conference/busyStatus.spec.ts | 1 - .../expirePresenceLeases.spec.ts | 112 ++++-------------- .../src/models/IVideoConferenceModel.ts | 3 - .../models/src/models/VideoConference.spec.ts | 26 ---- packages/models/src/models/VideoConference.ts | 20 ---- .../VideoConfAddParticipantsProps.ts | 6 +- .../videoConference/VideoConfCallIdProps.ts | 4 - .../v1/videoConference/VideoConfRingProps.ts | 8 +- .../VideoConfShareChatProps.ts | 11 +- 17 files changed, 65 insertions(+), 274 deletions(-) delete mode 100644 apps/meteor/server/lib/videoConfPresence.ts delete mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts diff --git a/apps/meteor/ee/server/configuration/videoConference.ts b/apps/meteor/ee/server/configuration/videoConference.ts index 351b8b81bccb2..629070863f8b8 100644 --- a/apps/meteor/ee/server/configuration/videoConference.ts +++ b/apps/meteor/ee/server/configuration/videoConference.ts @@ -2,10 +2,9 @@ import { VideoConf } from '@rocket.chat/core-services'; import type { IRoom, IUser, VideoConference } from '@rocket.chat/core-typings'; import { VideoConferenceStatus } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; -import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { shouldRingVideoConference } from '../../../lib/videoConference/constants'; import { callbacks } from '../../../server/lib/callbacks'; import { videoConfTypes } from '../../../server/lib/videoConfTypes'; import { addSettings } from '../settings/video-conference'; @@ -27,20 +26,19 @@ Meteor.startup(async () => { }, ); + // Ringing on start is for the rooms where every member is someone who chose to be in a conversation + // with the caller: a DM, handled by the `direct` type above, and a group DM, handled here. Starting a + // call in a channel, private group or team does not ring anyone — the people who want in are told by + // the call itself, and whoever else wants to reach a specific person can ring them from the call. videoConfTypes.registerVideoConferenceType({ type: 'videoconference', ringing: true }, async ({ _id, t }, allowRinging) => { - if (!allowRinging || t === 'l') { + if (!allowRinging || t !== 'd') { return false; } - if (t === 'd') { - const room = await Rooms.findOneById>(_id, { projection: { uids: 1 } }); - if (room && (!room.uids || room.uids.length <= 2)) { - return false; - } - } + // A two-person DM is the `direct` type's, so what rings here is a group DM. + const room = await Rooms.findOneById>(_id, { projection: { uids: 1 } }); - // Starting a call rings the whole room, so the room's size is the list being rung. - return shouldRingVideoConference(await Subscriptions.countByRoomId(_id)); + return Boolean(room?.uids && room.uids.length > 2); }); callbacks.add('onJoinVideoConference', async (callId: VideoConference['_id'], userId?: IUser['_id']) => diff --git a/apps/meteor/ee/server/settings/video-conference.ts b/apps/meteor/ee/server/settings/video-conference.ts index 740fafc14afc0..ea11b28ce3173 100644 --- a/apps/meteor/ee/server/settings/video-conference.ts +++ b/apps/meteor/ee/server/settings/video-conference.ts @@ -44,16 +44,16 @@ export function addSettings(): Promise { const persistentChatEnabled = { _id: 'VideoConf_Enable_Persistent_Chat', value: true }; - // 'main_room' keeps the historical behavior — a discussion created off the main room — for - // workspaces that already had persistent chat enabled before the mode existed. - await this.add('VideoConf_Persistent_Chat_Mode', 'main_room', { + // A thread off the call message is the mode we recommend, and nothing is being migrated away from + // the other one: the setting is new, so no workspace had a persistent chat under it to preserve. + await this.add('VideoConf_Persistent_Chat_Mode', 'thread', { type: 'select', values: [ { key: 'main_room', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Main_Room' }, { key: 'thread', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Thread' }, ], public: true, - invalidValue: 'main_room', + invalidValue: 'thread', i18nDescription: 'VideoConf_Persistent_Chat_Mode_Description', enableQuery: [persistentChatEnabled], }); diff --git a/apps/meteor/lib/videoConference/presence.ts b/apps/meteor/lib/videoConference/presence.ts index b3f1a1699ee94..5f3a2c3930235 100644 --- a/apps/meteor/lib/videoConference/presence.ts +++ b/apps/meteor/lib/videoConference/presence.ts @@ -12,9 +12,7 @@ import { isInVideoConference } from '@rocket.chat/core-typings'; * signal this infers a departure from. * * Deliberately provider-agnostic: the renewal comes from our own conference window, which exists whoever runs the - * media — an iframe provider renders inside our page, so our code is alive there too. Where a provider *can* be - * asked who is in the room, its answer renews leases as well (see `videoConfPresence`), which keeps someone in - * the call whose browser has throttled their heartbeat. Nothing here requires that integration to exist. + * media — an iframe provider renders inside our page, so our code is alive there too. */ /** diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index 337c5dcfe45bd..f49e56170e61c 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -470,7 +470,9 @@ API.v1.post( // Adding is open to anyone with access to the conference; the ring that usually accompanies it needs the // same permission `video-conference.start` demands, and degrades silently without it — same as `start`. const added = await VideoConf.addMembers(conference.userId, callId, users, { - ring: (ring ?? true) && (await hasPermissionAsync(this.userId, 'videoconf-ring-users')), + // Not ringing unless asked: adding someone to a call in progress is often to have them join when + // they can, and an unrequested ring is an interruption nobody chose. + ring: (ring ?? false) && (await hasPermissionAsync(this.userId, 'videoconf-ring-users')), }); return API.v1.success({ added }); diff --git a/apps/meteor/server/lib/videoConfPresence.ts b/apps/meteor/server/lib/videoConfPresence.ts deleted file mode 100644 index c0eacc0031b73..0000000000000 --- a/apps/meteor/server/lib/videoConfPresence.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { IUser, VideoConference } from '@rocket.chat/core-typings'; - -/** - * Asks a provider who is in a call's room right now. - * - * Returns the ids of the members it can see, or `undefined` for "no answer" — which is both what a provider with - * no such API says and what a reachable one says when the request fails. The distinction matters: an empty array - * is the provider stating that the room is empty, while `undefined` is silence, and silence must never be read as - * absence. - */ -export type VideoConfPresenceProbe = (call: Pick) => Promise; - -const probes = new Map(); - -/** - * Where a provider can offer to say who is in its rooms. - * - * Optional on purpose. Presence is held by leases the conference window renews, which works for every provider - * because that window is ours whoever runs the media. A probe is an upgrade on top of that, not a dependency: it - * renews leases from the server side, so a call window whose timers the browser has throttled — or which is - * behind a network that drops our heartbeat — is still recognised as being in the call. Providers reached by URL - * (Jitsi, Meet, Pexip as we drive it) register nothing and lose nothing but that. - */ -export const videoConfPresence = { - registerProbe(providerName: string, probe: VideoConfPresenceProbe): void { - probes.set(providerName.toLowerCase(), probe); - }, - - unregisterProbe(providerName: string): void { - probes.delete(providerName.toLowerCase()); - }, - - getProbe(providerName: string): VideoConfPresenceProbe | undefined { - return probes.get(providerName.toLowerCase()); - }, -}; diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 586fbd2256a3e..016de976937a4 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -75,7 +75,6 @@ import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { updateCounter } from '../../lib/statistics/functions/updateStatsCounter'; import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL'; import { getUserPreference } from '../../lib/utils/lib/getUserPreference'; -import { videoConfPresence } from '../../lib/videoConfPresence'; import { videoConfProviders } from '../../lib/videoConfProviders'; import { videoConfTypes } from '../../lib/videoConfTypes'; import { addUsersToRoomMethod } from '../../meteor-methods/rooms/addUsersToRoom'; @@ -1673,23 +1672,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf continue; } - // A provider that can say who is in its room is asked first, and its answer renews leases the same - // way a client's heartbeat does. Silence is not absence: `undefined` leaves the leases as they are. - // A probe that fails is silence too — per the probe contract it should already answer `undefined`, - // but an unreachable provider must not stop this call's leases being judged on their own evidence. - const present = await videoConfPresence - .getProbe(call.providerName)?.(call) - .catch((err) => { - logger.warn({ msg: 'Video conference presence probe failed', callId: call._id, providerName: call.providerName, err }); - return undefined; - }); - const users = present ? call.users.map((user) => (present.includes(user._id) ? { ...user, lastSeenAt: now } : user)) : call.users; - - if (present?.length) { - await VideoConferenceModel.renewUsersPresenceById(call._id, present, now); - } - - const expired = expiredPresenceLeases(users, now); + const expired = expiredPresenceLeases(call.users, now); if (!expired.length) { continue; } @@ -1707,7 +1690,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // No second grace period: the lease *was* the grace period, and it is far longer than the one a // reported departure gets. Anyone who came back renewed it and is not in `expired` at all. - const remaining = users.filter(({ _id }) => !expired.some((lease) => lease.uid === _id)); + const remaining = call.users.filter(({ _id }) => !expired.some((lease) => lease.uid === _id)); if (!remaining.some(isInVideoConference)) { await this.endCall(call._id); } @@ -1940,9 +1923,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } private getPersistentChatMode(): 'thread' | 'main_room' { - // 'main_room' is the historical behavior — a discussion off the main room — and is what a workspace that - // enabled persistent chat before the mode existed must keep getting. - return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'main_room'; + // Matches the setting's own default, so an unset value behaves the same as a freshly registered one. + return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'thread'; } /** diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts deleted file mode 100644 index d3874927ae7a8..0000000000000 --- a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { isVideoConfCancelProps } from '@rocket.chat/rest-typings'; -import { assert } from 'chai'; - -describe('VideoConfCancelProps (definition/rest/v1)', () => { - describe('isVideoConfCancelProps', () => { - it('should be a function', () => { - assert.isFunction(isVideoConfCancelProps); - }); - it('should return false when provided anything that is not an VideoConfCancelProps', () => { - assert.isFalse(isVideoConfCancelProps(undefined)); - assert.isFalse(isVideoConfCancelProps(null)); - assert.isFalse(isVideoConfCancelProps('')); - assert.isFalse(isVideoConfCancelProps(123)); - assert.isFalse(isVideoConfCancelProps({})); - assert.isFalse(isVideoConfCancelProps([])); - assert.isFalse(isVideoConfCancelProps(new Date())); - assert.isFalse(isVideoConfCancelProps(new Error())); - }); - it('should return false if callId is not provided to VideoConfCancelProps', () => { - assert.isFalse(isVideoConfCancelProps({})); - }); - - it('should accept a callId with nothing else', () => { - assert.isTrue( - isVideoConfCancelProps({ - callId: 'callId', - }), - ); - }); - - it('should return false when extra parameters are provided to VideoConfCancelProps', () => { - assert.isFalse( - isVideoConfCancelProps({ - callId: 'callId', - extra: 'extra', - }), - ); - }); - }); -}); diff --git a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts index a955831260fc2..7f4554f476624 100644 --- a/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts +++ b/apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfShareChatProps.spec.ts @@ -2,13 +2,15 @@ import { isVideoConfShareChatProps } from '@rocket.chat/rest-typings'; import { assert } from 'chai'; /** - * What this schema says that the others don't: a `mode` is optional, and when given it has to be one this server - * can actually act on. The "rejects a non-object", "requires the id" and "refuses extra properties" cases are + * What this schema says that the others don't: a `mode` is required, and has to be one this server can actually + * act on. The "rejects a non-object", "requires the id" and "refuses extra properties" cases are * `type: 'object'`, `required` and `additionalProperties: false` doing their job — ajv's, not ours. */ describe('isVideoConfShareChatProps', () => { - it('accepts a callId with nothing else, leaving the choice to the room', () => { - assert.isTrue(isVideoConfShareChatProps({ callId: 'callId' })); + // The caller shows which way it is sharing rather than letting the endpoint pick, so a body without one is + // an incomplete request, not a request for the default. + it('rejects a callId with no mode', () => { + assert.isFalse(isVideoConfShareChatProps({ callId: 'callId' })); }); it('accepts either way of sharing the chat', () => { diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index 76deaee937afa..617c981b4e751 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -50,7 +50,6 @@ const VideoConferenceModelMock = { member.lastSeenAt = new Date(); return { revived, rid: fixture.rid, providerName: fixture.providerName }; }), - renewUsersPresenceById: sinon.stub().resolves(), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { Object.assign(fixture, data); }), diff --git a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts index 6dd483754255d..bd3e244310f93 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/expirePresenceLeases.spec.ts @@ -12,9 +12,12 @@ const at = (offsetMs: number) => new Date(ts.getTime() + offsetMs); /** The one canonical record, as in `leaveCall.spec`: reads are copies of it and the write stubs mutate it. */ let fixture: VideoConference; -/** What the provider answers when asked who is in the room, or `undefined` for "no answer". */ -let present: string[] | undefined; -let probe: sinon.SinonStub | undefined; +const markMemberLeft = async (_callId: string, uid: string, leftAt: Date) => { + const member = fixture.users.find((user) => user._id === uid); + if (member) { + (member as IVideoConferenceUser).leftAt = leftAt; + } +}; const VideoConferenceModelMock = { findOneById: sinon.stub().callsFake(async () => cloneFixture(fixture)), @@ -24,13 +27,7 @@ const VideoConferenceModelMock = { yield cloneFixture(fixture); }, })), - setUserLeftById: sinon.stub().callsFake(async (_callId: string, uid: string, leftAt: Date) => { - const member = fixture.users.find((user) => user._id === uid); - if (member) { - (member as IVideoConferenceUser).leftAt = leftAt; - } - }), - renewUsersPresenceById: sinon.stub().resolves(), + setUserLeftById: sinon.stub().callsFake(markMemberLeft), setDataById: sinon.stub().callsFake(async (_callId: string, data: Partial) => { Object.assign(fixture, data); }), @@ -47,7 +44,6 @@ const VideoConfService = createService({ VideoConference: VideoConferenceModelMock, }, overrides: { - '../../lib/videoConfPresence': { videoConfPresence: { getProbe: () => probe } }, '../../lib/videoConfProviders': { videoConfProviders: { getProviderCapabilities: () => ({ embedded: true }), @@ -61,22 +57,21 @@ describe('VideoConfService.expirePresenceLeases', () => { beforeEach(() => { service = new VideoConfService(); - present = undefined; - probe = undefined; resetAll( VideoConferenceModelMock.findOneById, VideoConferenceModelMock.findActiveWithMembers, VideoConferenceModelMock.setUserLeftById, - VideoConferenceModelMock.renewUsersPresenceById, VideoConferenceModelMock.setDataById, VideoConferenceModelMock.setStatusById, ); - // `resetAll` only clears history — restore the single-call cursor for the tests that replace it. + // `resetAll` only clears history — restore the behaviours the tests below replace. VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ async *[Symbol.asyncIterator]() { yield cloneFixture(fixture); }, })); + VideoConferenceModelMock.setUserLeftById.resetBehavior(); + VideoConferenceModelMock.setUserLeftById.callsFake(markMemberLeft); }); // The case this exists for: the workspace was down while the call carried on in the provider, so the leave @@ -125,81 +120,22 @@ describe('VideoConfService.expirePresenceLeases', () => { expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); }); - describe('when the provider can say who is in the room', () => { - beforeEach(() => { - probe = sinon.stub().callsFake(async () => present); - }); - - // Why asking the provider is worth anything at all: a window that isn't in front has its timers throttled - // by the browser, so the member most likely to look absent is someone listening while they work. - it('holds on to a member the provider can see, whatever their heartbeat did', async () => { - present = ['throttled']; - fixture = buildGroupCall([buildMember({ _id: 'throttled', lastSeenAt: at(-PRESENCE_LEASE_MS * 2) })]); - - await service.expirePresenceLeases(at(0)); - - expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; - expect(VideoConferenceModelMock.renewUsersPresenceById.calledWith('call1', ['throttled'], at(0))).to.be.true; - expect(fixture.status).to.equal(VideoConferenceStatus.STARTED); - }); - - // Silence is not absence. A provider we cannot reach must not be able to empty a call — that would turn - // our own network trouble into everyone else's departure. - it('changes nothing about a lease when the provider cannot be asked', async () => { - present = undefined; - fixture = buildGroupCall([buildMember({ _id: 'staying', lastSeenAt: at(-1_000) })]); - - await service.expirePresenceLeases(at(0)); - - expect(VideoConferenceModelMock.renewUsersPresenceById.called).to.be.false; - expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; - }); - - // An empty array is the provider stating the room is empty, unlike `undefined`. It renews nobody, so the - // leases decide — which they do at their own pace rather than instantly. - it('lets the leases decide when the provider reports an empty room', async () => { - present = []; - fixture = buildGroupCall([buildMember({ _id: 'fresh', lastSeenAt: at(-1_000) })]); - - await service.expirePresenceLeases(at(0)); - - expect(VideoConferenceModelMock.renewUsersPresenceById.called).to.be.false; - expect(VideoConferenceModelMock.setUserLeftById.called).to.be.false; - }); - - // A probe that fails is silence, same as a provider with no probe at all: the leases still get judged on - // their own evidence. Anything else lets an unreachable provider keep its crashed members present forever — - // the exact situation the sweep exists to clean up. - it('still expires the leases when the probe fails', async () => { - probe = sinon.stub().rejects(new Error('LiveKit is unreachable')); - fixture = buildGroupCall([ - buildMember({ _id: 'staying', lastSeenAt: at(0) }), - buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) }), - ]); - - await service.expirePresenceLeases(at(0)); - - expect(VideoConferenceModelMock.setUserLeftById.calledWith('call1', 'gone')).to.be.true; - expect(VideoConferenceModelMock.setUserLeftById.calledOnce, 'a failed probe must not evict the fresh lease').to.be.true; - }); - - // And a failing probe on one call must not stop the sweep before it reaches the next one. - it('carries on to the next call when a probe fails', async () => { - probe = sinon.stub().rejects(new Error('LiveKit is unreachable')); - fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); - const second = buildGroupCall([buildMember({ _id: 'gone2', lastSeenAt: at(-PRESENCE_LEASE_MS) })], { _id: 'call2' }); - VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ - async *[Symbol.asyncIterator]() { - yield cloneFixture(fixture); - yield cloneFixture(second); - }, - })); + // One call that throws must not stop the sweep before it reaches the next one — otherwise a single bad + // record keeps every later call's members present forever, which is the situation the sweep exists to fix. + it('carries on to the next call when one of them fails', async () => { + fixture = buildGroupCall([buildMember({ _id: 'gone', lastSeenAt: at(-PRESENCE_LEASE_MS) })]); + const second = buildGroupCall([buildMember({ _id: 'gone2', lastSeenAt: at(-PRESENCE_LEASE_MS) })], { _id: 'call2' }); + VideoConferenceModelMock.findActiveWithMembers.callsFake(() => ({ + async *[Symbol.asyncIterator]() { + yield cloneFixture(fixture); + yield cloneFixture(second); + }, + })); + VideoConferenceModelMock.setUserLeftById.onFirstCall().rejects(new Error('the write failed')); - await service.expirePresenceLeases(at(0)); + await service.expirePresenceLeases(at(0)); - expect(VideoConferenceModelMock.setUserLeftById.calledWith('call1', 'gone')).to.be.true; - expect(VideoConferenceModelMock.setUserLeftById.calledWith('call2', 'gone2')).to.be.true; - }); + expect(VideoConferenceModelMock.setUserLeftById.calledWith('call2', 'gone2')).to.be.true; }); describe('non-embedded providers', () => { diff --git a/packages/model-typings/src/models/IVideoConferenceModel.ts b/packages/model-typings/src/models/IVideoConferenceModel.ts index bca4b3e0b09d0..c82c3258fbf28 100644 --- a/packages/model-typings/src/models/IVideoConferenceModel.ts +++ b/packages/model-typings/src/models/IVideoConferenceModel.ts @@ -80,9 +80,6 @@ export interface IVideoConferenceModel extends IBaseModel { inferredReasons?: VideoConferenceLeaveReason[], ): Promise<{ revived: boolean; rid: IRoom['_id']; providerName: string } | null>; - /** Renews several leases at once, as a provider reporting who is in its room does. */ - renewUsersPresenceById(callId: string, uids: IUser['_id'][], lastSeenAt?: Date): Promise; - /** Every open call, with the roster and provider the presence sweep judges it by. */ findActiveWithMembers(): FindCursor>; diff --git a/packages/models/src/models/VideoConference.spec.ts b/packages/models/src/models/VideoConference.spec.ts index 0efe11d06777e..a4d82cf4f2f12 100644 --- a/packages/models/src/models/VideoConference.spec.ts +++ b/packages/models/src/models/VideoConference.spec.ts @@ -158,32 +158,6 @@ describe('VideoConferenceRaw.renewUserPresenceById', () => { }); }); -describe('VideoConferenceRaw.renewUsersPresenceById', () => { - it('should stamp every named member at once', async () => { - const { model, updateOne } = setupModel(); - const lastSeenAt = new Date('2026-08-01T10:00:00Z'); - - await model.renewUsersPresenceById('call-1', ['user-1', 'user-2'], lastSeenAt); - - const [query, update, options] = updateOne.mock.calls[0]; - expect(query).toEqual({ _id: 'call-1', endedAt: { $exists: false } }); - expect(update).toEqual({ $set: { 'users.$[user].lastSeenAt': lastSeenAt } }); - expect(options).toEqual({ arrayFilters: [{ 'user._id': { $in: ['user-1', 'user-2'] } }] }); - }); - - // Unlike a member's own heartbeat, a provider reporting its room says nothing about whether an inferred - // departure was wrong — and an update with no ids would match every member of the call. - it('should leave departures alone, and do nothing at all with nobody to renew', async () => { - const { model, updateOne } = setupModel(); - - await model.renewUsersPresenceById('call-1', [], new Date()); - expect(updateOne).not.toHaveBeenCalled(); - - await model.renewUsersPresenceById('call-1', ['user-1'], new Date()); - expect(updateOne.mock.calls[0][1]).not.toHaveProperty('$unset'); - }); -}); - describe('VideoConferenceRaw.setUserLeftById', () => { it('should mutate the matching entry in place via arrayFilters', async () => { const { model, updateOne } = setupModel(); diff --git a/packages/models/src/models/VideoConference.ts b/packages/models/src/models/VideoConference.ts index a1828f88e2430..e09699b734eb7 100644 --- a/packages/models/src/models/VideoConference.ts +++ b/packages/models/src/models/VideoConference.ts @@ -329,26 +329,6 @@ export class VideoConferenceRaw extends BaseRaw implements IVid }; } - /** - * Renews several members' leases at once — what a provider that can be asked who is in its room answers - * with. Unlike a client's own heartbeat this never revives an inferred departure: the provider is reporting - * a room, not a member correcting us about their own window. - */ - public async renewUsersPresenceById(callId: string, uids: IUser['_id'][], lastSeenAt = new Date()): Promise { - if (!uids.length) { - return; - } - - // Guarded against ended calls for the same reason the single renewal is — though this one only stamps - // `lastSeenAt` and never revives anyone, so the guard is belt rather than braces: the sweep calls it for - // calls it just read as open. - await this.updateOne( - { _id: callId, endedAt: { $exists: false } }, - { $set: { 'users.$[user].lastSeenAt': lastSeenAt } }, - { arrayFilters: [{ 'user._id': { $in: uids } }] }, - ); - } - /** Records that we just rang these members, so every client can tell a ringing phone from a silent one. */ public async setUsersRingingById(callId: string, uids: IUser['_id'][], ringingAt = new Date()): Promise { if (!uids.length) { diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts index b5ed455bcd0b3..714100132f258 100644 --- a/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts +++ b/packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts @@ -13,8 +13,9 @@ export type VideoConfAddParticipantsProps = { */ users: string[]; /** - * Whether to ring the people being added. Defaults to ringing: someone added to a call in progress is being - * called *now*, and the whole point of adding them is usually that they are wanted in it. + * Whether to ring the people being added. **Defaults to `false`** — adding someone is often so they can + * join when they can, and a ring nobody asked for is an interruption. Ringing also needs the + * `videoconf-ring-users` permission, without which it is skipped silently. */ ring?: boolean; }; @@ -38,6 +39,7 @@ const videoConfAddParticipantsPropsSchema: JSONSchemaType = { }; export const isVideoConfCallIdProps = ajv.compile(videoConfCallIdPropsSchema); - -/** The name this shape shipped under before it was shared. Kept because it is part of the published surface. */ -export type VideoConfCancelProps = VideoConfCallIdProps; -export const isVideoConfCancelProps = isVideoConfCallIdProps; diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts index ff8754d205af3..c17b6f40f6a5b 100644 --- a/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts +++ b/packages/rest-typings/src/v1/videoConference/VideoConfRingProps.ts @@ -8,10 +8,9 @@ export type VideoConfRingProps = { /** * Ring only these members, by user *id* — not username. Ringing targets people who are already conference * members, and members are tracked by id; `video-conference.add-participants` speaks usernames instead, - * because it may also invite people into a room. Omitted, everyone who isn't in the call is rung. The - * endpoint answers with the user ids it actually rang. + * because it may also invite people into a room. The endpoint answers with the user ids it actually rang. */ - users?: string[]; + users: string[]; }; const videoConfRingPropsSchema: JSONSchemaType = { @@ -29,10 +28,9 @@ const videoConfRingPropsSchema: JSONSchemaType = { // A named list may never exceed what a ring is allowed to reach; asking for more is a malformed // request, not a set to be trimmed. maxItems: VIDEO_CONF_RINGING_LIMIT, - nullable: true, }, }, - required: ['callId'], + required: ['callId', 'users'], additionalProperties: false, }; diff --git a/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts index a294303c02400..9bc042a9c35f1 100644 --- a/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts +++ b/packages/rest-typings/src/v1/videoConference/VideoConfShareChatProps.ts @@ -5,8 +5,12 @@ import { ajv } from '../Ajv'; export type VideoConfShareChatProps = { callId: string; - /** When omitted the room's own rules decide; `invite` is rejected for rooms that can't take new members. */ - mode?: VideoConferenceChatAccessMode; + /** + * How to give the members without access to the chat: bring them into the room, or move the chat to a + * discussion. Required — which of the two leads is a choice the caller makes and shows, so the endpoint + * does not guess. `invite` is rejected for rooms that can't take new members. + */ + mode: VideoConferenceChatAccessMode; }; const videoConfShareChatPropsSchema: JSONSchemaType = { @@ -19,10 +23,9 @@ const videoConfShareChatPropsSchema: JSONSchemaType = { mode: { type: 'string', enum: ['invite', 'discussion'], - nullable: true, }, }, - required: ['callId'], + required: ['callId', 'mode'], additionalProperties: false, }; From 6d0b35bbd0f363f5e8d042602f9ee214d71a39da Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 19:04:58 -0300 Subject: [PATCH 21/31] chore(video-conf): name the ring limit after its recipients, ring one member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VIDEO_CONF_RINGING_LIMIT` and `shouldRingVideoConference` read as if they were about the conference. They are not: the cap bounds the *recipients of a single ring*, which is why it also caps how many people one add may take. Renamed to `RING_RECIPIENTS_LIMIT` and `shouldRingRecipients`, and the doc no longer explains itself through room size, which stopped deciding anything when starting a call in a group room stopped ringing. `video-conference.ring` now takes one `userId` instead of a list. Ringing again is aimed at a particular person who didn't pick up — that is what the members panel does, one at a time — so the endpoint says so, and answers `rang` as a boolean: false when there was nothing to do, because they are in the call, their phone is already ringing, or the call has ended. Ringing a batch remains what adding participants does, and remains capped. `VideoConf.ringMembers` becomes `ringMember`, which also settles the open question from the review: the "ring everyone absent" path had no caller left once the endpoint named its target, so it is gone rather than unreachable. A non-member is now explicitly not ringable, so the endpoint can't be used to make an arbitrary user's phone ring. --- apps/meteor/lib/videoConference/constants.ts | 7 +- apps/meteor/server/api/v1/videoConference.ts | 12 +- .../services/video-conference/service.ts | 40 +++--- .../lib/videoConference/memberStatus.spec.ts | 20 +-- .../video-conference/busyStatus.spec.ts | 2 +- .../video-conference/declineCall.spec.ts | 2 +- .../video-conference/leaveCall.spec.ts | 2 +- .../services/video-conference/ringing.spec.ts | 131 +++++------------- .../src/types/IVideoConfService.ts | 3 +- packages/core-typings/src/IVideoConference.ts | 11 +- .../VideoConfAddParticipantsProps.ts | 4 +- .../v1/videoConference/VideoConfRingProps.ts | 26 ++-- .../src/v1/videoConference/index.ts | 4 +- 13 files changed, 97 insertions(+), 167 deletions(-) diff --git a/apps/meteor/lib/videoConference/constants.ts b/apps/meteor/lib/videoConference/constants.ts index bea29ad0feb41..772951fc2ace8 100644 --- a/apps/meteor/lib/videoConference/constants.ts +++ b/apps/meteor/lib/videoConference/constants.ts @@ -1,4 +1,4 @@ -import { VIDEO_CONF_RINGING_LIMIT } from '@rocket.chat/core-typings'; +import { RING_RECIPIENTS_LIMIT } from '@rocket.chat/core-typings'; export const availabilityErrors = { NOT_CONFIGURED: 'video-conf-provider-not-configured', @@ -26,6 +26,5 @@ export const PREFLIGHT_FACES_SHOWN = 10; */ export const EMPTY_CALL_GRACE_MS = 10_000; -/** Whether this many recipients is a set worth ringing. See `VIDEO_CONF_RINGING_LIMIT` for why there is a cap. */ -export const shouldRingVideoConference = (recipientCount: number): boolean => - recipientCount > 0 && recipientCount <= VIDEO_CONF_RINGING_LIMIT; +/** Whether this many recipients is a set worth ringing. See `RING_RECIPIENTS_LIMIT` for why there is a cap. */ +export const shouldRingRecipients = (recipientCount: number): boolean => recipientCount > 0 && recipientCount <= RING_RECIPIENTS_LIMIT; diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index f49e56170e61c..7b5983f9212d3 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -112,10 +112,10 @@ const joinableResponseSchema = oneValueResponseSchema<{ calls: unknown[] }>('cal description: 'Calls running now that the caller may join.', }); -const ringResponseSchema = oneValueResponseSchema<{ rang: string[] }>('rang', { - type: 'array', - items: { type: 'string' }, - description: 'Ids of the members who were rung.', +const ringResponseSchema = oneValueResponseSchema<{ rang: boolean }>('rang', { + type: 'boolean', + description: + 'Whether the member was rung. False when there was nothing to do — they are in the call, their phone is already ringing, or the call has ended.', }); const shareChatResponseSchema = oneValueResponseSchema<{ rid: string }>('rid', { @@ -426,7 +426,7 @@ API.v1.post( }, }, async function action() { - const { callId, users } = this.bodyParams; + const { callId, userId } = this.bodyParams; // The same permission `video-conference.start` demands before ringing anyone — having access to a // conference must not be a way around it. @@ -439,7 +439,7 @@ API.v1.post( return API.v1.failure('invalid-params'); } - const rang = await VideoConf.ringMembers(conference.userId, callId, users); + const rang = await VideoConf.ringMember(conference.userId, callId, userId); return API.v1.success({ rang }); }, diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 016de976937a4..034d53e4af427 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -49,12 +49,7 @@ import { MongoInternals } from 'meteor/mongo'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { resolveChatAccessMode } from '../../../lib/videoConference/chatAccess'; import { conferenceNameFor } from '../../../lib/videoConference/conferenceName'; -import { - availabilityErrors, - CALL_FACES_SHOWN, - EMPTY_CALL_GRACE_MS, - shouldRingVideoConference, -} from '../../../lib/videoConference/constants'; +import { availabilityErrors, CALL_FACES_SHOWN, EMPTY_CALL_GRACE_MS, shouldRingRecipients } from '../../../lib/videoConference/constants'; import { canRingConferenceMember, isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus'; import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -1262,7 +1257,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } // A finished call is not something to add people to — and certainly not something to ring them into. - // Same answer `ringMembers` gives: nobody was added. + // Same answer `ringMember` gives: nobody was added. if (call.endedAt) { return []; } @@ -1293,7 +1288,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // The list being rung is just the people added, and the endpoint caps a single add at the ringing limit — // so unlike starting a call in a large room, an add can always ring. Whether it does is the adder's to // say: someone added to carry on later is not someone to interrupt now. - if (ring && shouldRingVideoConference(added.length)) { + if (ring && shouldRingRecipients(added.length)) { await this.ringUsers(callId, call.rid, uid, added); } @@ -1325,37 +1320,34 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } /** - * Rings members who aren't in the call, again — all of them, or the ones asked for. + * Rings one member who isn't in the call, again. * * A ring is one-shot, so the caller of a call nobody picked up needs a way to try again — and adding the - * same person a second time won't do it, since they are already a member. Returns who was rung. + * same person a second time won't do it, since they are already a member. One member at a time, because + * that is the shape of the act: someone specific didn't pick up. Says whether the ring went out. * - * Members who already left are rung too: they were there and are not now, which is exactly the case - * "call them back" is for. Anyone already in the call is never rung, whether or not they were asked for — - * and neither is anyone whose phone is ringing right now: there is nothing more to ask of them. + * A member who already left is rung too: they were there and are not now, which is exactly the case + * "call them back" is for. Someone already in the call is never rung, and neither is someone whose phone is + * ringing right now: there is nothing more to ask of them. The caller can't ring themselves. */ - public async ringMembers(uid: IUser['_id'], callId: VideoConference['_id'], userIds?: IUser['_id'][]): Promise { + public async ringMember(uid: IUser['_id'], callId: VideoConference['_id'], memberId: IUser['_id']): Promise { const call = await VideoConferenceModel.findOneById(callId, { projection: { rid: 1, users: 1, endedAt: 1 } }); if (!call) { throw new Error('invalid-video-conference'); } if (call.endedAt) { - return []; + return false; } - const requested = userIds?.length ? new Set(userIds) : undefined; - const absent = call.users - .filter((member) => member._id !== uid && canRingConferenceMember(member) && (!requested || requested.has(member._id))) - .map(({ _id }) => _id); - - if (!shouldRingVideoConference(absent.length)) { - return []; + const member = call.users.find(({ _id }) => _id === memberId); + if (memberId === uid || !member || !canRingConferenceMember(member)) { + return false; } - await this.ringUsers(callId, call.rid, uid, absent); + await this.ringUsers(callId, call.rid, uid, [memberId]); - return absent; + return true; } /** diff --git a/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts index 73c176fb36732..ce3451256e238 100644 --- a/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts +++ b/apps/meteor/tests/unit/lib/videoConference/memberStatus.spec.ts @@ -1,7 +1,7 @@ -import { VIDEO_CONF_RINGING_LIMIT, hasJoinedVideoConference } from '@rocket.chat/core-typings'; +import { RING_RECIPIENTS_LIMIT, hasJoinedVideoConference } from '@rocket.chat/core-typings'; import { expect } from 'chai'; -import { shouldRingVideoConference } from '../../../../lib/videoConference/constants'; +import { shouldRingRecipients } from '../../../../lib/videoConference/constants'; import { canRingConferenceMember, getConferenceMemberStatus, @@ -102,17 +102,17 @@ describe('isUnaskedConferenceMember', () => { }); }); -describe('shouldRingVideoConference', () => { - // Ringing a large room would mean a broadcast per subscriber, so past a point a call rings nobody at all. +describe('shouldRingRecipients', () => { + // A ring is a broadcast per recipient, so past a point the batch is not worth sending and rings nobody. it('rings a list up to the limit, and none beyond it', () => { - expect(shouldRingVideoConference(1)).to.be.true; - expect(shouldRingVideoConference(VIDEO_CONF_RINGING_LIMIT)).to.be.true; - expect(shouldRingVideoConference(VIDEO_CONF_RINGING_LIMIT + 1)).to.be.false; + expect(shouldRingRecipients(1)).to.be.true; + expect(shouldRingRecipients(RING_RECIPIENTS_LIMIT)).to.be.true; + expect(shouldRingRecipients(RING_RECIPIENTS_LIMIT + 1)).to.be.false; }); - // Nobody to ring is not the same as a list small enough to ring — it saves a pointless broadcast when a - // conference starts in an empty room, or when every user in an add was already a member. + // Nobody to ring is not the same as a list small enough to ring — it saves a pointless broadcast when + // every user in an add turned out to be a member already. it('rings nobody for an empty list', () => { - expect(shouldRingVideoConference(0)).to.be.false; + expect(shouldRingRecipients(0)).to.be.false; }); }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index 617c981b4e751..ee53269733e4e 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -83,7 +83,7 @@ const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), }, }, - '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false, CALL_FACES_SHOWN: 2 }, + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingRecipients: () => false, CALL_FACES_SHOWN: 2 }, }); const ts = new Date('2026-08-02T10:00:00.000Z'); diff --git a/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts index 73e3c21c7b0a3..044fde968fe06 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/declineCall.spec.ts @@ -5,7 +5,7 @@ import sinon from 'sinon'; import { buildGroupCall, buildMember, cloneFixture, createService, resetAll } from './testHarness'; -// Mirrors `ringMembers.spec.ts`'s approach: `fixture` is the single canonical record and +// Mirrors `ringing.spec.ts`'s approach: `fixture` is the single canonical record and // `VideoConference.findOneById` hands out a clone of it on every call, regardless of projection. let fixture: VideoConference; diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index 3a03c7edb9bec..6541d496df8d4 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -59,7 +59,7 @@ const VideoConfService = createService({ // This suite is about what happens when a call empties, so the ringing the service would otherwise do on a // join is stubbed out of the way. The grace period must stay the real one — it is what the suite measures. overrides: { - '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingVideoConference: () => false, EMPTY_CALL_GRACE_MS }, + '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingRecipients: () => false, EMPTY_CALL_GRACE_MS }, }, }); diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts index bb4e21a7d5ffa..082ba3c676368 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -56,8 +56,8 @@ const UsersMock = { const broadcastStub = sinon.stub().resolves(); -// Deliberately NOT overriding '../../../lib/videoConference/constants' — the ringing-limit guard -// (`shouldRingVideoConference`, capped at `VIDEO_CONF_RINGING_LIMIT`) is what two of the tests below exercise, +// Deliberately NOT overriding '../../../lib/videoConference/constants' — the recipient-limit guard +// (`shouldRingRecipients`, capped at `RING_RECIPIENTS_LIMIT`) is what one of the tests below exercises, // so it has to be the real implementation. const VideoConfService = createService({ broadcast: broadcastStub, @@ -101,20 +101,16 @@ beforeEach(() => { broadcastStub.resolves(); }); -describe('VideoConfService.ringMembers', () => { +describe('VideoConfService.ringMember', () => { // The base case: a member added to the call but who never answered has no active presence, so a second // ring is the only way to reach them. - it('rings members who were never in the call, and nobody else', async () => { - fixture = buildGroupCall([ - buildMember({ _id: 'caller' }), - buildMember({ _id: 'neverJoined1', joined: false, joinedAt: undefined }), - buildMember({ _id: 'neverJoined2', joined: false, joinedAt: undefined }), - ]); + it('rings a member who was never in the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'neverJoined', joined: false, joinedAt: undefined })]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'neverJoined'); - expect(result.sort()).to.deep.equal(['neverJoined1', 'neverJoined2']); - expect(ringedUserIds(broadcastStub).sort()).to.deep.equal(['neverJoined1', 'neverJoined2']); + expect(result).to.be.true; + expect(ringedUserIds(broadcastStub)).to.deep.equal(['neverJoined']); }); // "Call them back" is exactly this shape: they were on the call and aren't anymore. `isInVideoConference` @@ -126,39 +122,31 @@ describe('VideoConfService.ringMembers', () => { buildMember({ _id: 'wentQuiet', joined: true, leftAt: new Date('2026-01-01T00:15:00.000Z') }), ]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'wentQuiet'); - expect(result).to.deep.equal(['wentQuiet']); + expect(result).to.be.true; expect(ringedUserIds(broadcastStub)).to.deep.equal(['wentQuiet']); }); // Someone already on the call has no reason to be interrupted by a ring meant for people who aren't there. it('does not ring a member who is currently in the call', async () => { - fixture = buildGroupCall([ - buildMember({ _id: 'caller' }), - buildMember({ _id: 'stillHere', joined: true }), - buildMember({ _id: 'absent', joined: false, joinedAt: undefined }), - ]); + fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'stillHere', joined: true })]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'stillHere'); - expect(result).to.deep.equal(['absent']); - expect(ringedUserIds(broadcastStub)).to.not.include('stillHere'); + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); }); // The caller is the one asking for the retry, not a target of it — this has to hold even for a caller - // entry that would otherwise read as absent (e.g. written with `joined: false`), since nothing else in - // `ringMembers` special-cases the caller's own membership shape. + // entry that would otherwise read as absent (e.g. written with `joined: false`). it('never rings the caller themselves, even if their own entry looks absent', async () => { - fixture = buildGroupCall([ - buildMember({ _id: 'caller', joined: false, joinedAt: undefined }), - buildMember({ _id: 'absent', joined: false, joinedAt: undefined }), - ]); + fixture = buildGroupCall([buildMember({ _id: 'caller', joined: false, joinedAt: undefined })]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'caller'); - expect(result).to.not.include('caller'); - expect(ringedUserIds(broadcastStub)).to.not.include('caller'); + expect(result).to.be.false; + expect(ringedUserIds(broadcastStub)).to.deep.equal([]); }); // A phone that is ringing right now has nothing more to ask of it — re-ringing would just restart the @@ -169,9 +157,9 @@ describe('VideoConfService.ringMembers', () => { buildMember({ _id: 'stillRinging', joined: false, joinedAt: undefined, ringingAt: new Date() }), ]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'stillRinging'); - expect(result).to.deep.equal([]); + expect(result).to.be.false; expect(ringedUserIds(broadcastStub)).to.deep.equal([]); }); @@ -187,86 +175,35 @@ describe('VideoConfService.ringMembers', () => { }), ]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'ignoredIt'); - expect(result).to.deep.equal(['ignoredIt']); + expect(result).to.be.true; expect(ringedUserIds(broadcastStub)).to.deep.equal(['ignoredIt']); }); - // Nobody absent means nothing to do — this is also what a call with a full house looks like after - // everyone's already answered. - it('returns an empty array when nobody is absent', async () => { - fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'other', joined: true })]); + // Membership is what a ring targets, so someone who was never added to this call isn't ringable through + // it — the endpoint would otherwise be a way to make any user's phone ring. + it('rings nobody for a user who is not a member of the call', async () => { + fixture = buildGroupCall([buildMember({ _id: 'caller' })]); - const result = await service.ringMembers('caller', 'call1'); + const result = await service.ringMember('caller', 'call1', 'stranger'); - expect(result).to.deep.equal([]); + expect(result).to.be.false; expect(ringedUserIds(broadcastStub)).to.deep.equal([]); }); - // A conference that already ended is not something you can still ring people into — `ringMembers` must - // bail out before even looking at who's absent. - it('returns an empty array and rings nobody for a conference that has already ended', async () => { + // A conference that already ended is not something you can still ring people into — `ringMember` must + // bail out before even looking at the member. + it('rings nobody for a conference that has already ended', async () => { fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'absent', joined: false, joinedAt: undefined })], { endedAt: new Date('2026-01-01T01:00:00.000Z'), }); - const result = await service.ringMembers('caller', 'call1'); - - expect(result).to.deep.equal([]); - expect(ringedUserIds(broadcastStub)).to.deep.equal([]); - }); - - // The cap itself is pinned on `shouldRingVideoConference` in `tests/unit/lib/videoConference`; what matters - // here is that this path is wired to it, and that tripping it suppresses the ring entirely rather than - // ringing the first ten. - it('rings nobody when the number of absent members exceeds the ringing limit', async () => { - const absentMembers: IVideoConferenceUser[] = Array.from({ length: 11 }, (_, index) => - buildMember({ _id: `absent${index}`, joined: false, joinedAt: undefined }), - ); - fixture = buildGroupCall([buildMember({ _id: 'caller' }), ...absentMembers]); - - const result = await service.ringMembers('caller', 'call1'); - - expect(result).to.deep.equal([]); - expect(ringedUserIds(broadcastStub)).to.deep.equal([]); - }); - - // The members panel rings one person at a time, so the caller says who — everyone else absent is left alone. - it('rings only the members asked for', async () => { - fixture = buildGroupCall([ - buildMember({ _id: 'caller' }), - buildMember({ _id: 'wanted', joined: false, joinedAt: undefined }), - buildMember({ _id: 'other', joined: false, joinedAt: undefined }), - ]); - - const result = await service.ringMembers('caller', 'call1', ['wanted']); - - expect(result).to.deep.equal(['wanted']); - expect(ringedUserIds(broadcastStub)).to.deep.equal(['wanted']); - }); + const result = await service.ringMember('caller', 'call1', 'absent'); - // Being asked for doesn't override being present: ringing someone who is already on the call is noise. - it('will not ring a requested member who is in the call', async () => { - fixture = buildGroupCall([buildMember({ _id: 'caller' }), buildMember({ _id: 'present' })]); - - const result = await service.ringMembers('caller', 'call1', ['present']); - - expect(result).to.deep.equal([]); + expect(result).to.be.false; expect(ringedUserIds(broadcastStub)).to.deep.equal([]); }); - - it('rings everyone absent when no member is named', async () => { - fixture = buildGroupCall([ - buildMember({ _id: 'caller' }), - buildMember({ _id: 'one', joined: false, joinedAt: undefined }), - buildMember({ _id: 'two', joined: false, joinedAt: undefined }), - ]); - - const result = await service.ringMembers('caller', 'call1'); - - expect(result.sort()).to.deep.equal(['one', 'two']); - }); }); describe('VideoConfService.addMembers', () => { diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index afcbbbd3cb0b2..8b34ce5078b73 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -58,7 +58,8 @@ export interface IVideoConfService { renewPresence(uid: IUser['_id'], callId: VideoConference['_id']): Promise; /** Marks everyone whose presence lease has run out as having left, and ends the calls that empties. */ expirePresenceLeases(now?: Date): Promise; - ringMembers(uid: IUser['_id'], callId: VideoConference['_id'], userIds?: IUser['_id'][]): Promise; + /** Rings one member who isn't in the call, again; says whether the ring went out. */ + ringMember(uid: IUser['_id'], callId: VideoConference['_id'], memberId: IUser['_id']): Promise; listJoinableCalls(uid: IUser['_id']): Promise; getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise; shareChatWithMembers(uid: IUser['_id'], callId: VideoConference['_id'], mode?: VideoConferenceChatAccessMode): Promise; diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index d7a86367bef04..30cdecbf01502 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -123,14 +123,17 @@ export const isInVideoConference = (user: Pick = { @@ -20,17 +22,13 @@ const videoConfRingPropsSchema: JSONSchemaType = { type: 'string', nullable: false, }, - users: { - type: 'array', - description: 'User ids of the members to ring — not usernames. The endpoint returns the user ids it actually rang.', - items: { type: 'string' }, - minItems: 1, - // A named list may never exceed what a ring is allowed to reach; asking for more is a malformed - // request, not a set to be trimmed. - maxItems: VIDEO_CONF_RINGING_LIMIT, + userId: { + type: 'string', + description: 'Id of the member to ring — not their username.', + nullable: false, }, }, - required: ['callId', 'users'], + required: ['callId', 'userId'], additionalProperties: false, }; diff --git a/packages/rest-typings/src/v1/videoConference/index.ts b/packages/rest-typings/src/v1/videoConference/index.ts index 1ea564350db3e..0712f9cae68c6 100644 --- a/packages/rest-typings/src/v1/videoConference/index.ts +++ b/packages/rest-typings/src/v1/videoConference/index.ts @@ -52,9 +52,9 @@ export type VideoConferenceEndpoints = { POST: (params: VideoConfCallIdProps) => void; }; - /** Rings the members who aren't in the call again; returns the ids actually rung. */ + /** Rings one member who isn't in the call again; says whether the ring actually went out. */ '/v1/video-conference.ring': { - POST: (params: VideoConfRingProps) => { rang: string[] }; + POST: (params: VideoConfRingProps) => { rang: boolean }; }; '/v1/video-conference.cancel': { From d391d7fe1fd77074f15cc5fbdaa5bd8910339d6d Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 19:20:57 -0300 Subject: [PATCH 22/31] chore(video-conf): stop inferring the chat-sharing mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `share-chat` already required a `mode` at the endpoint; the service still accepted it missing, so `resolveChatAccessMode` kept a fallback that picked whichever action leads. Nothing reached it, and an inferred choice is the wrong default for this in any case: both ways give something away, so the one taken should be the one somebody saw and chose. `shareChatWithMembers` and `resolveChatAccessMode` now require the mode, which leaves the latter as the single rule worth sharing — an invite a room can't take is refused rather than quietly turned into a discussion. Which of the two leads is still `chatAccessLeadsWithDiscussion`, where the modal reads it to decide what to offer. --- apps/meteor/lib/videoConference/chatAccess.ts | 30 ++++++------------- .../services/video-conference/service.ts | 8 ++--- .../lib/videoConference/chatAccess.spec.ts | 16 ++++------ .../src/types/IVideoConfService.ts | 2 +- 4 files changed, 20 insertions(+), 36 deletions(-) diff --git a/apps/meteor/lib/videoConference/chatAccess.ts b/apps/meteor/lib/videoConference/chatAccess.ts index a1ae4c424c6b2..8c8e0c2ded35a 100644 --- a/apps/meteor/lib/videoConference/chatAccess.ts +++ b/apps/meteor/lib/videoConference/chatAccess.ts @@ -13,41 +13,29 @@ export const hasConferenceChatAccess = ( ): boolean => !uid || !access?.membersWithoutAccess.includes(uid); /** - * Which way of giving the missing members access should lead — be the primary action, and the one taken when - * no choice is made. + * Which way of giving the missing members access should lead — be the primary action offered. * * Both give something away: inviting exposes the room's whole history to someone outside it, while moving the * chat to a discussion leaves the earlier history behind for everyone already there. Exposing a *private* * room's history is the bigger step, so private rooms and DMs lead with the discussion and public rooms — * whose history is already open — lead with the invite. A room that can't take new members at all leaves the * discussion as the only option. - * - * Shared so the modal's primary button and the server's default can't drift apart. */ export const chatAccessLeadsWithDiscussion = ({ canInvite, type }: { canInvite: boolean; type: IRoom['t'] }): boolean => !canInvite || type === 'p' || type === 'd'; /** - * The mode to act on, given what the caller asked for and what the room allows. `undefined` means "you - * decide". Returns `null` when the caller asked for something the room can't do, which is a refusal rather - * than a reason to silently do the other thing — it would give away history nobody agreed to give away. + * The mode to act on, given what the caller asked for and what the room allows. + * + * The caller always asks for one: which way leads is shown to whoever is choosing (see + * `chatAccessLeadsWithDiscussion`), so there is nothing left for this to infer. Returns `null` when the room + * can't do what was asked, which is a refusal rather than a reason to silently do the other thing — that would + * give away history nobody agreed to give away. */ export const resolveChatAccessMode = ({ mode, canInvite, - type, }: { - mode: VideoConferenceChatAccessMode | undefined; + mode: VideoConferenceChatAccessMode; canInvite: boolean; - type: IRoom['t']; -}): VideoConferenceChatAccessMode | null => { - if (mode === 'invite' && !canInvite) { - return null; - } - - if (mode) { - return mode; - } - - return chatAccessLeadsWithDiscussion({ canInvite, type }) ? 'discussion' : 'invite'; -}; +}): VideoConferenceChatAccessMode | null => (mode === 'invite' && !canInvite ? null : mode); diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 034d53e4af427..f41820a4c489c 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1835,22 +1835,22 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf /** * Gives every member who can't read the chat access to it, either by bringing them into the room — which * exposes its whole history — or by moving the chat to a discussion. Both are lossy in different ways, so - * the caller chooses; without a choice, the room's own rules decide. Returns the room the chat now lives in. + * the caller says which; nothing here infers one. Returns the room the chat now lives in. */ public async shareChatWithMembers( uid: IUser['_id'], callId: VideoConference['_id'], - mode?: VideoConferenceChatAccessMode, + mode: VideoConferenceChatAccessMode, ): Promise { const { - access: { rid, membersWithoutAccess, canInvite, type }, + access: { rid, membersWithoutAccess, canInvite }, usernamesWithoutAccess: usernames, } = await this.resolveChatAccess(uid, callId); if (!membersWithoutAccess.length) { return rid; } - const resolved = resolveChatAccessMode({ mode, canInvite, type }); + const resolved = resolveChatAccessMode({ mode, canInvite }); if (!resolved) { throw new Error('error-not-allowed'); } diff --git a/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts index a1097a3472c08..8120fc551db91 100644 --- a/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts +++ b/apps/meteor/tests/unit/lib/videoConference/chatAccess.spec.ts @@ -23,24 +23,20 @@ describe('videoConference chat access', () => { describe('resolveChatAccessMode', () => { it('honours an explicit choice the room can carry out', () => { - expect(resolveChatAccessMode({ mode: 'invite', canInvite: true, type: 'c' })).to.equal('invite'); - expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true, type: 'c' })).to.equal('discussion'); + expect(resolveChatAccessMode({ mode: 'invite', canInvite: true })).to.equal('invite'); + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true })).to.equal('discussion'); }); + // Which of the two leads is the modal's business, not this one's: a discussion asked for by someone + // looking at a room that could have taken the members instead is still a discussion. it('honours a discussion even for a room that could have taken the members instead', () => { - expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true, type: 'p' })).to.equal('discussion'); + expect(resolveChatAccessMode({ mode: 'discussion', canInvite: true })).to.equal('discussion'); }); // Falling back to the discussion would move the whole conversation on the strength of a request the // room can't honour. Refusing leaves the decision with whoever asked. it('refuses an invite the room cannot take, rather than quietly doing the other thing', () => { - expect(resolveChatAccessMode({ mode: 'invite', canInvite: false, type: 'd' })).to.be.null; - }); - - it('falls back to whichever action leads when no choice is made', () => { - expect(resolveChatAccessMode({ mode: undefined, canInvite: true, type: 'c' })).to.equal('invite'); - expect(resolveChatAccessMode({ mode: undefined, canInvite: true, type: 'p' })).to.equal('discussion'); - expect(resolveChatAccessMode({ mode: undefined, canInvite: false, type: 'd' })).to.equal('discussion'); + expect(resolveChatAccessMode({ mode: 'invite', canInvite: false })).to.be.null; }); }); }); diff --git a/packages/core-services/src/types/IVideoConfService.ts b/packages/core-services/src/types/IVideoConfService.ts index 8b34ce5078b73..3c7867630f868 100644 --- a/packages/core-services/src/types/IVideoConfService.ts +++ b/packages/core-services/src/types/IVideoConfService.ts @@ -62,7 +62,7 @@ export interface IVideoConfService { ringMember(uid: IUser['_id'], callId: VideoConference['_id'], memberId: IUser['_id']): Promise; listJoinableCalls(uid: IUser['_id']): Promise; getChatAccess(uid: IUser['_id'], callId: VideoConference['_id']): Promise; - shareChatWithMembers(uid: IUser['_id'], callId: VideoConference['_id'], mode?: VideoConferenceChatAccessMode): Promise; + shareChatWithMembers(uid: IUser['_id'], callId: VideoConference['_id'], mode: VideoConferenceChatAccessMode): Promise; renameCall(uid: IUser['_id'], callId: VideoConference['_id'], title: string): Promise; createVoIP(data: InsertionModel): Promise; From b2c19bc8815fdb93265ec7df912689996afc09ba Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 19:46:04 -0300 Subject: [PATCH 23/31] chore(video-conf): correct two comments the review caught out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded-join comment credited `runOnUserJoinEvent` with the roster entry. It doesn't add one — it is the apps-engine provider event. The entry comes from the `onJoinVideoConference` callback fired a couple of lines above, for every provider alike. The ringing spec's note said the recipient guard was exercised by a test below; the test that pinned the limit boundary moved to the shared-lib spec when ringing became one member at a time. What still needs the real implementation is the add path, which rings through it — so the note now says that instead. --- apps/meteor/server/services/video-conference/service.ts | 7 +++---- .../unit/server/services/video-conference/ringing.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index f41820a4c489c..65a6683c19928 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -976,10 +976,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runOnUserJoinEvent(call._id, user as IVideoConferenceUser); - // Embedded providers (LiveKit) don't return a URL — the client mounts - // the call inline via the embedded provider's React tree. The join is - // already on the roster from `runOnUserJoinEvent` above, so returning an - // empty string is all that's left: it tells the client there's no URL to open. + // Embedded providers (LiveKit) don't return a URL — the client mounts the call inline via the embedded + // provider's React tree, so the empty string is what tells it there is nothing to open. The roster + // entry is the `onJoinVideoConference` callback's doing, fired above for every provider alike. if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { if (user) { await this.notifyUsersOfRoom(call.rid, user._id, 'started', { diff --git a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts index 082ba3c676368..a89fe7ec080f1 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/ringing.spec.ts @@ -56,9 +56,9 @@ const UsersMock = { const broadcastStub = sinon.stub().resolves(); -// Deliberately NOT overriding '../../../lib/videoConference/constants' — the recipient-limit guard -// (`shouldRingRecipients`, capped at `RING_RECIPIENTS_LIMIT`) is what one of the tests below exercises, -// so it has to be the real implementation. +// Deliberately NOT overriding '../../../lib/videoConference/constants' — an add rings through +// `shouldRingRecipients`, so stubbing it would quietly suppress the ring the add tests below assert. Where the +// limit itself falls is pinned on the guard in tests/unit/lib/videoConference/memberStatus.spec.ts. const VideoConfService = createService({ broadcast: broadcastStub, models: { VideoConference: VideoConferenceModelMock, Users: UsersMock }, From 18058e49be1c83e5ebf8d7ce088dc6e4ae05d945 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Fri, 28 Aug 2026 21:21:56 -0300 Subject: [PATCH 24/31] chore(video-conf): leave the chat mode to the PR that introduces the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VideoConf_Persistent_Chat_Mode` only describes something a workspace can have once the call window exists, so it belongs to the PR that turns the window on, not to this one. Registering it here changed behaviour for every workspace that already runs persistent chat: with the mode defaulting to a thread, they stopped getting the discussion per call they get today. Six of develop's own API tests say so, and they were right to. Unregistered, `getPersistentChatMode` now answers `main_room` — the discussion this has always created — so this PR is once again a no-op for anyone. The thread paths stay, unreachable until the setting exists, the same way the rest of the embedded lifecycle here waits on a provider that declares it. --- apps/meteor/ee/server/settings/video-conference.ts | 14 -------------- .../server/services/video-conference/service.ts | 10 ++++++++-- packages/i18n/src/locales/en.i18n.json | 4 ---- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/apps/meteor/ee/server/settings/video-conference.ts b/apps/meteor/ee/server/settings/video-conference.ts index ea11b28ce3173..81ff49e49a6d2 100644 --- a/apps/meteor/ee/server/settings/video-conference.ts +++ b/apps/meteor/ee/server/settings/video-conference.ts @@ -44,20 +44,6 @@ export function addSettings(): Promise { const persistentChatEnabled = { _id: 'VideoConf_Enable_Persistent_Chat', value: true }; - // A thread off the call message is the mode we recommend, and nothing is being migrated away from - // the other one: the setting is new, so no workspace had a persistent chat under it to preserve. - await this.add('VideoConf_Persistent_Chat_Mode', 'thread', { - type: 'select', - values: [ - { key: 'main_room', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Main_Room' }, - { key: 'thread', i18nLabel: 'VideoConf_Persistent_Chat_Mode_Thread' }, - ], - public: true, - invalidValue: 'thread', - i18nDescription: 'VideoConf_Persistent_Chat_Mode_Description', - enableQuery: [persistentChatEnabled], - }); - await this.add('VideoConf_Persistent_Chat_Discussion_Name', 'Video Call Chat', { type: 'string', public: true, diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 65a6683c19928..26ee198ef2c21 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1913,9 +1913,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return settings.get('VideoConf_Enable_Persistent_Chat') && settings.get('Discussion_enabled') && !encryptionEnforced; } + /** + * Where a call's persistent chat lives. + * + * The setting that chooses is registered by the PR that introduces the call window, because that is the only + * thing a mode other than `main_room` describes. Unregistered — which is every workspace until then — this + * answers `main_room`, the discussion off the room that persistent chat has always created. + */ private getPersistentChatMode(): 'thread' | 'main_room' { - // Matches the setting's own default, so an unset value behaves the same as a freshly registered one. - return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'thread'; + return (settings.get('VideoConf_Persistent_Chat_Mode') as 'thread' | 'main_room') || 'main_room'; } /** diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 6c647c874418b..6566bcd2d2ae5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -7142,10 +7142,6 @@ "VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.", "VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name", "VideoConf_Persistent_Chat_Discussion_Name_Description": "Use [date] tag to set where to include the date. Date will be added to the start if tag is not included.", - "VideoConf_Persistent_Chat_Mode": "Chat Mode", - "VideoConf_Persistent_Chat_Mode_Description": "Thread opens a thread from the call message in the original channel. Main room shows the channel itself in the chat panel.", - "VideoConf_Persistent_Chat_Mode_Main_Room": "Main room", - "VideoConf_Persistent_Chat_Mode_Thread": "Thread", "videoconf-ring-users": "Ring Other Users When Calling", "videoconf-ring-users_description": "Permission to ring other users when calling", "Videos": "Videos", From 4de8bbb78931fd08dbc89450681e18a98d3b700f Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 29 Aug 2026 16:21:15 -0300 Subject: [PATCH 25/31] fix(video-conf): don't push the callee twice when the caller arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ringing on the caller's arrival pushes the callee, and the same arrival flips the call to STARTED, which pushes everyone in the room — one call, two notifications, moments apart. `ringCalleeOnCallerArrival` now says whether it rang, and `updateDirectCall` skips the bulk push when it did. Every other path into `updateDirectCall` is unaffected, so a call that rang at creation still pushes as it always has. Unreachable until a provider declares `embedded`, like the rest of the join-side lifecycle here — but it is this code's bug, so it is fixed here rather than in the branch that first makes it reachable. --- .../services/video-conference/service.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 26ee198ef2c21..833eb8c013448 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1230,10 +1230,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf if (call.type === 'direct') { // The ring-on-arrival dance belongs to the embedded flow, where the caller sits on a preflight screen // first; a non-embedded direct call already rang its callee (and pushed) when it was created. - if (isEmbedded) { - await this.ringCalleeOnCallerArrival(call, _id); - } - return this.updateDirectCall(call, _id); + const rang = isEmbedded ? await this.ringCalleeOnCallerArrival(call, _id) : false; + + return this.updateDirectCall(call, _id, { pushed: rang }); } this.notifyVideoConfUpdate(call.rid, call._id); @@ -1488,15 +1487,18 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * * Only members who have never been rung, so rejoining doesn't ring anyone again; the call window's own * "ring again" is how a second attempt is asked for. + * + * Says whether it rang, because the caller arriving is also what starts the call — and starting a direct + * call pushes everyone in the room. Both would reach the same phone, a moment apart, about one call. */ - private async ringCalleeOnCallerArrival(call: IDirectVideoConference, uid: IUser['_id']): Promise { + private async ringCalleeOnCallerArrival(call: IDirectVideoConference, uid: IUser['_id']): Promise { if (call.createdBy._id !== uid) { - return; + return false; } const absent = call.users.filter((user) => user._id !== uid && isUnaskedConferenceMember(user)); if (!absent.length) { - return; + return false; } await this.ringUsers( @@ -1508,6 +1510,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // The in-product ring only reaches a client that is on screen; a direct call is also worth a push. await Promise.all(absent.map(({ _id }) => this.sendPushNotification(call, _id))); + + return true; } /** @@ -1882,7 +1886,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await VideoConferenceModel.increaseAnonymousCount(call._id); } - private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id']): Promise { + private async updateDirectCall(call: IDirectVideoConference, newUserId: IUser['_id'], { pushed = false } = {}): Promise { // If it's an user that hasn't joined yet — a member who was added but never joined still counts as not // having joined, so the ring must keep going for them. if (call.ringing && !call.users.some(({ _id, joined }) => _id === newUserId && hasJoinedVideoConference({ joined }))) { @@ -1902,7 +1906,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf this.notifyVideoConfUpdate(call.rid, call._id); await this.runVideoConferenceChangedEvent(call._id); - await this.sendAllPushNotifications(call._id); + + // Unless the callee's phone has just been pushed by the ring on the caller's arrival: this is that same + // arrival, so the bulk push would be the second notification about one call in as many moments. + if (!pushed) { + await this.sendAllPushNotifications(call._id); + } } private isPersistentChatEnabled(): boolean { From 17bda0cdaa16326c092a2af9f11c7d2bbe2f55f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 29 Aug 2026 17:00:22 -0300 Subject: [PATCH 26/31] fix(video-conf): report a departure once, however many times it is reported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving is reported more than once by design: the call window says so as it closes, and whatever opened that window says so again if it vanished without managing to. The second report re-stamped `leftAt` and broadcast a roster change that changed nothing — so a departure could be moved minutes later, to whenever the fallback happened to notice. `leaveCall` now returns as soon as it sees a member already recorded as gone. Rejoining clears `leftAt`, so a later departure is still recorded. --- .../services/video-conference/service.ts | 7 ++++- .../video-conference/leaveCall.spec.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 833eb8c013448..ee7cc2faee4fc 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1537,7 +1537,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return; } - if (!call.users.some(({ _id }) => _id === uid)) { + // Already recorded as gone, so there is nothing to record and nobody to tell. Leaving is reported more + // than once by design — the call window says so as it closes, and whatever opened it says so again if + // that window vanished without managing to — and re-stamping would move a departure that already + // happened and broadcast a roster change nothing changed. + const member = call.users.find(({ _id }) => _id === uid); + if (!member || member.leftAt) { return; } diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index 6541d496df8d4..d5a78a9128b97 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -287,3 +287,32 @@ describe('VideoConfService one call at a time', () => { }); }); }); + +// Leaving is reported more than once by design: the call window says so as it closes, and whatever opened it +// says so again if that window vanished without managing to. The second report must change nothing — a moved +// `leftAt` would rewrite when someone left, and the broadcast would announce a roster change that didn't happen. +describe('VideoConfService.leaveCall reported twice', () => { + let service: any; + + beforeEach(() => { + service = new VideoConfService(); + }); + + it('records the departure once and says nothing the second time', async () => { + fixture = buildGroupCall([buildMember({ _id: 'stays' }), buildMember({ _id: 'goes' })]); + + await service.leaveCall('goes', 'call1'); + + const leftAt = fixture.users.find(({ _id }) => _id === 'goes')?.leftAt; + expect(leftAt).to.be.an.instanceOf(Date); + + const writes = VideoConferenceModelMock.setUserLeftById.callCount; + const broadcasts = broadcastStub.callCount; + + await service.leaveCall('goes', 'call1'); + + expect(VideoConferenceModelMock.setUserLeftById.callCount, 'wrote the departure again').to.equal(writes); + expect(broadcastStub.callCount, 'announced the departure again').to.equal(broadcasts); + expect(fixture.users.find(({ _id }) => _id === 'goes')?.leftAt).to.equal(leftAt); + }); +}); From ef7305ff9d0bd3502a7bda23a7df027d25e8e275 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 1 Sep 2026 18:22:46 -0300 Subject: [PATCH 27/31] refactor(video-conf): ask "is this provider embedded" through a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same capability lookup appeared a dozen times in the service, positively and negatively, of a call and of a provider name. It is a claim about where the media runs, and half the rules in the file turn on it, so it is worth a name: `isEmbeddedProvider`, with `supportsPersistentChat` beside it for the other one. Also says what the joinable endpoint's rate limit is really for: discovery is event-driven, and the poll behind it is the fallback for what an event cannot cover — which is the question the comment invited. Co-Authored-By: Claude Opus 5 --- apps/meteor/server/api/v1/videoConference.ts | 4 +- .../services/video-conference/service.ts | 46 +++++++++++++------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index 7b5983f9212d3..abe54af6177ff 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -592,7 +592,9 @@ API.v1.get( 'video-conference.joinable', { authRequired: true, - // Polled by the sidebar, so it has to tolerate a steady trickle. + // Discovery is event-driven: a per-user `video-conference` event — a ring, an arrival, a call ending — + // is what sends a client back here. The poll behind it is a fallback for what an event cannot cover, + // a missed message or a reconnection, so this has to tolerate a steady trickle as well as bursts. rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 }, response: { 200: joinableResponseSchema, diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index ee7cc2faee4fc..e838c6be062c3 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -515,6 +515,22 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf * is one signal rather than three: the call window needs it to know whether it is still waiting on anyone, and * a participant's chat panel needs it to follow the chat. */ + /** + * Whether the provider runs the call inside Rocket.Chat rather than at a page of its own. + * + * A question asked all over this file — of a call, of a provider name, positively and negatively — and one + * worth asking through a name, because "embedded" is a claim about where the media runs and half the rules + * here turn on it. + */ + private isEmbeddedProvider(providerName: string): boolean { + return videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + } + + /** Whether the provider supports a chat that outlives the call — see `maybeCreateDiscussion`. */ + private supportsPersistentChat(providerName: string): boolean { + return videoConfProviders.getProviderCapabilities(providerName)?.persistentChat === true; + } + private notifyConferenceUpdate(callId: VideoConference['_id']): void { void api.broadcast('video-conference.updated', { callId }); } @@ -529,7 +545,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runVideoConferenceChangedEvent(call._id); this.notifyVideoConfUpdate(call.rid, call._id); - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { await this.notifyUsersOfRoom(call.rid, '', 'end', { callId: call._id, rid: call.rid, @@ -622,7 +638,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Enabled + URL + API key + secret). Their presence in the registry // IS the "fully configured" signal. Going through the apps-engine // manager would fail because there's no app behind them. - if (videoConfProviders.getProviderCapabilities(providerName)?.embedded) { + if (this.isEmbeddedProvider(providerName)) { return; } const manager = await this.getProviderManager(); @@ -800,7 +816,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf await this.runNewVideoConferenceEvent(callId); - const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + const isEmbedded = this.isEmbeddedProvider(providerName); // Being called makes you a member, exactly as being added to a group conference does. Without this the // callee only appears once they answer, so nothing can tell "still ringing" from "nobody was called", @@ -908,7 +924,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // no URL handoff. Skip both URL generation and ringing notifications: // the call shows up as an "active call" banner in the room and other // participants tap to join. No incoming-call sound/modal. - const isEmbedded = videoConfProviders.getProviderCapabilities(providerName)?.embedded === true; + const isEmbedded = this.isEmbeddedProvider(providerName); if (!isEmbedded) { const url = await this.generateNewUrl(call); await VideoConferenceModel.setUrlById(callId, url); @@ -979,7 +995,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Embedded providers (LiveKit) don't return a URL — the client mounts the call inline via the embedded // provider's React tree, so the empty string is what tells it there is nothing to open. The roster // entry is the `onJoinVideoConference` callback's doing, fired above for every provider alike. - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { if (user) { await this.notifyUsersOfRoom(call.rid, user._id, 'started', { callId: call._id, @@ -1127,7 +1143,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Embedded (built-in) providers have no apps-engine app behind them, // so the provider-manager dispatch would be a no-op at best and // throw at worst. Skip the lifecycle hook for them. - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { return; } @@ -1149,7 +1165,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { return; } @@ -1171,7 +1187,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf throw new Error('video-conf-provider-unavailable'); } - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { return; } @@ -1191,7 +1207,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // The whole join-side lifecycle below only exists for embedded providers. A non-embedded call (Jitsi, // Meet, ...) has no leave, no heartbeat and no sweep — nothing would ever undo what gets claimed here — // so for those a join must do what it always did: record the member, and nothing else. - const isEmbedded = videoConfProviders.getProviderCapabilities(call.providerName)?.embedded === true; + const isEmbedded = this.isEmbeddedProvider(call.providerName); // A user is in one call at a time, and this is where that becomes true rather than hoped for. A window that // dies without reporting its departure — a crash, a killed tab — otherwise leaves its user counted as @@ -1551,7 +1567,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf this.notifyVideoConfUpdate(call.rid, callId); this.notifyConferenceUpdate(callId); - if (videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (this.isEmbeddedProvider(call.providerName)) { // Only the leaver's own devices are told 'end', so their other windows stop showing a call they are no // longer in. Never the room: one member leaving is not the call ending — a reload fires a leave too — // and a room-wide 'end' from here would dismiss everyone else's ringing popup and silence the caller's @@ -1641,7 +1657,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return; } - if (videoConfProviders.getProviderCapabilities(renewal.providerName)?.embedded) { + if (this.isEmbeddedProvider(renewal.providerName)) { await this.claimBusyForCall(uid); } this.notifyConferenceUpdate(callId); @@ -1668,7 +1684,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Non-embedded providers (Jitsi, Meet, Pexip) open in an iframe/popup we don't control — no heartbeat // is sent, so every lease would look expired and the sweep would end every call after 3 minutes. // Those calls are cleaned up by the 24-hour TTL cron instead, exactly as they were before leases existed. - if (!videoConfProviders.getProviderCapabilities(call.providerName)?.embedded) { + if (!this.isEmbeddedProvider(call.providerName)) { continue; } @@ -1952,7 +1968,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf // Same rule as `maybeCreateDiscussion`: persistent chat is only acted on for a provider that declares // support for it — a Jitsi call must not start following threads because the setting is on. - if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + if (!this.supportsPersistentChat(call.providerName)) { return; } @@ -1975,7 +1991,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } // Same rule as `maybeCreateDiscussion`: only for a provider that declares persistent chat support. - if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + if (!this.supportsPersistentChat(call.providerName)) { return; } @@ -2004,7 +2020,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } // If the call provider does not explicitly support persistent chat, do not create discussions - if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) { + if (!this.supportsPersistentChat(call.providerName)) { return; } From f605dfcc468cbe4e5eb5e18c52d1583141427ccf Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 9 Sep 2026 11:55:08 -0300 Subject: [PATCH 28/31] fix(video-conf): tell a call's own members when it ends, not just its room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-of-call broadcast walked the room's subscriptions, and being added to a conference grants no room access — the third person in a DM call is a member of the call with no subscription to the DM it started in. So the one person who could not discover the ending any other way was the one the broadcast skipped, and their window went on showing a call that was over. The call's membership is added to the room's now, the way `assignDiscussionToConference` already does it when the chat moves. Only the end is broadcast this way: `ring` and `started` are about a call appearing in a room, and someone outside that room learns of it by being rung rather than by watching the room. Found in review by @sampaiodiego. Co-Authored-By: Claude Opus 5 --- .../services/video-conference/service.ts | 24 ++++++++++++++++++- .../video-conference/busyStatus.spec.ts | 1 + .../video-conference/leaveCall.spec.ts | 18 ++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index e838c6be062c3..17b16ae09e641 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -546,7 +546,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf this.notifyVideoConfUpdate(call.rid, call._id); if (this.isEmbeddedProvider(call.providerName)) { - await this.notifyUsersOfRoom(call.rid, '', 'end', { + await this.notifyCallAndRoomUsers(call, 'end', { callId: call._id, rid: call.rid, uid: call.createdBy._id, @@ -878,6 +878,28 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf }; } + /** + * Everyone the end of a call concerns, which is not the same as everyone in its room. + * + * Being added to a conference grants no room access — the third person in a DM call is a member of the call + * with no subscription to the DM it started in — so a broadcast that walks subscriptions alone never reached + * them, and their window went on showing a call that had ended. Their own membership is added to the room's, + * the way `assignDiscussionToConference` does it when the chat moves. + * + * Only the end is broadcast this way. `ring` and `started` are about a call appearing in a room, and someone + * outside that room learns of it by being rung rather than by watching the room. + */ + private async notifyCallAndRoomUsers( + call: AtLeast, + action: string, + params: { uid: IUser['_id']; rid: IRoom['_id']; callId: VideoConference['_id'] }, + ): Promise { + const roomMemberIds = (await Subscriptions.findByRoomId(call.rid, { projection: { 'u._id': 1 } }).toArray()).map(({ u }) => u._id); + const recipients = new Set([...roomMemberIds, ...call.users.map(({ _id }) => _id)]); + + recipients.forEach((userId) => this.notifyUser(userId, action, params)); + } + private async notifyUsersOfRoom( rid: IRoom['_id'], uid: IUser['_id'], diff --git a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts index ee53269733e4e..6029d6e2a1269 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/busyStatus.spec.ts @@ -81,6 +81,7 @@ const { VideoConfService } = proxyquire.noCallThru().load('../../../../../server Messages: { setBlocksById: sinon.stub().resolves() }, Subscriptions: { findByRoomIdAndNotUserId: sinon.stub().returns({ toArray: sinon.stub().resolves([]), forEach: sinon.stub().resolves() }), + findByRoomId: sinon.stub().returns({ toArray: sinon.stub().resolves([]) }), }, }, '../../../lib/videoConference/constants': { availabilityErrors: {}, shouldRingRecipients: () => false, CALL_FACES_SHOWN: 2 }, diff --git a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts index d5a78a9128b97..b0bf6592c1e37 100644 --- a/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts +++ b/apps/meteor/tests/unit/server/services/video-conference/leaveCall.spec.ts @@ -54,6 +54,9 @@ const VideoConfService = createService({ return Promise.resolve(); }, }), + // The same room read as a list, which is how the end-of-call broadcast asks for it: it adds the call's + // own members to these, and a member with no subscription here is the case that needs the union. + findByRoomId: sinon.stub().returns({ toArray: sinon.stub().resolves([{ u: { _id: 'other' } }]) }), }, }, // This suite is about what happens when a call empties, so the ringing the service would otherwise do on a @@ -213,6 +216,21 @@ describe('VideoConfService.leaveCall', () => { expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); expect(endNotifiedUserIds()).to.include('other'); }); + + // Being added to a conference grants no room access, so a member can have no subscription to the room the + // call started in — the third person in a DM call is exactly that. A broadcast that walked subscriptions + // alone never reached them, and their window went on showing a call that had ended. + it('tells a member with no subscription to the room that the call ended', async () => { + providerCapabilities.current = { embedded: true }; + // Added to the call and still ringing, so the call empties when the creator goes: `invited` is in the + // membership without ever having been in the room. + fixture = buildGroupCall([buildMember({ _id: 'creator' }), buildMember({ _id: 'invited', joined: false })]); + + await leaveAndSettle('creator'); + + expect(fixture.status).to.equal(VideoConferenceStatus.ENDED); + expect(endNotifiedUserIds()).to.include('invited'); + }); }); describe('VideoConfService one call at a time', () => { From c54b4723738de51ca1f522bcc4c0f4c6345e506a Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 9 Sep 2026 11:55:26 -0300 Subject: [PATCH 29/31] fix(video-conf): announce a renamed call on the conference stream too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming told the room and nothing else. A call window watches the conference rather than the room it started in, so the window showing the call — the one place the name is most visible — was the one place that never learned it had changed. Found in review by @sampaiodiego. It is also what made a rename look like a dead stream while debugging the window's own updates. Co-Authored-By: Claude Opus 5 --- apps/meteor/server/services/video-conference/service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index 17b16ae09e641..ad14099f28674 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -1875,6 +1875,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf } await VideoConferenceModel.setTitleById(callId, name); + // Both streams: the room's message block shows the name, and so does the call window, which watches the + // conference rather than the room it started in. + this.notifyConferenceUpdate(callId); this.notifyVideoConfUpdate(call.rid, callId); } From 3536a06047f8bd8bef464c4febc51794d9902ebb Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 9 Sep 2026 11:55:26 -0300 Subject: [PATCH 30/31] fix(video-conf): drop the share-chat endpoint's unreliable error mapping The endpoint mapped `error-not-allowed` to a 403 by comparing `e.message`, which only ever matched the service's own plain `Error`: `addUsersToRoomMethod` throws a `Meteor.Error`, whose message reads `Not allowed [error-not-allowed]`, so the very failure most worth answering with a 403 fell through instead. Rather than fix the comparison, the block goes: any failure here answers 400, which is what the endpoint did for every Meteor.Error already. The endpoint is additive and no client calls it yet, so nothing depended on the 403. Found in review by @sampaiodiego. Co-Authored-By: Claude Opus 5 --- apps/meteor/server/api/v1/videoConference.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/meteor/server/api/v1/videoConference.ts b/apps/meteor/server/api/v1/videoConference.ts index abe54af6177ff..8035df8c3c460 100644 --- a/apps/meteor/server/api/v1/videoConference.ts +++ b/apps/meteor/server/api/v1/videoConference.ts @@ -537,16 +537,7 @@ API.v1.post( return API.v1.failure('invalid-params'); } - // The service refuses a mode the room can't do, and discussion creation the caller isn't permitted — - // authorization answers, not failures, so they map to 403 rather than surfacing as internal errors. - try { - return API.v1.success({ rid: await VideoConf.shareChatWithMembers(conference.userId, callId, mode) }); - } catch (e) { - if (e instanceof Error && e.message === 'error-not-allowed') { - return API.v1.forbidden('Not allowed'); - } - throw e; - } + return API.v1.success({ rid: await VideoConf.shareChatWithMembers(conference.userId, callId, mode) }); }, ); From 79b1f421db5e5da160769661965972b6a3e44f52 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 9 Sep 2026 11:57:12 -0300 Subject: [PATCH 31/31] chore(video-conf): answer the review on the changeset, a stray comment and a redundant type Three notes from review, none of them behaviour: - The changeset is `minor` and lists the endpoints. New REST surface is not a patch, even when nothing calls it yet and no provider turns the behaviour on. Raised by @sampaiodiego. - A doc comment had been orphaned by the helper extraction: `isEmbeddedProvider` and `supportsPersistentChat` landed between the comment and the function it describes. It belongs to `notifyConferenceUpdate` rather than to `notifyVideoConfUpdate` above it, and now sits there. Raised by @sampaiodiego. - One `Users.findOneById` still passed a returning type the model infers from the projection. Suggested by @sampaiodiego. Co-Authored-By: Claude Opus 5 --- .changeset/videoconf-conference-data-model.md | 40 ++++++++++++++----- .../services/video-conference/service.ts | 14 +++---- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/.changeset/videoconf-conference-data-model.md b/.changeset/videoconf-conference-data-model.md index 39e4e31c527ce..00bbd8db7b07e 100644 --- a/.changeset/videoconf-conference-data-model.md +++ b/.changeset/videoconf-conference-data-model.md @@ -1,16 +1,34 @@ --- -'@rocket.chat/core-services': patch -'@rocket.chat/core-typings': patch -'@rocket.chat/model-typings': patch -'@rocket.chat/rest-typings': patch -'@rocket.chat/ddp-client': patch -'@rocket.chat/models': patch -'@rocket.chat/i18n': patch -'@rocket.chat/meteor': patch +'@rocket.chat/core-services': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ddp-client': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor --- -Groundwork for the video conference window: no user-facing change. +Groundwork for the video conference window: new API, no change to how any call behaves today. -Conference records gain per-member lifecycle fields (joined, declined, left, last seen, ringing) and the service gains the operations a call window needs — leaving, heartbeats, ringing again, declining, adding participants, renaming, resolving where the call's chat lives — behind new REST endpoints. A cron sweeps presence leases so a call whose participants vanish is closed rather than left running. +Conference records gain per-member lifecycle fields (joined, declined, left, last seen, ringing) and the service +gains the operations a call window needs, behind new REST endpoints: -All of the new behaviour is reserved for providers whose call renders inside Rocket.Chat, identified by an `embedded` capability. No provider registers that capability yet, so on any existing workspace every one of these paths is skipped and calls placed through Jitsi, Google Meet, BBB or Pexip behave exactly as before. The endpoints are additive and no client calls them yet. +| Endpoint | What it does | +| --- | --- | +| `POST /v1/video-conference.leave` | Records that the caller left a call, ending it when nobody is left in it | +| `POST /v1/video-conference.heartbeat` | Renews the caller's presence lease, so a participant whose client vanishes is treated as gone | +| `POST /v1/video-conference.decline` | Records that the caller turned a call down, without ending it for anyone else | +| `POST /v1/video-conference.ring` | Rings a member who has not answered yet, again | +| `POST /v1/video-conference.rename` | Renames a running group call, for the person who started it | +| `POST /v1/video-conference.add-participants` | Adds people to a call in progress, and rings them | +| `POST /v1/video-conference.share-chat` | Gives members who cannot read the call's chat access to it | +| `GET /v1/video-conference.joinable` | Lists the calls the caller could still join | + +A cron sweeps presence leases so a call whose participants vanish is closed rather than left running, and a +`video-conference` stream carries per-conference updates to whoever is watching one. + +All of the new behaviour is reserved for providers whose call renders inside Rocket.Chat, identified by an +`embedded` capability. No provider registers that capability yet, so on any existing workspace every one of +these paths is skipped and calls placed through Jitsi, Google Meet, BBB or Pexip behave exactly as before. The +endpoints are additive and no client calls them yet. diff --git a/apps/meteor/server/services/video-conference/service.ts b/apps/meteor/server/services/video-conference/service.ts index ad14099f28674..690299210e903 100644 --- a/apps/meteor/server/services/video-conference/service.ts +++ b/apps/meteor/server/services/video-conference/service.ts @@ -509,12 +509,6 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf void api.broadcast('room.video-conference', { rid, callId }); } - /** - * Tells anyone watching the conference that something about it moved — its membership, its chat's room, or who - * can read that chat. Whichever it was, the answer on the other side is to read the conference again, so this - * is one signal rather than three: the call window needs it to know whether it is still waiting on anyone, and - * a participant's chat panel needs it to follow the chat. - */ /** * Whether the provider runs the call inside Rocket.Chat rather than at a page of its own. * @@ -531,6 +525,12 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf return videoConfProviders.getProviderCapabilities(providerName)?.persistentChat === true; } + /** + * Tells anyone watching the conference that something about it moved — its membership, its chat's room, or who + * can read that chat. Whichever it was, the answer on the other side is to read the conference again, so this + * is one signal rather than three: the call window needs it to know whether it is still waiting on anyone, and + * a participant's chat panel needs it to follow the chat. + */ private notifyConferenceUpdate(callId: VideoConference['_id']): void { void api.broadcast('video-conference.updated', { callId }); } @@ -1630,7 +1630,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf */ private async claimBusyForCall(uid: IUser['_id']): Promise { try { - const user = await Users.findOneById>(uid, { projection: { language: 1 } }); + const user = await Users.findOneById(uid, { projection: { language: 1 } }); const lng = user?.language || settings.get('Language') || 'en'; await Presence.setActiveState(uid, {