Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
aaf5512
feat(video-conf): conference data model, server service & API
rodrigok Aug 25, 2026
46e3e92
fix(video-conf): skip presence lease expiry for non-embedded providers
rodrigok Aug 25, 2026
a6f7842
chore(video-conf): remove jwt, i18n, desktop-api, docs and changeset
rodrigok Aug 25, 2026
30deae3
fix(video-conf): keep embedded call lifecycle away from non-embedded …
rodrigok Aug 25, 2026
20795ad
feat(video-conf): register the VideoConf_Persistent_Chat_Mode setting
rodrigok Aug 25, 2026
f203f2b
fix(video-conf): make the join endpoint's missing-url failure reachable
rodrigok Aug 25, 2026
9ec7924
fix(video-conf): measure the presence-sweep grace from job registration
rodrigok Aug 25, 2026
73df473
fix(models): correct video-conference index, stale leftReason and par…
rodrigok Aug 25, 2026
d64f661
fix(video-conf): scope leave notifications to the leaver and survive …
rodrigok Aug 25, 2026
069c761
fix(video-conf): share canAccessConference with the conference stream…
rodrigok Aug 25, 2026
fdd32e1
chore(video-conf): drop the unused CORE_PROVIDER_APP_ID export
rodrigok Aug 25, 2026
8c87fab
docs(rest-typings): say which identifier the video-conference users f…
rodrigok Aug 25, 2026
86cf00d
fix(video-conf): enforce the ring permission on the new endpoints
rodrigok Aug 25, 2026
89f40c9
fix(video-conf): presence and ringing correctness from the re-review
rodrigok Aug 25, 2026
8376e2b
fix(video-conf): decide presence revival atomically with the renewal …
rodrigok Aug 25, 2026
ffa58bb
test(video-conf): keep the ringing e2e as develop has it
rodrigok Aug 26, 2026
f021b56
fix(i18n): add the conference invite notification string
rodrigok Aug 26, 2026
16072c3
chore(video-conf): add the changeset for the conference data model
rodrigok Aug 26, 2026
35d8b93
chore(video-conf): defer the embedded participants record to LiveKit
rodrigok Aug 28, 2026
ccb56e8
chore(video-conf): answer the review on ringing, chat mode and API co…
rodrigok Aug 28, 2026
6d0b35b
chore(video-conf): name the ring limit after its recipients, ring one…
rodrigok Aug 28, 2026
d391d7f
chore(video-conf): stop inferring the chat-sharing mode
rodrigok Aug 28, 2026
b2c19bc
chore(video-conf): correct two comments the review caught out
rodrigok Aug 28, 2026
18058e4
chore(video-conf): leave the chat mode to the PR that introduces the …
rodrigok Aug 29, 2026
4de8bbb
fix(video-conf): don't push the callee twice when the caller arrives
rodrigok Aug 29, 2026
17bda0c
fix(video-conf): report a departure once, however many times it is re…
rodrigok Aug 29, 2026
ef7305f
refactor(video-conf): ask "is this provider embedded" through a name
rodrigok Sep 1, 2026
f605dfc
fix(video-conf): tell a call's own members when it ends, not just its…
rodrigok Sep 9, 2026
c54b472
fix(video-conf): announce a renamed call on the conference stream too
rodrigok Sep 9, 2026
3536a06
fix(video-conf): drop the share-chat endpoint's unreliable error mapping
rodrigok Sep 9, 2026
79b1f42
chore(video-conf): answer the review on the changeset, a stray commen…
rodrigok Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/videoconf-conference-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@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: 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, behind new REST endpoints:

| 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.
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ 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, ...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,
})),
}),
),
Expand Down
22 changes: 9 additions & 13 deletions apps/meteor/ee/server/configuration/videoConference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { callbacks } from '../../../server/lib/callbacks';
Expand All @@ -26,23 +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<Pick<IRoom, 'uids'>>(_id, { projection: { uids: 1 } });
if (room && (!room.uids || room.uids.length <= 2)) {
return false;
}
}

if ((await Subscriptions.countByRoomId(_id)) > 10) {
return false;
}
// A two-person DM is the `direct` type's, so what rings here is a group DM.
const room = await Rooms.findOneById<Pick<IRoom, 'uids'>>(_id, { projection: { uids: 1 } });

return true;
return Boolean(room?.uids && room.uids.length > 2);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});

callbacks.add('onJoinVideoConference', async (callId: VideoConference['_id'], userId?: IUser['_id']) =>
Expand Down
41 changes: 41 additions & 0 deletions apps/meteor/lib/videoConference/chatAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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 = (
Comment thread
rodrigok marked this conversation as resolved.
access: Pick<VideoConferenceChatAccess, 'membersWithoutAccess'> | undefined,
uid: IUser['_id'] | null | undefined,
): boolean => !uid || !access?.membersWithoutAccess.includes(uid);
Comment thread
rodrigok marked this conversation as resolved.

/**
* 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.
*/
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.
*
* 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,
}: {
mode: VideoConferenceChatAccessMode;
canInvite: boolean;
}): VideoConferenceChatAccessMode | null => (mode === 'invite' && !canInvite ? null : mode);
53 changes: 53 additions & 0 deletions apps/meteor/lib/videoConference/conferenceName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { IRoom, IUser, IVideoConferenceUser, VideoConference } from '@rocket.chat/core-typings';

type NameableConference = {
type: VideoConference['type'];
title?: string;
createdBy: Pick<IUser, '_id'> & Partial<Pick<IUser, 'name' | 'username'>>;
users: (Pick<IVideoConferenceUser, '_id'> & Partial<Pick<IVideoConferenceUser, 'name' | 'username'>>)[];
};

const displayName = (person?: Partial<Pick<IUser, 'name' | 'username'>>): 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)) : '';
};
25 changes: 25 additions & 0 deletions apps/meteor/lib/videoConference/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { RING_RECIPIENTS_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;

/**
* 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 `RING_RECIPIENTS_LIMIT` for why there is a cap. */
export const shouldRingRecipients = (recipientCount: number): boolean => recipientCount > 0 && recipientCount <= RING_RECIPIENTS_LIMIT;
46 changes: 46 additions & 0 deletions apps/meteor/lib/videoConference/memberStatus.ts
Original file line number Diff line number Diff line change
@@ -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<IVideoConferenceUser, 'joined' | 'declined' | 'declinedAt' | 'leftAt' | 'ringingAt'>;

/**
* 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<IVideoConferenceUser, 'joined' | 'declined' | 'ringingAt'>): boolean =>
!member.ringingAt && !hasJoinedVideoConference(member) && !member.declined;
72 changes: 72 additions & 0 deletions apps/meteor/lib/videoConference/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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.
*/

/**
* 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;
Loading
Loading