When joining a meeting that already has many participants, new
participants are now muted by default.
This is an empirical change, not directly requested by users but
informed by user experience: a lot of people joining large meetings
arrive with their microphone and/or camera open, which can be
painful for the host, who otherwise has to mute everyone or ask
everyone to mute. Many of these participants are inattentive but
still noisy.
If you are joining a room that is already at a certain size, you are
probably not the one expected to speak; presenters typically join
among the very first participants.
In particularly large rooms, with more than 5 or 10 participants,
the entry notification sound can quickly feel spammy.
A configuration to disable it exists, but new users do not discover
it easily. The sound notification is most useful while waiting for
the first few participants to arrive; once a few people are in, the
meeting usually starts and the sound becomes more disruptive than
helpful.
The visual notification remains in place, so users are still aware
when newcomers join. Roll this out and check whether users find the
change helpful.
Required by some self-hosters who want to present a link to
participants that are guests only, without access to their SSO. For
example, a meeting between a citizen and a public servant where the
guest is known not to have an account.
Also useful when rendering the join page inside an iframe.
Several clients ran into issues with the silent login, leading to
redirections that were not appropriate for their use case.
Offer a URL parameter to control this behavior and disable silent
login when needed.
The noise-suppression processor is now imported on demand with a
dynamic import() instead of a top-level static import, so it lands in
its own code-split chunk rather than the main bundle.
Why this is necessary:
* @libreaudio/la-call is fully self-contained. It inlines *everything*
as JavaScript: two WASM binaries (SIMD and non-SIMD variants), the
Emscripten glue, the worklet processor source, and the noise model
plus its weights — the model is compiled into the .wasm, so we ship
effectively two copies of it.
* The WASM is inlined as numeric array literals (new Uint8Array([...])),
the least compact representation possible (~2-4 source chars/byte) and
not meaningfully minifiable. The result is a large module that also
can't be stream-compiled the way an external .wasm asset would be.
* A static import would pull all of that into the initial bundle,
inflating critical-path download size and lengthening build time
(parsing/minifying the big literal) — for a feature that's only used
when the user actually turns on noise suppression.
* Dynamic import() isolates the whole payload in a separate,
content-hashed, browser-cached chunk. The cost (chunk fetch + WASM
instantiation) becomes a one-time hit deferred to first activation,
and is fully off the page's initial load path.
Notes:
* The library self-bundles its AudioWorklet at runtime from a Blob URL
and inlines the WASM, so it needs no build-tool asset plumbing
(no ?worker&url / ?url, no Vite asset config). The dynamic import is
therefore the only splitting mechanism required.
* Audio processing still runs off the main thread on the AudioWorklet
path (desktop/most browsers); only the Android ScriptProcessor
fallback runs on the main thread. Import style does not affect this.
* To hide the first-activation latency, the chunk can be prefetched
(e.g. import() on idle or <link rel="modulepreload">) so it's warm
before the user enables suppression.
Replace the basic RNN noise processor with a more advanced audio
pipeline powered by Big Blue Better Audio (BBBA), a project
supported by the Prototype Fund.
The new WASM-based pipeline introduced by @falkTX and
@trummerschlunk adds:
- voice isolation
- high-pass filtering
- spectral balancing
- multiband compression
- low-pass filtering
This significantly improves overall audio quality and speech
clarity.
More information about the pipeline is available on the
trummerschlunk/BigBlueBetterAudio repo.
Voice isolation still relies on RNNNoise, a widely used deep
learning-based denoising model. Audio quality could be further
improved in the future if DeepFilterNet becomes usable directly
in the browser.
Their code has been released in an NPM package under GPL license.
Uninstall `vite-tsconfig-paths` as path resolution is now supported
natively by Vite's built-in configuration.
Remove the unused dependency and simplify the frontend tooling setup.
The upgrade introduced around 50 new linting errors. Fix the
low-hanging fruit and address straightforward violations.
Several of the new rules appear to target patterns intended for newer
React compiler capabilities. Since the project currently runs on
React 18 rather than React 19, disable these rules for now instead of
applying potentially inappropriate changes.
Follow-up work can address the new rules in a dedicated PR, potentially
alongside a future React version upgrade.
Removes Node.js type dependency from browser/React code by substituting
the non-portable `NodeJS.Timeout` type with the standard
`ReturnType<typeof setTimeout>` equivalent across all affected files.
Also replaces `process.env.NODE_ENV` in Icon.tsx with
`import.meta.env.MODE` for Vite compatibility.
With the addition of the ANALYSING state
files could be accessed in the short time they
were in that state.
We now require files to be in ready.
Also adds missing frontend types (no impact).
Manually upgrade the frontend codebase to ESLint 9.
Update the linting configuration and related code where required to
maintain compatibility with the new major version.
Avoid stacking too many components in route/home. Keep only the
responsibility of orchestration and layout in the home route, and
move stateful components to their appropriate folder.
The homepage previously relied on the userChoices store, which loads
the persisted user choices from localStorage. However, this store is
widely used in the room feature, so code splitting ended up loading
the chunk containing the userChoices store at the initial render.
That chunk was bundled with unrelated parts that proved to be heavy.
Switch to a more straightforward approach reading the cached username
and verify the bundle output + intial loading.
Saves a few hundred ko on the initial load, now bundled in another
chunk that is loaded only when entering a room.
Material icons (and Symbols, the most recent ones from Google
Material) were initially adopted to align with the UI kit. Before
that, we relied on Remixicon, which loads only the icons used and
totals 40ko for the whole library.
To benefit from Material icons, the implementation matched the UI
kit, which uses two fonts: 150ko and 750ko. Unlike the UI kit, the
fonts were preloaded to prevent icon render blinks. On products using
UI kit, the fonts are loaded the first time an icon renders,
and because they are heavy, the icon swap is visible to the user.
Icons are replaced by their text label in the meantime, which also
caused screen-reader vocalization or i18n issues that had to be mitigated.
Preloading the fonts was a quick win that worked fine on high-speed
connections where fonts get loaded fast then cached. However, while
optimizing LCP for users on poor connections (a separate concern that
already affects videoconference joining), preloading proved harmful:
it stole network bandwidth at the most critical moment,
when the main JS chunk was loading.
Switch to a frugal approach: import only the SVG icons actually
needed from Material, treated as components so they are tree-shaken
and customizable (color, etc.). Icons do not change often, so the DX
remains good. The UI kit also supports custom icons not available in
the font, so this approach lets us define them and works like a
charm.
Removal of Remixicon is planned in a follow-up PR.
It save user loading almost 1Mo of assets for few icons.
posthog-js was bundled in the main chunk. Loading it dynamically saves
200ko on the initial load and around 4s of loading time on slow 4G
connections.
PostHog, and analytics in general, should not block the initial
rendering. The risk is that some feature-flagged features may not be
instantaneously available to the user, but for most users on
high-speed connections this will be imperceptible.
Other places in the code rely on a direct import from posthog, I only
verified the delay import of posthog for the home page, and did
not for the /room route. I'm pretty sure there is room for
improvements on other parts of the app.
The Valtio actions on the store were initially defined inside a hook,
which was a bad idea: it does not follow Valtio's recommendation of
defining actions at the module level, and the function definitions
were re-created every time the hook re-rendered, which happened on
every state update.
These action has nothing related to React.
Additionally, the snapshot should be used more directly so Valtio can
understand and optimize which parts of the proxy are of interest to
the snapshot being made.
Use the Python port of Google's phone number library on the backend
instead of the JS one on the client. Saves 140Kb of unnecessary JS
and avoids a complex dynamic import that would have been required to
optimize loading.
The transformation is static: the phone number lives in the Django
settings, so the backend can format it once and pass the result to
the client. This avoids every client loading the 140Kb library and
re-computing the same information.
Moreover, within a single client, the transformation was previously
re-computed several times during a webapp lifecycle.
Avoid re-rendering the whole home page when interacting with the later
meeting creation flow.
Maintaining the modal open/close state at the parent level was causing
the whole home page to re-render on every menu open/close state
change.
livekit-client is a 400ko package that was wrongly bundled in the main
index.js chunk.
Every page was loading this enormeous vendor package only necessary
to join and participate to a room.
It made sense to set the LiveKit log level at app init, however the
performance tradeoff forces us to move it as close as possible to
where it is actually needed.
The library is rarely used, so load it dynamically. It is only 50ko,
which might not have been worth the effort, but these 50ko were
bundled in the main chunk and are not needed on the homepage nor when
launching a room.
The static placeholder is good enough to be acceptable. On very slow
connections, the 50ko might take a second to load, after which the
text is updated with the right content.
Also improves renders across the components touched, especially the
idle modal with the countdown.
Introduce a component that renders its children only if the logged-in
user is considered Admin or User. This helps avoid mounting the hooks
and logic of components that previously early-returned when the user
was not admin or owner, but were still re-rendering whenever their
internal logic updated.
One of the best ways to reduce the size of the initially loaded chunk
(index.js, around 2Mo before optimization) is to lazy load routes and
features so their JS gets isolated in dedicated chunks and is loaded
only when needed.
For example, most of the JS under the legal terms page is never
consulted by users. Likewise, when loading the home page, there is no
need for all the feature-related JS required to display a
videoconference.
After this lazy-loading optimization, the initial chunk is now around
1.2Mo, a significant improvement.
While attempting to lazy-load routes, Vite/Rollup warnings revealed
that the Dialog and Form primitives were causing circular imports
through their use of the barrel file.
Easy to address: when using primitive elements inside those
primitives, rely on direct imports instead of the barrel file.
Auth-related code is used across the codebase and imported by many
different features. The barrel file, while convenient for importing
utilities, was harming code splitting by pulling unrelated modules
into shared chunks.
Verified by running `npm run build:debug` before and after: chunking
and code splitting are better without the barrel.
Install tooling to visualize the Rollup bundle output. It helps
understand precisely what composes our bundle and what is included in
each chunk, which is a great help when trying to enhance code
splitting.
You can see its output running npm run build:debug.
Note: the library is not compatible with Node 20, which is the build
image we use in Docker to build the frontend, so it will raise a
warning in CI. This does not matter much, as it is a dev-only
dependency used locally without Docker.
Mark JS imports as type-only when applicable so they are stripped at
build time and ignored during chunk splitting, ensuring we take full
advantage of Rollup's code-splitting optimizations.
Following the ProConnect documentation leads to inlining almost 40ko of
SVG assets in the JS bundle. Moreover, each SVG asset (around 20ko) is
not optimized in size.
As the ProConnect button is loaded in the main index.js chunk at app
launch, these few Ko are critical.
Optimize index.js by 40ko and preload the hovered variant to avoid any
blink on hover.
Could be further optimized by handling the button background with CSS,
but leaving that as an improvement for later.
Surface connection state changes (reconnecting, disconnected,
etc.) directly in the PiP window so the user stays informed
without needing to switch back to the main window.
Co-authored-by: Cyril <c.gromoff@gmail.com>
Replace the hard-coded tooltip offset with a semantic CSS token,
making it possible to override the spacing per context (e.g.
tighter spacing in the PiP window where vertical room is scarce).
Co-authored-by: Cyril <c.gromoff@gmail.com>
react-aria computes overlay positions against the main window's
dimensions, which produces incorrect placement when the overlay
is rendered into a PiP document. Tooltips were the most visible
symptom, but the issue affects overlays in general.
Introduce a context that lets our primitives know when they're
rendering across documents. When set, the primitives use the
host document's window for positioning instead of falling back
to react-aria's default.
Original work by @ovgod. No cleaner approach seems feasible in
the short term, react-aria doesn't expose a clean way to
override the positioning target, so this works around it at our
primitive layer.
Co-authored-by: Cyril <c.gromoff@gmail.com>
The PiP window can be resized to a wide range of dimensions, so
the control bar needs to adapt. Introduce a collapsible mechanism
that hides or condenses controls as available width shrinks,
keeping the UX usable at small sizes.
Original works by @ovgdd
Co-authored-by: Cyril <c.gromoff@gmail.com>
The toolbar's onFocus handler called focusManager.focusFirst() whenever
focus arrived from outside the toolbar, which included clicks on
reaction buttons. This caused focus (and the scroll viewport) to snap
back to the first button on every click.
Use :focus-visible to distinguish keyboard focus from pointer focus.
Only redirect to the first button when focus arrives via keyboard,
leaving click-induced focus untouched.
Co-authored-by: Cyril <c.gromoff@gmail.com>
Render the notification region from the main window into the PiP
document via a portal, rather than duplicating it. This keeps a
single listener, a single store, and a single component instance —
so notifications stay consistent across both windows without extra
synchronization.
Simplest approach I could find. Good enough as a first pass;
worth revisiting if the requirements grow.
Co-authored-by: Cyril <c.gromoff@gmail.com>
The core component of the PiP layout. Adapted from a simplified version
of @ovgodd's work in #890 — see that PR for context on the original design.
Co-authored-by: Cyril <c.gromoff@gmail.com>
Adding a menu turned out to be tricky. Our usual menu primitive
is built on react-aria, which positions overlays based on the
`window` object. Since the JS runs in the main window, the
computed position refers to the main window's coordinates, and
the menu renders in the wrong place inside the PiP document.
After investigating, we couldn't find a clean way to make
react-aria target the PiP window's `window`/`document` without
significant rework. Agreed with @ovgodd to duplicate the menu in
the PiP window as a pragmatic workaround until a better approach
emerges.
Co-authored-by: Cyril <c.gromoff@gmail.com>
The PiP control bar shares state with the main window's control
bar — most importantly, the reaction toolbar and the mic/camera
toggles, which are fully stateful.
Sharing state works naturally here because the PiP content is
rendered through a portal into the same React tree, so the
controls in both windows read from and write to the same stores.
Co-authored-by: Cyril <c.gromoff@gmail.com>
The main window computes a JS offset to align the reaction toolbar
with the (off-center) reaction toggle. PiP and mobile don't need
this — centering the toolbar is enough. Add `adjustedCentering` to
opt out.
Not a great seam: the component shouldn't know about its host.
A cleaner fix would move the alignment concern to the parent.
Leaving for now to unblock PiP.
Co-authored-by: Cyril <c.gromoff@gmail.com>
When the PiP window is active, replace the corresponding content
in the main window with a lightweight placeholder. This avoids
rendering the camera feed (and other expensive media) twice when
the user is only watching the PiP window.
Co-authored-by: Cyril <c.gromoff@gmail.com>