Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
eab9c1a
Initial import from `feat/persistent-chat-ui` branch
rodrigok Aug 1, 2026
b969b01
refactor(video-conf): replace conference side rail with a bottom call…
rodrigok Aug 1, 2026
56829f7
feat(video-conf): open the conference in a popout window
rodrigok Aug 1, 2026
ba86d76
fix(video-conf): stop the conference flashing the app skeleton while …
rodrigok Aug 1, 2026
1798394
fix(video-conf): tighten the conference chat panel for its width
rodrigok Aug 1, 2026
603439a
docs(video-conf): record the membership-based conference plan
rodrigok Aug 1, 2026
e66b81c
feat(video-conf): make conference membership authorize joining a call
rodrigok Aug 1, 2026
68844b1
feat(video-conf): record conference declines server-side
rodrigok Aug 1, 2026
ffaea9a
test(models): pin the conference membership update shapes
rodrigok Aug 1, 2026
56921b1
feat(video-conf): record group conferences in personal call history
rodrigok Aug 2, 2026
0cf322c
feat(video-conf): surface conference members who cannot see the chat
rodrigok Aug 2, 2026
c94fdbd
docs(video-conf): record implementation notes for the membership work
rodrigok Aug 2, 2026
e93cd7f
fix(video-conf): ring members who can't see the call's room
rodrigok Aug 2, 2026
798d669
fix(video-conf): join outright when accepting a server ring
rodrigok Aug 2, 2026
5f64c52
feat(video-conf): let participants choose how to share the conference…
rodrigok Aug 2, 2026
ee6c438
feat(video-conf): explain a chat that was never shared, instead of "p…
rodrigok Aug 2, 2026
e29cfb7
fix(video-conf): clear the chat-access notice when the chat is shared
rodrigok Aug 2, 2026
b131e95
fix(video-conf): let a member without chat access add participants
rodrigok Aug 2, 2026
7191fda
feat(video-conf): notify users added to a conference
rodrigok Aug 2, 2026
e02cb2b
fix(video-conf): authorize the conference stream on chat access too
rodrigok Aug 2, 2026
07120da
refactor(video-conf): give the chat-access rules one tested home
rodrigok Aug 2, 2026
cc39b91
test(video-conf): cover the ring, decline and chat-access scenarios
rodrigok Aug 2, 2026
4c50c36
docs(video-conf): bring the feature doc back in line with the impleme…
rodrigok Aug 2, 2026
413dd85
docs(video-conf): record what was verified against live data
rodrigok Aug 2, 2026
aaaaf82
fix(video-conf): record conferences that expire, not just ones that end
rodrigok Aug 2, 2026
5188ae0
docs(video-conf): record moving the calling state into the popout
rodrigok Aug 2, 2026
53ca763
feat(video-conf): end a conference when everyone leaves, and log 1:1 …
rodrigok Aug 2, 2026
264caca
feat(video-conf): open the call window when a DM call is placed
rodrigok Aug 2, 2026
c85859f
fix(video-conf): make ringing again actually ring, and overlay the ou…
rodrigok Aug 2, 2026
08c1755
feat(video-conf): show who is on the call, and ring them individually
rodrigok Aug 3, 2026
1d80204
fix(video-conf): don't end a call because someone reloaded the window
rodrigok Aug 3, 2026
e81bf47
perf(video-conf): answer chat access from one subscription read where…
rodrigok Aug 3, 2026
746734c
test(video-conf): cover the service methods and endpoints that had none
rodrigok Aug 3, 2026
cbad8ab
docs(video-conf): consolidate into the current state
rodrigok Aug 3, 2026
ab28482
feat(video-conf): refine the members panel, its badges, and the chat-…
rodrigok Aug 3, 2026
12aab2d
fix(video-conf): show the chat badge, and mark no-access members with…
rodrigok Aug 3, 2026
b7315f6
fix(auth): don't flash the login form at a window resuming a session
rodrigok Aug 3, 2026
8c8eeff
docs(video-conf): plan reaching a call without a ring
rodrigok Aug 3, 2026
5ddb166
feat(video-conf): reach a running call without having caught its ring
rodrigok Aug 3, 2026
29c77fa
feat(video-conf): make every ongoing-call row actionable
rodrigok Aug 3, 2026
a3c5a12
fix(video-conf): stop leaving people in calls they never got into
rodrigok Aug 4, 2026
153b408
feat(video-conf): configure the call before joining it
rodrigok Aug 4, 2026
fa3b6dd
feat(video-conf): ring the callee when the caller enters the call
rodrigok Aug 4, 2026
1655cb6
feat(video-conf): create the conference from the preflight, not the c…
rodrigok Aug 4, 2026
e3d175a
feat(video-conf): describe the camera instead of previewing it
rodrigok Aug 4, 2026
0d42ec2
feat(video-conf): put running calls in the call history itself
rodrigok Aug 5, 2026
62c3503
feat(video-conf): answer a call where the others are listed
rodrigok Aug 6, 2026
d2af0e9
feat(video-conf): say what the preflight is for
rodrigok Aug 6, 2026
4e7b42e
refactor(video-conf): collapse mechanisms that grew a part at a time
rodrigok Aug 6, 2026
1359fee
refactor(video-conf): reuse what the product already has
rodrigok Aug 6, 2026
c58d50c
test(video-conf): let the harness carry what nine specs repeated
rodrigok Aug 6, 2026
3fd3a13
docs(video-conf): correct what the code has since changed, and stop r…
rodrigok Aug 6, 2026
f812e99
chore(changeset): one accurate entry for the persistent-chat work
rodrigok Aug 6, 2026
05cc616
docs(video-conf): give the feature doc a folder, with diagrams and a …
rodrigok Aug 7, 2026
b7403b9
refactor(video-conf): cut the first release down to what it needs
rodrigok Aug 7, 2026
91ba685
refactor(video-conf): use the components the product already has
rodrigok Aug 7, 2026
651d55a
refactor(video-conf): drop what this feature had no business changing
rodrigok Aug 7, 2026
912cdda
test(video-conf): drop the obsolete added-as-joined expectation
rodrigok Aug 12, 2026
cf2985c
feat(video-conf): extract shared conference improvements from native-…
rodrigok Aug 24, 2026
d81a6c2
refactor(video-conf): move native-call UI to the LiveKit branch
rodrigok Aug 24, 2026
9f27d97
feat(video-conf): add conference UI for persistent chat with iframe p…
rodrigok Aug 24, 2026
638a3b6
refactor(video-conf): remove conference call history from persistent …
rodrigok Aug 24, 2026
f9365fb
feat(video-conf): add LiveKit as a native video conference provider
rodrigok Aug 24, 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
17 changes: 17 additions & 0 deletions .changeset/native-livekit-video-conference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/models': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/jwt': minor
'@rocket.chat/ui-voip': minor
'@rocket.chat/meteor': minor
---

Adds LiveKit as a native video conference provider, so a call can run inside Rocket.Chat instead of handing the user to someone else's page.

Every provider until now has been a URL: the workspace knows a call is open and nothing else — not who is in it, not what was said, and certainly not how to record it. A native provider changes what the server can know. Conferences now carry the people actually connected, so the call itself can be the source of who is present rather than an inference from who clicked join.

On top of that: multi-party calls with a grid and spotlight, screen sharing, hand-raise and reactions, and a floating widget that keeps the call with the user when they navigate to another room. Recording runs through LiveKit egress and lands as an ordinary upload posted in a thread under the call's message. Live captions are opt-in per user and come from a worker that joins each room as a hidden participant; with note-taking on, the transcript is kept and an AI summary is posted alongside it when the call ends.

The provider is enterprise, off unless configured, and sits beside the existing URL-based providers rather than replacing them — a workspace with no LiveKit deployment behaves exactly as before.
27 changes: 27 additions & 0 deletions .changeset/videoconf-persistent-chat.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 21 additions & 8 deletions apps/meteor/app/ui/client/lib/UserAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const activityTimeouts = new Map();
const activityRenews = new Map();
const continuingIntervals = new Map();
const roomActivities = new Map<string, Set<string>>();
const rooms = new Map<string, (username: string, activityType: string[], extras?: object | undefined) => void>();
const rooms = new Map<
string,
{ handler: (username: string, activityType: string[], extras?: object | undefined) => void; stop: () => void; refs: number }
>();

const performingUsers = new Map<string, IRoomActivity>();
const performingUsersEmitter = new Emitter<{ changed: void }>();
Expand Down Expand Up @@ -69,8 +72,16 @@ function handleStreamAction(rid: string, username: string, activityTypes: string
}
export const UserAction = new (class {
addStream(rid: string): () => void {
if (rooms.get(rid)) {
throw new Error('UserAction - addStream should only be called once per room');
const existing = rooms.get(rid);
if (existing) {
existing.refs++;
return () => {
existing.refs--;
if (existing.refs === 0) {
existing.stop();
rooms.delete(rid);
}
};
}

const handler = function (username: string, activityType: string[], extras?: object): void {
Expand All @@ -82,15 +93,17 @@ export const UserAction = new (class {
}
handleStreamAction(rid, username, activityType, extras);
};
rooms.set(rid, handler);

const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler);
const entry = { handler, stop, refs: 1 };
rooms.set(rid, entry);

return () => {
if (!rooms.get(rid)) {
return;
entry.refs--;
if (entry.refs === 0) {
stop();
rooms.delete(rid);
}
stop();
rooms.delete(rid);
};
}

Expand Down
78 changes: 78 additions & 0 deletions apps/meteor/client/components/CallParticipants.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';

import CallParticipants from './CallParticipants';

const person = (username: string) => ({ _id: username, username });

const renderParticipants = (props: Parameters<typeof CallParticipants>[0], avatars = true) =>
render(<CallParticipants {...props} />, {
wrapper: mockAppRoot().withJohnDoe().withUserPreference('displayAvatars', avatars).build(),
});

// Faces say *who* is in the call, which is usually what decides whether to join. The count alone never did.
it('shows a face for each of the people it was given', () => {
const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 2 });

expect(container.querySelectorAll('img')).toHaveLength(2);
});

// Said the way the call's own message block says it, so a call reads the same in the sidebar as in its room.
it('follows the faces with how many more there are', () => {
renderParticipants({ people: [person('alice'), person('bob'), person('carol')], total: 12 });

expect(screen.getByText('plus__usersCount__joined')).toBeInTheDocument();
});

it('says just "joined" when every one of them is shown', () => {
renderParticipants({ people: [person('alice'), person('bob')], total: 2 });

expect(screen.getByText('joined')).toBeInTheDocument();
expect(screen.queryByText('plus__usersCount__joined')).not.toBeInTheDocument();
});

it('still says how many there are, for anyone who cannot see the faces', () => {
renderParticipants({ people: [person('alice')], total: 4 });

expect(screen.getByTitle('__count__people_in_the_call')).toBeInTheDocument();
});

// A single face reads as a mistake rather than as a call, so an empty second place stands beside it — decorative,
// which is why it is hidden from anyone listening rather than looking.
it('gives a lone face an empty place beside it', () => {
const { container } = renderParticipants({ people: [person('alice')], total: 1 });

expect(container.querySelectorAll('img')).toHaveLength(1);
expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(1);
});

it('leaves the empty place out once there are two', () => {
const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 2 });

expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(0);
});

// Side by side rather than overlapped, so nothing has to be stacked and no face is half hidden behind another.
it('sets the faces beside each other', () => {
const people = [person('alice'), person('bob'), person('carol')];
const { container } = renderParticipants({ people, total: 3 });

expect(container.querySelectorAll('img')).toHaveLength(people.length);
expect(container.querySelector('[style*="z-index"]')).toBeNull();
});

// An older server, or a call whose members did not travel with it.
it('falls back to the number when there are no faces to show', () => {
const { container } = renderParticipants({ people: [], total: 3 });

expect(screen.getByText('__usersCount__joined')).toBeInTheDocument();
expect(container.querySelectorAll('img')).toHaveLength(0);
});

// Avatars are a preference, and the message block honours it by saying the count in words instead.
it('says it in words when the reader has avatars turned off', () => {
const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 5 }, false);

expect(screen.getByText('__usersCount__joined')).toBeInTheDocument();
expect(container.querySelectorAll('img')).toHaveLength(0);
});
73 changes: 73 additions & 0 deletions apps/meteor/client/components/CallParticipants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { IUser } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';

/**
* A little definition under each face, so a row of them reads as faces rather than as a strip of colour.
* `drop-shadow` rather than `box-shadow` because it follows the avatar's own rounded shape — the radius belongs to
* the avatar, and guessing it here would leave a square shadow behind a rounded picture.
*/
const facesStyles = css`
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.24)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32));
`;

type CallParticipantsProps = {
/** A few of the people in the call — whoever is to get a face. Capped by the caller. */
people: (Pick<IUser, '_id'> & Partial<Pick<IUser, 'username'>>)[];
/** How many are in the call altogether, which is what the count after the faces is worked out from. */
total: number;
/** Avatar size, since a sidebar row and a full screen don't want the same one. */
size?: 'x18' | 'x24';
};

/**
* Who is already in a call: their faces, then how many more there are.
*
* Says it the way the call's own message block says it — the faces, then `+ 3 joined`, or just `joined` when they
* are all shown. Same arrangement and the same phrases (`plus__usersCount__joined`, `joined`), because a call the
* user meets in the sidebar and again in its room should read the same both times.
*
* Faces answer *who* is in there, which is usually what decides whether to walk in. With avatars turned off there
* is nobody to show, so it falls back to the count in words, as the message block does.
*/
const CallParticipants = ({ people, total, size = 'x18' }: CallParticipantsProps) => {
const { t } = useTranslation();
const displayAvatars = useUserPreference<boolean>('displayAvatars');

// The whole count, as the group's label: it is what a screen reader gets instead of the faces, and "+ 3" only
// means something next to a total.
const label = t('__count__people_in_the_call', { count: total });

// Faces switched off, or a call whose members didn't travel with it — an older server, say.
if (!displayAvatars || !people.length) {
return (
<Box fontScale='micro' color='hint'>
{t('__usersCount__joined', { count: total })}
</Box>
);
}

const remaining = total - people.length;

return (
<Box display='flex' alignItems='center' aria-label={label} title={label} style={{ gap: 6 }}>
{/* Side by side with a little air between them, rather than overlapped: there are only ever a few, and
a face half behind another face is a worse picture of who is in the call. */}
<Box display='flex' alignItems='center' style={{ gap: 4 }}>
{people.map(({ _id, username }) => (
<Box key={_id} className={facesStyles}>
<UserAvatar username={username ?? ''} size={size} />
</Box>
))}
</Box>
<Box fontScale='micro' color='hint' flexShrink={0}>
{remaining > 0 ? t('plus__usersCount__joined', { count: remaining }) : t('joined')}
</Box>
</Box>
);
};

export default CallParticipants;
43 changes: 43 additions & 0 deletions apps/meteor/client/components/OngoingCalls/CallListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { JoinableVideoConference } from '@rocket.chat/core-typings';
import { Box, Icon } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import Extended from '../../sidebar/Item/Extended';

type CallListItemProps = {
call: JoinableVideoConference;
timeLabel?: ReactNode;
actions: ReactNode;
onOpen: () => void;
};

const CallListItem = ({ call, timeLabel, actions, onOpen }: CallListItemProps) => {
const { t } = useTranslation();

return (
<Extended
onClick={(event) => {
event.preventDefault();

if ((event.target as HTMLElement).closest('button')) {
return;
}

onOpen();
}}
icon={<Icon name='video' size='x16' />}
title={call.name}
time={call.createdAt}
timeLabel={timeLabel}
subtitle={
<Box fontScale='micro' color='hint'>
{t('__count__people_joined', { count: call.usersCount })}
</Box>
}
actions={actions}
/>
);
};

export default CallListItem;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Box, Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';

type DeclinedCallsToggleProps = {
count: number;
expanded: boolean;
onToggle: () => void;
};

/**
* The way back to a call that was turned down, at the foot of the Ongoing calls group.
*
* Declining quiets a call rather than losing it: the row drops out of the list and waits under this, so a call
* turned down by accident — or turned down and then wanted after all — is one click away rather than a trip to the
* call history. It only exists while there is something behind it.
*/
const DeclinedCallsToggle = ({ count, expanded, onToggle }: DeclinedCallsToggleProps) => {
const { t } = useTranslation();

return (
<Box paddingInline={16} paddingBlock={4}>
<Button small secondary width='100%' onClick={onToggle}>
{expanded ? t('Show_fewer') : t('Show__count__declined_calls', { count })}
</Button>
</Box>
);
};

export default DeclinedCallsToggle;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import OngoingCallRow from './OngoingCallRow';
import { buildJoinableCall } from '../../views/conference/testFixtures';

const onJoin = jest.fn();
const onDecline = jest.fn();

const renderRow = (name = 'Standup') =>
render(<OngoingCallRow call={buildJoinableCall({ callId: 'call-1', name })} onJoin={onJoin} onDecline={onDecline} />, {
wrapper: mockAppRoot().withJohnDoe().withUserPreference('displayAvatars', true).build(),
});

beforeEach(() => {
onJoin.mockClear();
onDecline.mockClear();
});

it('says what the call is and how many people are in it', () => {
renderRow('Sprint planning');

expect(screen.getByText('Sprint planning')).toBeInTheDocument();
expect(screen.getByText('__count__people_joined')).toBeInTheDocument();
});

it('opens the call when the row is clicked', async () => {
const { container } = renderRow();
const row = container.querySelector('.rcx-sidebar-v2-item') as HTMLElement;

const click = new MouseEvent('click', { bubbles: true, cancelable: true });
row.dispatchEvent(click);

expect(click.defaultPrevented).toBe(true);
expect(onJoin).toHaveBeenCalledWith('call-1');
expect(onDecline).not.toHaveBeenCalled();
});

it('turns the call down without opening it', async () => {
renderRow();

await userEvent.click(screen.getByRole('button', { name: 'Decline' }));

expect(onDecline).toHaveBeenCalledWith('call-1');
expect(onJoin).not.toHaveBeenCalled();
});

it('is the room item with no avatar', () => {
const { container } = renderRow('Standup');

expect(container.querySelector('.rcx-sidebar-v2-item')).not.toBeNull();
expect(container.querySelector('.rcx-sidebar-v2-item__timestamp')).not.toBeNull();
expect(container.querySelector('.rcx-sidebar-v2-item__avatar')).toBeNull();
});
Loading
Loading