feat: LiveKit as a native video conference provider (PoC) - #41735
feat: LiveKit as a native video conference provider (PoC)#41735rodrigok wants to merge 27 commits into
Conversation
|
Looks like this PR is not ready to merge, because of the following issues:
Please fix the issues and try again If you have any trouble, please check the PR guidelines |
🦋 Changeset detectedLatest commit: 478ce3e The changes in this PR will be included in the next version bump. This PR includes changesets to release 8 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feat/persistant-chat #41735 +/- ##
========================================================
- Coverage 71.46% 71.46% -0.01%
========================================================
Files 1999 2020 +21
Lines 120088 121370 +1282
Branches 16940 17036 +96
========================================================
+ Hits 85826 86738 +912
- Misses 33563 33894 +331
- Partials 699 738 +39
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
403bbea to
95764dc
Compare
Ported from #40726, minus that PR's persistent-chat work -- this branch already carries its own, and the two overlapped in about twenty files. Every video conference provider so far 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. Running the call inside Rocket.Chat changes what the server is able to know, and most of this diff is the consequence of that rather than the SFU itself -- conferences now carry the people actually connected, so presence in a call stops being inferred from who clicked join. What that buys: multi-party calls with a grid and a spotlight, screen sharing, hand-raise and reactions, and a floating widget that keeps the call with the user when they walk to another room. Recording goes through LiveKit egress and comes back as an ordinary upload, posted in a thread under the call's own message rather than somewhere new. 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 summarised when the call ends. The provider is enterprise and off unless configured. A workspace with no LiveKit deployment behaves exactly as it did. Reconciling the PR against a newer develop needed six things: - `app/` has been restructured, so the settings, notifications and file-upload imports across the eight EE files point at their new homes. - `useGoToRoom` left `ui-contexts` for the app. - fuselage no longer takes the `w`/`h`/`bg` shorthands. - `MediaCallRoomActivity` gained `MediaCallViewProvider` and container-height sizing on develop while the PR replaced that sizing with `useTileGridLayout`; the PR's layout wins, since it is what the rewritten section expects. - `IVideoConference` took both sides: our membership predicates and the provider's participant/recording/transcript records. - `@livekit/agents` and `@livekit/rtc-node` were pinned at `latest`, which a committed lockfile cannot honour. They are pinned to what resolved. `useLiveKitVideoConf` threw outside its provider, which made `VideoConfProvider` untestable and coupled mount order for a component that only forwards a join on. It gains an optional accessor; the throwing one stays for LiveKit's own components, which genuinely cannot work without the context. MEDIA-CALLS-FEATURE.md and deploy/livekit/deploy.md move under docs/features/native-video-conference/, where this repo keeps feature docs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing the port against a configured LiveKit found the seam between it and persistent chat exactly where the docs said it was, and worse than "not wired": with both on, the room's call button led to a preflight that never went anywhere. An embedded provider answers the join with an empty url -- there is no page to send anyone to -- and the conference page treated a missing url as "not joined yet", so it re-rendered the preflight forever. So the page now waits on having *joined* rather than on having a url, and renders the call itself when there is no page to embed. The connection is registered with the bridge above this route rather than held here, which is the same slot the in-room path uses -- a call cannot be claimed twice, and it survives this page re-rendering. One bar, not two. The call brings mic, camera, screen share, hand and hang up; this window already had a bar carrying members and chat. The call's controls are handed to that bar and take its centre, with the panel toggles at the end. They are moved, not rebuilt: the same nodes render in the same component either way, so the two placements cannot drift. The floating mini-view is gone. It followed the user out of the call's room, which a call that always has a window of its own never needs -- and in that window it drew a second copy of the call over the members panel, because it decides visibility by comparing against the opened room and a call window has none. Nine strings in the call's own bar were hardcoded English or asked for keys that were never added, so recording, notes and captions rendered as `Start_recording` and friends. Verified against a live LiveKit: capabilities report the provider embedded, start posts the call block, join records the participant, the transport endpoint mints a real room token, and the call connects with working camera and the members panel beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tom bar
Three things found testing the native provider.
**Starting a call ignored the mic and camera choice; joining honoured it.**
The window read the preferences off its own join mutation, and starting a
call does not join here -- the start screen does, and this window finds the
result already in the cache having never asked. So it handed the provider
nothing and got its defaults. It now reads what the preflight persisted,
which is the same for both paths and is where the choice actually lives.
**The call built a second bottom bar inside the conference.** The controls
were being handed a container captured from a ref, which is null on the
first render -- so the call had already built its own strip by the time it
was told where to put them. The container is now created up front and is
non-null from the start, so there is one bar: the call's controls in its
centre, members and chat at its end.
**The window grew but would not shrink.** The call column is a flex item,
and a flex item's `min-width` is `auto` -- it will not go below its content.
Widening worked because that is the direction `auto` allows. `minWidth={0}`
lets it give the width back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three more of the call bar's strings had no key behind them, so the camera button's tooltip read `Stop_camera`. Added, along with `Camera` itself. The hang-up button read "End call with Call". A one-to-one call is left *with* someone and can name them; a group call has no single other side, and naming one anyway produced that. It now says what the button does. The nine keys added earlier landed near the top of the file rather than beside their neighbours -- my insert compared case-insensitively against a file that isn't sorted that way. Moved to where they belong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndow
**The preflight now shows the camera it will actually send.** It used to say
only *that* the camera would be on, and it was right to: a URL-based provider
takes "camera on" and nothing more, so a self-view would have promised a
choice the screen couldn't make and might have shown a camera the call never
used. A provider running the call in here takes the devices too, so the
honest thing is the real thing -- a live self-view, with a camera and a
microphone menu beside the toggles that own them. Gated on
`capabilities.embedded`, so a URL provider keeps the old wording.
The device ids stay client-side. `video-conference.join` accepts `{mic, cam}`
and nothing else -- `additionalProperties: false` would reject them -- and
the server has no use for a device id anyway. They are persisted beside the
on/off habit and handed to the provider as capture options on connect, so
starting and joining both arrive on what was chosen.
Opening the devices for the preview is also what earns the permission the
menus need: `enumerateDevices` returns unnamed entries until something has
been allowed. The preview releases every track when it unmounts, so the call
gets the devices back rather than finding them busy.
**Hanging up closes the window.** The call had been left, but the window it
was opened for stayed. It now reports leaving and closes -- the same thing
Cancel on the preflight does, and what closing the window would have done
anyway. Watching the active-call slot rather than the button means it also
covers a call that ends from the other side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two columns, as Google Meet does it: how you will look and sound on one
side, what you are joining and the decision on the other. They are separate
questions, and stacking the decision under a column of controls made it read
as the last of them rather than the point of the screen. The preview gets the
room -- it is the subject -- and the details column only needs to be
readable. Narrow viewports stack, preview first.
The mic and camera toggles follow the convention every call UI uses: **off
is red**, filled, because a muted mic or a dark camera is the state you need
to notice at a glance; on is a ghost button, since there is nothing to
report. `IconButton`'s own `danger` is the ghost variant -- a red glyph on
nothing -- so the fill comes from the same tokens the danger buttons use.
`mic-off` slashes the other way from `video-off`, so side by side they read
as two unrelated marks. The mic is mirrored to match, which flips its slash
without visibly changing the mic: it is symmetric about that axis.
The tile is black, and stays black with the camera off. It is where a camera
goes, and a camera with nothing to show is black -- a themed panel that
repainted as the camera toggled read as a different surface each time.
Three device selectors below it -- microphone, speaker, camera -- in one row
of equal columns. A grid, not flex: flex sizes to content however hard it is
pushed, and equal columns are what make the three read as one set rather
than three differently-sized pills.
What they show took some undoing of what browsers hand over:
- the USB vendor:product pair ("Display Audio (05ac:1107)") identifies the
hardware to the machine, not to the person choosing it, and is long enough
to push the name itself out of view. A parenthetical like "(Built-in)"
stays -- that is part of the name.
- the system default arrives prefixed "Default - ". The prefix is dropped
from the name and said properly on its own line, where it reads as a fact
about the device rather than part of what it is called.
- it also arrives *twice*: once as the `default` alias and again under its
own id. The alias is kept -- it is what "leave it to the system" means, and
it follows the system if that changes -- and its twin is found by
`groupId`, which the two share. Matching on the name would have collapsed
genuinely different devices that happen to share one, which two displays
generally do.
The default sorts first: it is what will be used if nothing is picked.
None of the three is gated on the device being on. Arriving muted is normal,
and someone who does may still care which microphone gets unmuted later;
refusing the choice until they turn it on would make them turn it on just to
set it. Enumeration is therefore independent of the preview, and follows
`devicechange` so a headset arriving mid-decision shows up.
Confirm and cancel are stacked and full width, confirm first: it is what the
screen is asking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device on the left, its name beside it, the chevron pushed to the far right, and the chevron turning over when the list opens -- a control that says what it is, what it is set to, and that there is more behind it, read left to right. None of that was reachable through `GenericMenu`, which clones its trigger: it injects its own chevron as a *leading* icon and replaces the button's `className`, so neither the icon's place nor the name's alignment was ours to set, and there was nothing to read the open state from. So the trigger is a plain button over `useDropdownVisibility` and fuselage's `Dropdown` -- the same pairing the omnichannel quick actions use -- which owns the open state and puts the chevron under our control. The selected device now prefers the system-default alias rather than whatever the browser enumerated first, so the pill agrees with the list underneath it about which one is chosen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are the two controls the whole screen is for, and at toolbar size they read as an afterthought on a wide camera tile. Bigger than any of `IconButton`'s own sizes, which stop at about a toolbar button, and round — at this size a rounded square reads as a tile of its own rather than a control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… out Captions, persisted transcripts, the AI summary and the per-call language picker move to a branch of their own, stacked on this one. They are one feature, not two: the language picker exists to tell the agent which language to transcribe, and means nothing without it. What that takes out of here: the worker subprocess and its supervisor, the Gemini summary path, the transcription and transcript-append endpoints, the `Agent_*` and `Summary_*` settings, the transcription/transcript/summary records on the conference, the caption overlay on the tiles, the notes, captions and language pills, and the four data-channel messages they spoke over. Three dependencies go with them -- `@livekit/agents`, `@livekit/rtc-node` and `@google/genai` -- and so does the Dockerfile change, which existed only to build a musl FFI binding for `@livekit/rtc-node`. The client SDK stays: `livekit-client`, `@livekit/components-react` and the Krisp filter are the call itself, not the agent. `@livekit/components-styles` was declared and never imported, so it goes too. What is left is a call: multi-party video with a grid and spotlight, screen sharing, hand-raise, reactions, and egress recording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recording is a separable feature with its own dependencies, its own storage story and its own failure modes, so it joins the agent branch stacked on top of this one rather than shipping with the call. Out of here: the egress client, the recording orchestration, the poller that resumes in-flight recordings on boot, the finaliser that registers the file as an ordinary upload and posts it in a thread, the three recording endpoints, the `Recording_*` settings, the `recording` record on the conference, the pill and its chimes, and the `recording-state` data-channel message. The CloudFormation templates and the deployment walkthrough go with it. They are infrastructure rather than product code -- a different audience reviews them, and unlike the docs they are not free in CI. What this branch needs from a deployment is a URL and a key pair, which the settings ask for and any LiveKit instance can provide, so the doc now says that instead of shipping a turnkey stack nobody reviewing React can assess. `callId` came off the call's session state only so recording could name its egress, and `settings` was read only to gate it, so both go too. What is left is the call: multi-party video with a grid and spotlight, screen sharing, hand-raise and reactions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the recording and transcription method signatures left two runs of blank lines behind, which prettier rejects. CI caught it in `@rocket.chat/model-typings#lint`; my own lint pass had covered `models` and `core-typings` but not this package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e same **The call was rendering twice in the call window.** Its chat panel renders the conference's room, and `MediaCallRoom` puts the in-room call activity in any room that *is* the active call's room -- which, in that window, it always is. So the panel drew a second, fully live copy of the call, with its own timer and its own control strip, beside the first one. The window already shows the call, so the in-room activity is skipped there. **The in-call mic and camera now match the preflight**: off is the state worth noticing, so the whole button goes `secondary-danger` rather than only the glyph turning red, and `mic-off` is mirrored so its slash runs the same way as `video-off` -- side by side they were reading as two unrelated marks. That treatment is opt-in on `ToggleButton` rather than its default, because for most of these toggles pressed means *active*: a shared screen or a raised hand is not a problem, and colouring it as one would read as an error. Only mic and camera ask for it. The mirroring is unconditional, since `mic-off` should look the same everywhere it appears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four changes to how the call's controls read. **A device and its selector are now one control.** They were two adjacent buttons with their own rounded corners, nudged together by 2px, which read as two things that happened to be side by side. Now: one rounded outline, a hairline between the halves, and the selector on the inline start -- out of the way of the toggle a user actually reaches for. **The selector's chevron points up**, toward where its menu opens from a bottom bar. Only the `chevron` variant of `DevicePicker` and `CameraPicker` change, and both are used solely in this strip; the cog-icon variant in the VoIP widget and popout is untouched. **8px between the bar's buttons**, on both groups, so the controls and the panel toggles at the far end keep the same rhythm. **Members moved to the call's top bar.** Who is in the call is a fact about the call, so for a provider that brings its own header it belongs up there beside the call's own actions, ruled off from them -- not down among the mic and camera. The chat toggle is this window's own and stays on this window's bar. A provider rendered in an iframe has no header of ours to put anything in, so there both stay on the bar as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the screenshot showed. **Members and chat were ghost buttons** while everything beside them — screen share, hand, reactions — carried a background, so the two that belong to this window read as disabled rather than as merely inactive. They are `secondary` now, like the rest of the row. **The selector half of the mic and camera controls had neither background nor the toggle's height.** `DevicePicker`'s chevron variant forced `tiny` and left `secondary` off, and `CameraPicker` forced `tiny` unconditionally, so the two halves of a control that is meant to read as one pill were different sizes and different colours. The chevron now takes the same `secondary` background and the same medium size as the toggle it is joined to. `CameraPicker`'s `secondary` prop had become dead once the strip needed it on; it defaults to on rather than being hardcoded, so the prop still means something. The cog-icon `DevicePicker` in the VoIP widget and popout is untouched — only the chevron variant, which is used solely in this strip, changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two halves were a red button with a grey tail. The selector now carries whatever its toggle carries -- grey when the device is on, `secondary-danger` when it is off -- at 0.8 opacity, so it reads as the quieter half of one control rather than as a second button that happens to be adjacent. Same treatment Meet uses. Both pickers gained a `danger` prop for it rather than reaching for the call state themselves: they are given the device's state by whoever owns both halves, which keeps them ignorant of what a call is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call's header was inside the call area, so it stopped at the side panel's edge and shifted every time a panel opened — a header about the call moving because of something that isn't about the call. The window now owns a bar at the top, spanning the call *and* its panels, the mirror of the bottom bar below them. It renders only for a provider that runs the call in here: one handed off to an iframe keeps its chrome inside that frame and has nothing to give. The call fills it the same way it fills the bottom bar — by being handed a container and portaling into it, so the header is moved rather than rebuilt and the two placements cannot drift. What moves up is everything that was in that header: how long the call has been running, and the fullscreen toggle. This window adds who is in the call, at the far end. The in-room 1:1 view has no such bar, so there the header still renders in place, exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…port is still recorded A reported departure needs a live client talking to a live server, and a call depends on neither: 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 the call still lists them as present. A crashed tab, a killed browser or a dead battery lose the same report. So presence becomes a lease. The conference window renews it every 30s via `video-conference.heartbeat`; a cron marks anyone whose lease is 3min stale as having left and ends the call that empties. Nothing has to arrive at the moment someone goes — what matters is that nothing arrives afterwards. Three things carry the correctness: - The departure is dated from the last evidence, never from the sweep. Stamping "now" on a call recovered twenty minutes after an outage would add twenty minutes to everyone's call history. - A restart waits out a full lease before evicting anyone. From the database, "everyone left" and "we weren't here to be told" are the same picture, so whoever is still there is given the chance to renew. This also replaces the in-process timer `leaveCall` used for an emptied call, which a restart discarded. - A renewal undoes an inferred departure, and only an inferred one. The guard is in the query, so a heartbeat behind someone who chose to leave matches nothing. Provider-agnostic by construction: the renewing window is ours whether the call renders in it or is handed to an iframe, so it asks nothing of Pexip or Jitsi. Where a provider can be asked who is in a room it may register a presence probe, whose answer renews the same leases from the server side — which matters because browsers throttle a background window's timers. LiveKit registers one, replacing its count-only reconciler cron; silence from a probe is never read as absence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the room can join it A conference started in a DM and joined by a third person left that person alone in a call the other two never saw them in, with mic and camera buttons that did nothing. `authorizeCall` in the LiveKit API required access to the call's *room*, while every conference endpoint requires `canAccessConference` — membership or room access. Those two disagree about exactly one person: someone added to a call from outside its room, which grants no room access by design. So `join` admitted them and wrote them into `participants[]`, while `transport.config` refused them the token for the very call they had just joined. Never in the LiveKit room, they saw only themselves; the other two saw nothing; and the controls had no room to act on. The members list was right the whole time, which is what made it puzzling. Confirmed against a development workspace's Mongo: a direct call carrying three `users[]` entries and three `participants[]`, in a DM whose `uids` and subscriptions name only two. `canAccessConference` moves to `server/lib/videoConfAccess.ts` and both use it. Two copies of "may this person be here" is what produced two answers. Also stops the refusal being silent. `fetchTransportConfig` returned null both for "no credentials" and "credentials refused", which reads as a call still connecting — so the failure presented as a working call that answered nothing. It now throws, and the bridge names the reason and leaves the call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Someone added to a call in a DM saw the call named with a hash. `listJoinableCalls` named a direct call from the reader's own subscription and fell back to the room — but conference membership grants no room access, so a member added from outside has no subscription to read, and a DM room carries neither `name` nor `fname`. That reached `getRoomName`'s last resort, which is the raw room id. A direct call is named after a person, so that is what it falls back to now: whoever started it, or — for the person who started it — whoever else is on the call. The room stays the answer only for calls named after a room in the first place. The same rule now names the conference window, where a direct call showed no name at all for the same reason. The rule lives in `lib/videoConference/conferenceName.ts` so the window and the list can't disagree about what a call is called. The spec's `find` mock now answers with only the projected fields, as a database would. Handing back the whole fixture is what let this ship: the projection had no `createdBy`, the naming code read it anyway, and `video-conference.joinable` threw — which showed up as a ringing call that made a sound but never appeared in the sidebar to accept. Removing `createdBy` from the projection now fails the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"2 people in the call" answers a worse question than three avatars do. What decides whether to walk into a call is *who* is already in it. `CallParticipants` draws a face for each of the people it is given and turns whatever is left over into a "+N" shaped and sized like one more avatar, so a row reads as a group of people rather than as faces followed by a statistic. The whole count stays as the group's label — for anyone who cannot see the avatars, and because "+2" means nothing without a total. Two surfaces, one component: - the sidebar list, under a `Participants` label, from `participants` on the joinable payload. The server caps that at three, since a call in a busy channel would otherwise send a roster to draw three avatars. - the preflight when joining, under a "Participants in the call" label and five at a time — a screen has more room than a row. These come from the call window's own copy of the members, so nothing extra travels for them. `participants` returns to the joinable payload, and `CallParticipants` to the tree; both were cut from the first release in 12ce0c2, which recorded the design for exactly this. The joinable fixture now carries participants, and the spec asserts the cap, that the payload carries nothing beyond what a face needs, and that people who were invited but never turned up get no face. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95764dc to
d57634e
Compare
`trim-eof` fails the i18n check on a locale file that ends in whitespace, and `en.i18n.json` had picked up a trailing newline. Restores the byte the file is supposed to end on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…teor's bundler
The Meteor build has been failing since the rebase with `RangeError: Invalid
array length` inside reify's `findPossibleIndexes`, naming no file. The rebase
regenerated the lockfile, and `^2.6.0` floated components-react from 2.9.23 to
2.9.24; every other package in the LiveKit tree resolved identically.
Reproduced outside CI by running reify 0.25.4 over both published tarballs:
2.9.24's `dist/index.mjs` throws, 2.9.23's whole dist compiles cleanly.
The mechanism, from reify's own source:
new RegExp("\\b(?:" + identifiers.join("|") + ")\\b", "g")
`identifiers` are a module's exported local names, and this barrel exports one
named `$`. Inside that alternation `$` is not a literal — it is the end-of-string
anchor. Whether that matters comes down to the last byte of the file:
- 2.9.23 ends with a newline, so `\b` fails at end-of-string, the `$` branch
never matches, and the scan terminates.
- 2.9.24 ends with a word character, so `\b` succeeds, `$` matches the empty
string, `lastIndex` never advances, and the loop pushes the same index until
the array overflows.
So the trigger is a published file that lost its trailing newline. Pinning is the
proportionate fix: it is the exact tree this branch was developed and last built
green against. The hazard outlives the pin, though — any release whose bundle both
exports `$` and ends without a newline will do this again, so a bump wants a
Meteor build before it is trusted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ock says it The sidebar row led with a `Participants` label; the call's own message block, in the room, puts the faces first and follows them with `+ 1 joined`. Two ways of saying the same thing about the same call, so this adopts the block's: the faces, then `plus__usersCount__joined`, or plain `joined` when they are all shown. Same phrases, no new keys — a call met in the sidebar and met again in its room now reads the same both times. The count moving into that text takes it out of the stack, so there is no longer a `+N` tile to keep on top of the faces it overlapped. The faces themselves gain what they were missing while they overlap: a `drop-shadow` on each, so a row of them reads as several people rather than one smudge, and an explicit stacking order rather than whatever paint order gave. `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. Also picks up what the block does with the `displayAvatars` preference, which this component had ignored: with faces turned off there is nobody to show, so it says the count in words instead of showing avatars to someone who asked not to see any. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Meeting in "20 August planning"` came out as `Meeting in "20…` — the name shared its line with a video icon in front and the join and decline buttons behind, and was left about a third of a narrow sidebar row to say itself in. So the row is two lines now: the name alone on the first, and the faces and the actions sharing the second — the faces read from the start of it, the actions from the end. Neither needs the width a name does. The icon in front is gone with it. A row this narrow has nothing to spend on saying "this is a call" twice, when the section it sits under is already called Ongoing calls and every row carries a join button. That icon was also the only thing marking a ringing call, so the red moves to the name rather than disappearing: one line of difference, no decoration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the navbar The calls lived in a card wedged above the sidebar's list, in a layout of their own making: a video icon, then a name with a third of a narrow row to say itself in, then the actions. `Meeting in "20 August planning"` came out as `Meeting in "20…`. They are now a group of the sidebar's list — *Ongoing calls*, always first, collapsing and scrolling exactly as Discussions or Channels do. Prepended rather than placed by `sidebarSectionsOrder`, since that order is a preference saved before this group existed and a stored copy of it has nowhere to put calls. A row *is* the room item now (`sidebar/Item/Extended`), with a call's things in its slots: a camera in front of the name, the name in the item's own title tokens, when it started in the timestamp corner, and the faces where a room puts its last message. That is what makes a call proportional to the channels under it without copying any measurements. The one slot it never fills is the avatar — a call has no single face, and the column would indent every call to say nothing. `timeLabel` is new on the item, so a ringing call can say *Ringing…* where the time would be. Ringing calls are the same row with a green phone, a decline and a silence; the row itself is inert, since a whole row that joined a call is a large target for something the reader may not have meant. It renders as an anchor, so "inert" means preventing the default, not merely leaving off a handler. Declining now quiets a call rather than losing it: it drops out of the list and waits behind a toggle at the foot of the group. The old "show all N calls" cap goes with that — a group of the list scrolls with the list. `NavBarItemOngoingCalls` returns from baf0d91, no longer only a stand-in for a collapsed sidebar: it shows whenever there is a call, red while something is ringing, blue while something is on offer, and the camera alone once every call has been turned down. `useOngoingCallItems` is what both surfaces walk, so the sidebar and the dropdown cannot drift into different orders. The room list's own *Incoming calls* group goes, along with the accept/decline buttons it put on the room's row: a ringing call is in Ongoing calls now, and having it in two places meant two answers to the same question. Includes `fakeOngoingCalls` — layout scaffolding behind `localStorage.rcFakeOngoingCalls`, off by default, meant to be deleted with its three call sites once the layout is settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Proposed changes
Adds LiveKit as a native video conference provider, so a call can run inside Rocket.Chat instead of handing the user off 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. Running the call in-process changes what the server is able to know, and most of this diff is the consequence of that rather than the SFU itself — conferences now carry the participants actually connected, so presence stops being inferred from who clicked join.
On top of that: multi-party calls with a grid and spotlight, screen sharing, hand-raise and reactions, egress-based recording that lands as an ordinary upload posted in a thread under the call's message, and opt-in live captions from a worker that joins each room as a hidden participant — with note-taking on, the transcript is kept and summarised when the call ends.
The provider is enterprise and off unless configured. A workspace with no LiveKit deployment behaves exactly as before.
Deployment (CloudFormation + walkthrough):
docs/features/native-video-conference/deploying-livekit.md.Where this came from
Ported from #40726 (
feat/vc-poc), minus that PR's persistent-chat work, which this stack already carries from #41657. That PR's history splits cleanly: commits up to7e617cc8a8care the LiveKit work, everything after is its own conference-window integration. Only the first range was taken, so none ofclient/views/conference/,client/views/root/,appLayout,desktop-apiorstartup/routes.tsxcame across.Reconciling against a newer
developneeded six things:app/was restructured — the settings, notifications and file-upload imports across eight EE files point at their new homes (server/settings,server/lib/notifications/core/lib/Notifications,server/lib/media/file-upload,server/meteor-methods/messages/sendFileMessage).useGoToRoomleftui-contextsfor the app.w/h/bgshorthands.MediaCallRoomActivitygainedMediaCallViewProviderand container-height sizing on develop while the PR replaced that sizing withuseTileGridLayout; the PR's layout wins, since it is what the rewritten section expects.IVideoConferencetook both sides — this stack's membership predicates and the provider's participant / recording / transcript records.@livekit/agentsand@livekit/rtc-nodewere pinned atlatest, which a committed lockfile cannot honour. Pinned to what resolved (^1.6.2,^0.13.33).Then it was wired to the conference window
Testing against a live LiveKit found the seam between the two features, and it was worse than "not wired": with both enabled, the room's call button led to a preflight that never went anywhere. An embedded provider answers the join with an empty
url— there is no page to send anyone to — and the conference page read a missing url as "not joined yet", so it re-rendered the preflight forever.Start_recordingand friends. The hang-up button read "End call with Call" on group calls.Steps to test or reproduce
Requires a LiveKit deployment; see the deploy doc. Then, as admin:
Verified
Against a live LiveKit (
wss://livekit.dev.rocket.chat):video-conference.capabilitiesembedded: truevideo-conference.startvideo-conference.joinurl: "", records the participant…livekit.transport.configmin-width: 0, no horizontal overflowyarn typecheckclean · jest 309 suites / 2214 passing · mocha 2258 · definition 152 · eslint clean over the ported tree.Not verified — please exercise before this goes anywhere near merge
MediaCallRoomSectionnow rendersCallStagefor direct calls too, replacing develop'sMediaCallCardList. That is the ported PR's design, but it changes existing VoIP call rendering and deserves a look on a running workspace.Dockerfile.alpinegained a musl build of the LiveKit FFI binding; the image was never built here.Further comments
MEDIA-CALLS-FEATURE.mdanddeploy/livekit/deploy.mdmoved underdocs/features/native-video-conference/, where this repo keeps feature docs. The CloudFormation templates stay indeploy/livekit/.🤖 Generated with Claude Code