Skip to content

feat: LiveKit as a native video conference provider (PoC) - #41735

Draft
rodrigok wants to merge 27 commits into
feat/persistant-chatfrom
feat/native-video-conference
Draft

feat: LiveKit as a native video conference provider (PoC)#41735
rodrigok wants to merge 27 commits into
feat/persistant-chatfrom
feat/native-video-conference

Conversation

@rodrigok

@rodrigok rodrigok commented Aug 10, 2026

Copy link
Copy Markdown
Member

PoC — not for merge as-is. Draft, stacked on #41657.
Base is feat/persistant-chat, so this diff shows only the native-provider work. Review #41657 first.

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 to 7e617cc8a8c are the LiveKit work, everything after is its own conference-window integration. Only the first range was taken, so none of client/views/conference/, client/views/root/, appLayout, desktop-api or startup/routes.tsx came across.

Reconciling against a newer develop needed 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).
  • useGoToRoom left ui-contexts for the app.
  • fuselage no longer accepts 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 — this stack's 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. 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.

  • 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 the route, which is the same slot the in-room path uses — a call cannot be claimed twice, and it survives the page re-rendering.
  • One bottom bar. The call brings mic, camera, screen, hand and hang-up; the window already had a bar carrying members and chat. The call's controls are handed to that bar and take its centre. They are moved, not rebuilt — the same nodes in the same component — 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; in that window it drew a second copy of the call over the members panel.
  • Twelve strings in the call's bar were hardcoded English or asked for keys that were never added, so recording, notes, captions and the camera tooltip rendered as Start_recording and 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:

  1. Admin → Video Conference — enable LiveKit, set URL / API key / secret, make it the default provider.
  2. Open a channel and press Video call. The preflight opens in a dedicated window; set mic/camera there.
  3. Confirm — the call runs in that window with its chat and members panels beside it, and the controls in the single bottom bar.

Verified

Against a live LiveKit (wss://livekit.dev.rocket.chat):

video-conference.capabilities reports the provider embedded: true
video-conference.start creates the conference, posts the call block
video-conference.join returns url: "", records the participant
…livekit.transport.config mints a real room token
The call connects; live 1280×720 camera track; members panel populates
Preflight honoured started with mic off / camera on — the inverse of the provider defaults — and the call came up muted with video
One bar 1 footer; 8 call controls + 2 panel toggles in it
Layout call column min-width: 0, no horizontal overflow

yarn typecheck clean · 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

  • A second participant. Grid and spotlight layout, per-tile speaking indicators, hand-raise queue positions and reactions have all only been seen with one person in the call.
  • Recording. The egress path, S3 upload, and the thread reply were never triggered.
  • Captions / transcripts / AI summary. The agent worker was never dispatched.
  • The 1:1 VoIP path. MediaCallRoomSection now renders CallStage for direct calls too, replacing develop's MediaCallCardList. That is the ported PR's design, but it changes existing VoIP call rendering and deserves a look on a running workspace.
  • Docker: Dockerfile.alpine gained a musl build of the LiveKit FFI binding; the image was never built here.

Further comments

MEDIA-CALLS-FEATURE.md and deploy/livekit/deploy.md moved under docs/features/native-video-conference/, where this repo keeps feature docs. The CloudFormation templates stay in deploy/livekit/.

🤖 Generated with Claude Code

Review in cubic

@dionisio-bot

dionisio-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Looks like this PR is not ready to merge, because of the following issues:

  • This PR is missing the 'stat: QA assured' label
  • This PR is missing the required milestone or project

Please fix the issues and try again

If you have any trouble, please check the PR guidelines

@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 478ce3e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@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
@rocket.chat/license Patch

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

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d75fdf6-33e4-49bd-90de-4075c77b9369

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.14700% with 576 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.46%. Comparing base (20377ad) to head (478ce3e).

Additional details and impacted files

Impacted file tree graph

@@                   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     
Flag Coverage Δ
unit 71.46% <72.14%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rodrigok
rodrigok force-pushed the feat/native-video-conference branch from 403bbea to 95764dc Compare August 14, 2026 00:59
rodrigok and others added 22 commits August 13, 2026 22:56
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>
@rodrigok
rodrigok force-pushed the feat/native-video-conference branch from 95764dc to d57634e Compare August 14, 2026 02:03
`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>
rodrigok and others added 4 commits August 14, 2026 08:42
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant