Compare commits

..

28 Commits

Author SHA1 Message Date
Timo
b51a33c4e1 Merge pull request #4194 from element-hq/toger5/rtc-mode-documentation
Add docs about currently used matrixRTC mode
2026-08-21 17:44:10 +02:00
Timo K.
c3837464a3 Add docs about currently used matrixRTC mode 2026-08-21 12:04:24 +02:00
Timo
e7372d4c03 Merge pull request #4190 from element-hq/toger5/update-branche-ci-main
`livekit` -> `main` Update branches used in ci and tooling to main
2026-08-21 11:55:32 +02:00
renovate[bot]
69eddd7846 Update ghcr.io/element-hq/element-web:develop Docker digest to ee5b90c (#4193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 07:10:30 +02:00
Timo K.
de3e4c6a78 update branches used in ci and tooling to main 2026-08-20 15:44:13 +02:00
Robin
3abc5a0435 Merge pull request #4185 from element-hq/pip-no-modals
Hide reactions interface in PiP mode
2026-08-20 15:16:22 +02:00
Johannes Marbach
8e20d48b1d Merge pull request #4187 from element-hq/johannes/label-sync-pat
Switch label sync workflow to LABEL_SYNC_GITHUB_TOKEN
2026-08-20 10:05:12 +02:00
Johannes Marbach
ff57c217aa Switch label sync workflow to LABEL_SYNC_GITHUB_TOKEN 2026-08-20 09:21:42 +02:00
renovate[bot]
b60e7452b7 Update ghcr.io/element-hq/element-web:develop Docker digest to a416986 (#4186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 07:14:58 +02:00
Robin
1c03ad335c Hide reactions interface in PiP mode 2026-08-19 17:44:45 +02:00
Robin
87873b726f Merge pull request #4183 from element-hq/msc4519-docs
Update docs to reflect transports endpoint moving to MSC4519
2026-08-19 15:58:28 +02:00
Robin
3448a3a2ef Update docs to reflect transports endpoint moving to MSC4519
This is linked from the 0.24.0 release notes, so even more important for it to be up to date.
2026-08-19 11:48:48 +02:00
Robin
c625fb8845 Merge pull request #4180 from element-hq/background-pngs
Performance: Use PNGs for the background gradients
2026-08-19 10:40:07 +02:00
Robin
cab9698fc7 Undo breakpoint change made during testing 2026-08-19 10:24:26 +02:00
Robin
7410bd8a82 Merge pull request #4181 from element-hq/lobby-video-border
Fix lobby video preview losing its rounded corners in Firefox
2026-08-19 08:54:57 +02:00
renovate[bot]
f17670b731 Update ghcr.io/element-hq/element-web:develop Docker digest to 9ac7cde (#4182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 08:09:58 +02:00
Robin
33a0653ea5 Fix lobby video preview losing its rounded corners in Firefox
Same fix as e1bc4a096b, but for the video preview in the lobby.
2026-08-18 17:49:14 +02:00
Robin
b1c29e9a41 Performance: Use PNGs for the background gradients
It turns out that for a blurry gradient background, low-res raster images look very convincing when scaled up and interpolated. That means that we can rasterize our gradient SVGs for improved paint performance when resizing the window, while still keeping them quite lightweight (just a couple of kilobytes each).
2026-08-18 17:30:12 +02:00
Robin
ad92892063 Merge pull request #4177 from element-hq/ignore-layout-updates
Performance: Ignore redundant layout updates
2026-08-18 15:39:49 +02:00
Robin
1a827e0930 Merge pull request #4178 from element-hq/tiles-debug-performance
Performance: Isolate tile store debug info in its own component
2026-08-18 13:05:02 +02:00
Robin
a1b37bb3c9 Performance: Ignore redundant layout updates
When someone starts or stops speaking, the layout will often be recomputed only to find out that there is ultimately no layout change. We can ignore these redundant updates to avoid re-rendering the InCallView and Grid components, which are relatively slow.

For that specific, common case, this reduces JS CPU usage by as much as 70% in my testing.
2026-08-18 08:42:59 +02:00
renovate[bot]
061d25a54c Update ghcr.io/element-hq/element-web:develop Docker digest to 073e6a4 (#4176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 07:48:41 +02:00
Robin
b554fa63d1 Performance: Isolate tile store debug info in its own component
It's very uncommon to have this debug option enabled, and yet it currently causes the footer to re-render on every layout update, which is a small but avoidable cost.
2026-08-17 16:45:26 +02:00
Robin
b5bcb9aeed Merge pull request #4160 from alexmaras/room-joining/assume-public
Room joining: if join_rule is absent, assume public
2026-08-17 14:40:07 +02:00
Timo
3d3e6dcdcc Merge pull request #4171 from element-hq/toger5/fix-unclose-able-error-screen
Fix: error screen cannot be closed in embedded mode.
2026-08-17 14:16:37 +02:00
Timo K.
43ce68e80a Fix: error screen cannot be closed in embedded mode. 2026-08-17 12:08:30 +02:00
Robin
6d5f698357 Merge pull request #4151 from element-hq/more-more-performance
Performance: Fit video to frame without polling RTP stats
2026-08-17 11:36:37 +02:00
Alex Maras
1bb342e9e1 Room joining: if join_rule is absent, assume public 2026-08-11 21:24:38 +08:00
26 changed files with 257 additions and 149 deletions

View File

@@ -9,7 +9,7 @@ on:
- opened
- labeled
push:
branches: [livekit]
branches: [main]
jobs:
versioning:

View File

@@ -4,12 +4,12 @@ on:
push:
branches:
- livekit
- main
paths:
- .github/labels.yml
- .github/workflows/sync-labels.yml
permissions: {} # We use ELEMENT_BOT_TOKEN instead
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
jobs:
sync-labels:
@@ -20,4 +20,4 @@ jobs:
DELETE: true
WET: true
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}

View File

@@ -2,7 +2,7 @@ name: Test
on:
pull_request: {}
push:
branches: [livekit]
branches: [main]
jobs:
vitest:
name: Run unit tests

View File

@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
on:
push:
branches:
- livekit
- main
paths-ignore:
- ".github/**"

View File

@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["livekit", "full-mesh"]
branches: ["main", "full-mesh"]
pull_request: {}
permissions: {}

View File

@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
image: ghcr.io/element-hq/element-web:develop@sha256:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
image: ghcr.io/element-hq/element-web:develop@sha256:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b

View File

@@ -5,5 +5,6 @@ This folder contains documentation for setup, usage, and development of Element
- [Embedded vs standalone mode](./embedded_standalone.md)
- [Url format and parameters](./url_params.md)
- [Global JS controls](./controls.md)
- [MatrixRTC modes](./matrix_rtc_modes.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)

60
docs/matrix_rtc_modes.md Normal file
View File

@@ -0,0 +1,60 @@
# MatrixRTC modes
Element Call is in the middle of a transition of how a call session is
represented and how participants pick an SFU:
- **Membership events**: from room _state_ events
(`org.matrix.msc3401.call.member`) to _sticky_ events
([MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354)),
which are a much better fit for the short lived, per-device nature of call
memberships.
- **SFU selection**: from "everyone connects to the SFU of the oldest member" to
**multi SFU**, where each participant uses its own homeserver's SFU and the
SFUs interconnect.
Not every homeserver supports sticky events yet. Multi SFU is supported on all current (August 2026)
element call clients. The three MatrixRTC modes are the steps of that transition,
so a deployment can pick the newest one its homeserver and its user base can
handle.
## The modes
| Mode | Membership events | SFU selection | JWT endpoint |
| --------------- | ----------------- | ------------- | ---------------------------- |
| `legacy` | state events | oldest member | legacy |
| `compatibility` | state events | multi SFU | legacy |
| `matrix_2_0` | sticky events | multi SFU | Matrix 2.0 (hashed identity) |
**`legacy`** — the lowest common denominator. Use it if calls need to work with
Element Call clients older than v0.17.0, which cannot handle multi SFU calls. (unused)
**`compatibility`** — multi SFU, but still state events. Use it when all Element
Call clients are v0.17.0 or later but the homeserver does not support sticky
events. This is the default. (default)
**`matrix_2_0`** — the target state. Requires a homeserver that advertises
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
token from the Matrix 2.0 JWT endpoint of the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
and identifies the room by a hashed identity instead of a `livekit_alias`.
(Remote memberships always try the new endpoint first and fall back to the
legacy one, so remote participants can be on either.)
## Selecting a mode
Users can choose a mode under **Settings → Developer → MatrixRTC mode**. The
Matrix 2.0 option is disabled if the homeserver does not support sticky events.
A deployment can pin the mode for all its clients in `config.json`, which
disables the Developer Settings choice:
```json
{
"matrix_rtc_mode": "compatibility"
}
```
Valid values are `legacy`, `compatibility` and `matrix_2_0`; an invalid value is
ignored (with a warning) and the user's choice applies. Pinning `matrix_2_0` on a
homeserver without sticky event support makes joining fail with a "sticky events
required" error.

View File

@@ -16,7 +16,7 @@
The following [MSCs](https://github.com/matrix-org/matrix-spec-proposals) are
required for Element Call to work properly:
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md):
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md)
Room Summary API**: In Standalone mode Element Call is able to join rooms
over federation using knocking. In this context MSC3266 is required as it
allows to request a room summary of rooms you are not joined. The summary
@@ -29,19 +29,26 @@ required for Element Call to work properly:
signalling. If disabled it is very likely that you end up with stuck calls in
Matrix rooms.
- **[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/voip-transports-registry/proposals/4519-rtc-transports-registry.md)
MatrixRTC Transports Registry**: Defines an endpoint that clients can use to
query the available MatrixRTC transports (i.e. find your LiveKit SFU).
- **[MSC4222](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sync_v2_state_after/proposals/4222-sync-v2-state-after.md)
Adding `state_after` to sync v2**: Allow clients to opt-in to a change of the
sync v2 API that allows them to correctly track the state of the room. This is
required by Element Call to track room state reliably.
If you're using [Synapse](https://github.com/element-hq/synapse/) as your
homeserver, you'll need to additionally add the following config items to
`homeserver.yaml` to comply with Element Call:
homeserver, you can configure these features by adding the following entries to
`homeserver.yaml`:
```yaml
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4143: MatrixRTC. For historical reasons this flag enables the transports
# endpoint defined in MSC4519.
msc4143_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
@@ -61,6 +68,15 @@ rc_delayed_event_mgmt:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
per_second: 1
burst_count: 20
matrix_rtc:
transports:
# The transport you specify will be made available to clients over the
# /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint as
# defined in MSC4519.
- type: livekit
# Replace this with the actual URL of your MatrixRTC Authorization Service
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
```
As a prerequisite for the
@@ -187,23 +203,6 @@ backend mxrtc_auth_backend
```
#### MatrixRTC transport announcement
Enable the unstable feature flag `msc4143_enabled`, and update the
[`matrix_rtc` section](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#matrix_rtc)
of your Synapse config file:
```yaml
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
```
The transport you specify will be made available to clients over the
`/_matrix/client/unstable/org.matrix.msc4143/rtc/transports` endpoint as defined
in [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143).
## Building Element Call
> [!NOTE]

View File

@@ -127,6 +127,7 @@ export const Default: Story = {
showFooter: true,
hideControls: false,
asOverlay: false,
showModals: true,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,

View File

@@ -26,6 +26,7 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
@@ -76,6 +77,7 @@ export interface FooterState {
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay: boolean;
showModals: boolean;
buttonSize: "md" | "lg";
showLogo: boolean;
@@ -120,6 +122,7 @@ export const CallFooter: FC<FooterProps> = ({
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const showModals = useBehavior(vm.showModals$);
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
@@ -135,7 +138,6 @@ export const CallFooter: FC<FooterProps> = ({
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
@@ -235,7 +237,8 @@ export const CallFooter: FC<FooterProps> = ({
);
}
if (reactionIdentifier && reactionData) {
// Reaction button contains a pretty large menu, so treat it like a modal
if (reactionIdentifier && reactionData && showModals) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
@@ -283,7 +286,9 @@ export const CallFooter: FC<FooterProps> = ({
/>
</>
)}
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
{debugTileLayout ? (
<TilesDebugInfo generation$={vm.tileStoreGeneration$} />
) : undefined}
</div>
);
@@ -316,3 +321,14 @@ export const CallFooter: FC<FooterProps> = ({
</div>
);
};
interface TilesDebugInfoProps {
generation$: Behavior<number | undefined>;
}
// Isolated in its own component since the layout generation updates frequently
// and we can avoid re-rendering the footer this way
const TilesDebugInfo: FC<TilesDebugInfoProps> = ({ generation$ }) => {
const generation = useBehavior(generation$);
return `Tiles generation: ${generation}`;
};

View File

@@ -49,6 +49,7 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
showModals$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;

View File

@@ -161,6 +161,7 @@ export function createCallFooterViewModel(
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
showModals$: callModel.showModals$,
asOverlay$: callModel.edgeToEdge$,
buttonSize$: scope.behavior(
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
@@ -168,12 +169,12 @@ export function createCallFooterViewModel(
openSettings$: scope.behavior(
combineLatest([
isPip$,
callModel.showModals$,
callModel.showHeader$,
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
map(([showModals, showHeader, setSettingsOpen]) =>
showModals && headerStyle !== HeaderStyle.AppBar && showControls
? (): void => setSettingsOpen(true)
: undefined,
),
@@ -239,6 +240,7 @@ export function createLobbyFooterViewModel(
showLogo,
hideControls: false,
asOverlay: false,
showModals: true,
buttonSize: "lg",
openSettings,
hangup,

View File

@@ -179,7 +179,8 @@ export interface ConfigOptions {
/**
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
* overriding any per-user choice from the Developer Settings. If unset,
* the user's Developer Settings choice (or its default of `Legacy`) wins.
* the user's Developer Settings choice (or its default of `Compatibility`)
* wins.
*/
matrix_rtc_mode?: MatrixRTCMode;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,16 +0,0 @@
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_f_4162_80259)">
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
</g>
<defs>
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
</filter>
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
<stop stop-color="#00C59E"/>
<stop offset="0.75" stop-color="#0044A5"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,86 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1200"
height="285"
viewBox="0 0 1200 285"
fill="none"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
filter="url(#filter0_f_3970_9366)"
id="g2"
transform="translate(422.00201)">
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint0_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path1"
style="stroke:url(#paint0_linear_3970_9366)" />
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint1_linear_3970_9366)"
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path2" />
</g>
<defs
id="defs5">
<filter
id="filter0_f_3970_9366"
x="-517.617"
y="-0.00012207"
width="1391.23"
height="848.409"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood2" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
id="feBlend2" />
<feGaussianBlur
stdDeviation="117.758"
result="effect1_foregroundBlur_3970_9366"
id="feGaussianBlur2" />
</filter>
<linearGradient
id="paint0_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop2" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop3" />
</linearGradient>
<linearGradient
id="paint1_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop4" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop5" />
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -78,16 +78,19 @@ body {
body[data-background="gradient"]::before {
content: "";
position: fixed;
inset: 0;
background-image: url("graphics/mobile-gradient.svg");
background-size: auto;
/* Chromium abruptly fades our images to fully transparent at the edge of
the element. If we just make the element a little bigger than the viewport,
this is no longer visible. */
inset: -20px;
background-image: url("graphics/mobile-gradient.png");
background-size: 1400px 305px;
background-position: bottom;
background-repeat: no-repeat;
}
body[data-background="gradient"][data-platform="desktop"]::before {
background-image: url("graphics/desktop-gradient.svg");
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
background-image: url("graphics/desktop-gradient.png");
background-size: max(1440px, 100vw) max(1440px, 100vh);
background-position: center;
}
}

View File

@@ -555,6 +555,9 @@ export const GroupCallView: FC<Props> = ({
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
// We need it here explicitly in case rtcSession.isJoined is false.
void widget?.api.setAlwaysOnScreen(false);
}}
>
{body}

View File

@@ -261,6 +261,7 @@ export const InCallView: FC<InCallViewProps> = ({
const overflowing = useBehavior(vm.overflowing$);
const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
const showModals = useBehavior(vm.showModals$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
@@ -622,7 +623,7 @@ export const InCallView: FC<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
{layout.type !== "pip" && (
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal

View File

@@ -22,6 +22,9 @@ Please see LICENSE in the repository root for full details.
height: 100%;
object-fit: cover;
background-color: var(--video-tile-background);
/* In FF if you add a transform: scale/translate/matrix filter on an element,
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
border-radius: inherit;
}
video.mirror {

View File

@@ -280,7 +280,7 @@ export const useLoadGroupCall = (
);
}
if (
roomSummary === undefined ||
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {

View File

@@ -79,7 +79,7 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { shallowEquals } from "../../utils/array";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
@@ -89,6 +89,7 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
layoutShallowEquals,
type Alignment,
type GridLayoutMedia,
type Layout,
@@ -365,6 +366,11 @@ export interface CallViewModel {
*/
overflowing$: Behavior<boolean>;
/**
* Whether modals such as settings and reactions should be accessible at all.
*/
showModals$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
@@ -941,7 +947,7 @@ export function createCallViewModel$(
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowEquals),
distinctUntilChanged(shallowArrayEquals),
),
);
@@ -1000,7 +1006,7 @@ export function createCallViewModel$(
const spotlight$ = scope.behavior<MediaViewModel[]>(
spotlightAndPip$.pipe(
map(({ spotlight }) => spotlight),
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
),
);
@@ -1209,6 +1215,7 @@ export function createCallViewModel$(
}
return layout;
}),
distinctUntilChanged(),
scope.bind(),
)
.subscribe((orientation) => {
@@ -1448,6 +1455,11 @@ export function createCallViewModel$(
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
),
);
const showModals$ = scope.behavior(
windowMode$.pipe(map((mode) => mode !== "pip")),
);
const settingsOpen$ = new BehaviorSubject(false);
const setSettingsOpen$ = constant((open: boolean) => {
settingsOpen$.next(open);
@@ -1574,7 +1586,11 @@ export function createCallViewModel$(
* The layout of tiles in the call interface.
*/
const layout$ = scope.behavior<Layout>(
layoutInternals$.pipe(map(({ layout }) => layout)),
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged<Layout>(layoutShallowEquals),
),
);
const overflowing$ = scope.behavior<boolean>(
@@ -1820,6 +1836,7 @@ export function createCallViewModel$(
showNameTags$,
showHeader$: showHeader$,
showFooter$: showFooter$,
showModals$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,

View File

@@ -0,0 +1,74 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test, expect } from "vitest";
import {
type Alignment,
layoutShallowEquals,
type Layout,
} from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { BehaviorSubject } from "rxjs";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
pipAlignment$,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: spotlightTile,
grid: [gridTile],
setVisibleTiles: () => {},
};
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
true,
));
test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: gridTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: gridTile },
spotlightExpanded,
),
).toBe(false);
});
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid],
}),
).toBe(true));
test("layoutShallowEquals detects grid arrays with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid, gridTile],
}),
).toBe(false));

View File

@@ -16,6 +16,7 @@ import {
type SpotlightTileViewModel,
} from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
export interface GridLayoutMedia {
type: "grid";
@@ -140,3 +141,30 @@ export type Layout =
| OneOnOneDesktopLayout
| OneOnOneMobileLayout
| PipLayout;
/**
* Tests whether the top-level properties and array elements of layout `a` are
* equal to those of layout `b`. Useful for deduping redundant layout updates.
*/
export function layoutShallowEquals(a: Layout, b: Layout): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!(key in b)) return false;
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
// Special case for arrays so we can detect when the grid tiles arrays are
// essentially the same.
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
}
return true;
}