From 1e128c4da0fbb6d3f166fa70d6f158fbe8b87ff9 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 19:01:20 +0200 Subject: [PATCH 01/30] Performance: Fit video to frame without polling RTP stats It turns out that the timers which repeatedly poll the RTP stats of each video track all run independently of each other and can add up to a small but constant sink of CPU. Meanwhile we can replace these timers with HTMLVideoElement 'resize' event listeners, which is way more efficient and reacts instantly to orientation changes. --- src/state/media/UserMediaViewModel.ts | 25 +-- src/state/media/observeRtpStreamStats.ts | 9 - src/tile/GridTile.tsx | 12 +- src/tile/MediaView.test.tsx | 1 - src/tile/MediaView.tsx | 47 ++++- src/tile/SpotlightTile.tsx | 13 +- src/utils/videoFit.test.ts | 243 +++++------------------ src/utils/videoFit.ts | 111 ++--------- 8 files changed, 111 insertions(+), 350 deletions(-) diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index ea0331030..61d534656 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -33,7 +33,6 @@ import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; import { type ObservableScope } from "../ObservableScope"; import { showConnectionStats } from "../../settings/settings"; import { observeRtpStreamStats$ } from "./observeRtpStreamStats"; -import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts"; /** * A participant's user media (i.e. their microphone and camera feed). @@ -47,7 +46,6 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel { speaking$: Behavior; audioEnabled$: Behavior; videoEnabled$: Behavior; - videoFit$: Behavior<"cover" | "contain">; videoOrientation$: Behavior<"landscape" | "portrait">; toggleCropVideo: () => void; /** @@ -63,12 +61,9 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel { RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; /** - * Set the target dimensions of the HTML element (final dimension after anim). - * This can be used to determine the best video fit (fit to frame / keep ratio). - * @param targetWidth - The target width of the HTML element displaying the video. - * @param targetHeight - The target height of the HTML element displaying the video. + * Set the aspect ratio of the video track to determine the orientation. */ - setTargetDimensions: (targetWidth: number, targetHeight: number) => void; + setVideoAspectRatio: (ratio: number) => void; } export interface BaseUserMediaInputs extends Omit< @@ -98,14 +93,8 @@ export function createBaseUserMedia( ), ); const toggleCropVideo$ = new Subject(); + const videoAspectRatio$ = new BehaviorSubject(NaN); - // The target size of the video element, used to determine the best video fit. - // The target size is the final size of the HTML element after any animations have completed. - const targetSize$ = new BehaviorSubject< - { width: number; height: number } | undefined - >(undefined); - - const videoSize$ = videoSizeFromParticipant$(participant$); return { ...createMemberMedia(scope, { ...inputs, @@ -132,13 +121,11 @@ export function createBaseUserMedia( media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), ), videoOrientation$: scope.behavior( - videoSize$.pipe( - map((s) => (s ? s.width / s.height : 1)), + videoAspectRatio$.pipe( map((aspect) => (aspect > 1 ? "landscape" : "portrait")), ), "portrait", ), - videoFit$: videoFit$(scope, videoSize$, targetSize$), toggleCropVideo: () => toggleCropVideo$.next(), rtcBackendIdentity, handRaised$, @@ -162,8 +149,6 @@ export function createBaseUserMedia( return observeRtpStreamStats$(p, Track.Source.Camera, statsType); }), ), - setTargetDimensions: (targetWidth: number, targetHeight: number): void => { - targetSize$.next({ width: targetWidth, height: targetHeight }); - }, + setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio), }; } diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts index 44181f857..5edc28ef7 100644 --- a/src/state/media/observeRtpStreamStats.ts +++ b/src/state/media/observeRtpStreamStats.ts @@ -69,12 +69,3 @@ export function observeInboundRtpStreamStats$( map((x) => x as RTCInboundRtpStreamStats | undefined), ); } - -export function observeOutboundRtpStreamStats$( - participant: Participant, - source: Track.Source, -): Observable { - return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe( - map((x) => x as RTCOutboundRtpStreamStats | undefined), - ); -} diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 657bf0bc8..83d071eff 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -11,7 +11,6 @@ import { type ReactNode, type Ref, useCallback, - useEffect, useRef, useState, } from "react"; @@ -91,7 +90,6 @@ const RingingMediaTile: FC = ({ } avatarStyle="translucent" videoEnabled={false} - videoFit="cover" mirror={false} {...props} /> @@ -141,19 +139,11 @@ const UserMediaTile: FC = ({ const audioEnabled = useBehavior(vm.audioEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$); const speaking = useBehavior(vm.speaking$); - const videoFit = useBehavior(vm.videoFit$); const rtcBackendIdentity = vm.rtcBackendIdentity; const handRaised = useBehavior(vm.handRaised$); const reaction = useBehavior(vm.reaction$); - // Whenever bounds change, inform the viewModel - useEffect(() => { - if (targetWidth > 0 && targetHeight > 0) { - vm.setTargetDimensions(targetWidth, targetHeight); - } - }, [targetWidth, targetHeight, vm]); - const AudioIcon = playbackMuted ? VolumeOffSolidIcon : audioEnabled @@ -190,7 +180,6 @@ const UserMediaTile: FC = ({ userId={vm.userId} unencryptedWarning={unencryptedWarning} videoEnabled={videoEnabled} - videoFit={videoFit} className={classNames(className, styles.tile, { [styles.speaking]: showSpeaking, [styles.handRaised]: !showSpeaking && handRaised, @@ -233,6 +222,7 @@ const UserMediaTile: FC = ({ raisedHandOnClick={raisedHandOnClick} waitingForMedia={waitingForMedia} focusUrl={focusUrl} + setVideoAspectRatio={vm.setVideoAspectRatio} audioStreamStats={audioStreamStats} videoStreamStats={videoStreamStats} rtcBackendIdentity={rtcBackendIdentity} diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index 099cbaa22..660d37a27 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -33,7 +33,6 @@ describe("MediaView", () => { const baseProps: ComponentProps = { displayName: "some name", videoEnabled: true, - videoFit: "contain", targetWidth: 300, targetHeight: 200, mirror: false, diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index a00fb6cba..d57441687 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details. import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; -import { type FC, type ComponentProps, type ReactNode } from "react"; +import { + type FC, + type ComponentProps, + type ReactNode, + type SyntheticEvent, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -26,6 +32,7 @@ import { type ReactionOption } from "../reactions"; import { ReactionIndicator } from "../reactions/ReactionIndicator"; import { RTCConnectionStats } from "../RTCConnectionStats"; import videoPlaceholder from "../graphics/video-placeholder.gif"; +import { autoVideoFit } from "../utils/videoFit"; interface Props extends ComponentProps { className?: string; @@ -33,7 +40,11 @@ interface Props extends ComponentProps { targetWidth: number; targetHeight: number; video: TrackReferenceOrPlaceholder | undefined; - videoFit: "cover" | "contain"; + /** + * How to fit the video content inside the tile. When undefined, MediaView + * chooses a smart default based on the aspect ratios of the tile and video. + */ + videoFit?: "cover" | "contain"; mirror: boolean; soundWaves?: boolean; userId: string; @@ -55,8 +66,15 @@ interface Props extends ComponentProps { audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; rtcBackendIdentity?: string; - // The focus url, mainly for debugging purposes + /** + * The focus url, mainly for debugging purposes. + */ focusUrl?: string; + /** + * Called whenever the aspect ratio of the video content becomes known or + * otherwise changes. + */ + setVideoAspectRatio?: (ratio: number) => void; } export const MediaView: FC = ({ @@ -89,6 +107,7 @@ export const MediaView: FC = ({ videoStreamStats, rtcBackendIdentity, focusUrl, + setVideoAspectRatio: setTheirVideoAspectRatio, ...props }) => { const { t } = useTranslation(); @@ -100,6 +119,22 @@ export const MediaView: FC = ({ (soundWaves === undefined ? 0.5 : 0.38), ); + const [videoAspectRatio, setOurVideoAspectRatio] = useState(NaN); + const tileAspectRatio = targetWidth / targetHeight; + + // Propagate video dimensions + const setVideoAspectRatio = (ratio: number) => { + setOurVideoAspectRatio(ratio); + setTheirVideoAspectRatio?.(ratio); + }; + const videoRef = (el: HTMLVideoElement | null) => { + if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight); + }; + const onResize = (ev: SyntheticEvent) => + setVideoAspectRatio( + ev.currentTarget.videoWidth / ev.currentTarget.videoHeight, + ); + const warnings = unencryptedWarning && ( = ({ ref={ref} data-testid="videoTile" data-video-enabled={video && videoEnabled} - data-video-fit={videoFit} + data-video-fit={ + videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio) + } data-background={background} {...props} > @@ -158,6 +195,8 @@ export const MediaView: FC = ({ // Set the placeholder to a small transparent image. (On Android web // views the default poster image is particularly ugly.) poster={videoPlaceholder} + ref={videoRef} + onResize={onResize} /> )} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index d21a7f5f5..036e044fe 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -68,6 +68,7 @@ interface SpotlightItemBaseProps { background: "solid" | "transparent"; focusable: boolean; "aria-hidden"?: boolean; + setVideoAspectRatio?: (ratio: number) => void; } interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps { @@ -77,7 +78,6 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps { } interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps { - videoFit: "contain" | "cover"; videoEnabled: boolean; soundWaves: boolean | undefined; } @@ -120,20 +120,12 @@ const SpotlightUserMediaItem: FC = ({ targetHeight, ...props }) => { - const videoFit = useBehavior(vm.videoFit$); const videoEnabled = useBehavior(vm.videoEnabled$); const speaking = useBehavior(vm.speaking$); - // Whenever target bounds change, inform the viewModel - useEffect(() => { - if (targetWidth > 0 && targetHeight > 0) { - vm.setTargetDimensions(targetWidth, targetHeight); - } - }, [targetWidth, targetHeight, vm]); - const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { - videoFit, + setVideoAspectRatio: vm.setVideoAspectRatio, videoEnabled, soundWaves: props.background === "transparent" ? speaking : undefined, targetWidth, @@ -227,7 +219,6 @@ const SpotlightRingingMediaItem: FC = ({ } avatarStyle="translucent" videoEnabled={false} - videoFit="cover" mirror={false} {...props} /> diff --git a/src/utils/videoFit.test.ts b/src/utils/videoFit.test.ts index 5068526ba..ba46b6d84 100644 --- a/src/utils/videoFit.test.ts +++ b/src/utils/videoFit.test.ts @@ -5,259 +5,106 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, expect, test, vi } from "vitest"; -import { - LocalTrack, - type LocalTrackPublication, - type RemoteTrackPublication, - Track, -} from "livekit-client"; +import { describe, expect, test } from "vitest"; -import { ObservableScope } from "../state/ObservableScope"; -import { videoFit$, videoSizeFromParticipant$ } from "./videoFit"; -import { constant } from "../state/Behavior"; -import { - flushPromises, - mockLocalParticipant, - mockRemoteParticipant, -} from "./test"; +import { autoVideoFit } from "./videoFit"; describe("videoFit$ defaults", () => { test.each([ { - videoSize: { width: 1920, height: 1080 }, - tileSize: undefined, + videoAspectRatio: 1920 / 1080, + tileAspectRatio: NaN, }, { - videoSize: { width: 1080, height: 1920 }, - tileSize: undefined, + videoAspectRatio: 1080 / 1920, + tileAspectRatio: NaN, }, { - videoSize: undefined, - tileSize: { width: 1920, height: 1080 }, + videoAspectRatio: NaN, + tileAspectRatio: 1920 / 1080, }, { - videoSize: undefined, - tileSize: { width: 1080, height: 1920 }, + videoAspectRatio: NaN, + tileAspectRatio: 1080 / 1920, }, ])( - "videoFit$ returns `cover` when videoSize is $videoSize and tileSize is $tileSize", - ({ videoSize, tileSize }) => { - const scope = new ObservableScope(); - const videoSize$ = constant(videoSize); - const tileSize$ = constant(tileSize); - - const fit = videoFit$(scope, videoSize$, tileSize$); - expect(fit.value).toBe("cover"); - }, + "videoFit$ returns `cover` when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio", + ({ videoAspectRatio, tileAspectRatio }) => + expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe("cover"), ); }); -const VIDEO_480_L = { width: 640, height: 480 }; -const VIDEO_720_L = { width: 1280, height: 720 }; -const VIDEO_1080_L = { width: 1920, height: 1080 }; +const VIDEO_480_L = 640 / 480; +const VIDEO_720_L = 1280 / 720; +const VIDEO_1080_L = 1920 / 1080; // Some sizes from real world testing, which don't match the standard video sizes exactly -const TILE_SIZE_1_L = { width: 180, height: 135 }; -const TILE_SIZE_3_P = { width: 379, height: 542 }; -const TILE_SIZE_4_L = { width: 957, height: 542 }; +const TILE_SIZE_1_L = 180 / 135; +const TILE_SIZE_3_P = 379 / 542; +const TILE_SIZE_4_L = 957 / 542; // This is the size of an iPhone Xr in portrait mode -const TILE_SIZE_5_P = { width: 414, height: 896 }; +const TILE_SIZE_5_P = 414 / 896; -export function invertSize(size: { width: number; height: number }): { - width: number; - height: number; -} { - return { - width: size.height, - height: size.width, - }; +function inverse(ratio: number): number { + return 1 / ratio; } test.each([ { - videoSize: VIDEO_480_L, - tileSize: TILE_SIZE_1_L, + videoAspectRatio: VIDEO_480_L, + tileAspectRatio: TILE_SIZE_1_L, expected: "cover", }, { - videoSize: invertSize(VIDEO_480_L), - tileSize: TILE_SIZE_1_L, + videoAspectRatio: inverse(VIDEO_480_L), + tileAspectRatio: TILE_SIZE_1_L, expected: "contain", }, { - videoSize: VIDEO_720_L, - tileSize: TILE_SIZE_4_L, + videoAspectRatio: VIDEO_720_L, + tileAspectRatio: TILE_SIZE_4_L, expected: "cover", }, { - videoSize: invertSize(VIDEO_720_L), - tileSize: TILE_SIZE_4_L, + videoAspectRatio: inverse(VIDEO_720_L), + tileAspectRatio: TILE_SIZE_4_L, expected: "contain", }, { - videoSize: invertSize(VIDEO_1080_L), - tileSize: TILE_SIZE_3_P, + videoAspectRatio: inverse(VIDEO_1080_L), + tileAspectRatio: TILE_SIZE_3_P, expected: "cover", }, { - videoSize: VIDEO_1080_L, - tileSize: TILE_SIZE_5_P, + videoAspectRatio: VIDEO_1080_L, + tileAspectRatio: TILE_SIZE_5_P, expected: "contain", }, { - videoSize: invertSize(VIDEO_1080_L), - tileSize: TILE_SIZE_5_P, + videoAspectRatio: inverse(VIDEO_1080_L), + tileAspectRatio: TILE_SIZE_5_P, expected: "cover", }, { // square video - videoSize: { width: 400, height: 400 }, - tileSize: VIDEO_480_L, + videoAspectRatio: 400 / 400, + tileAspectRatio: VIDEO_480_L, expected: "contain", }, { // Should default to cover if the initial size is 0:0. // Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring. - videoSize: VIDEO_480_L, - tileSize: { width: 0, height: 0 }, + videoAspectRatio: VIDEO_480_L, + tileAspectRatio: 0 / 0, expected: "cover", }, { - videoSize: { width: 0, height: 0 }, - tileSize: VIDEO_480_L, + videoAspectRatio: 0 / 0, + tileAspectRatio: VIDEO_480_L, expected: "cover", }, ])( - "videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize", - ({ videoSize, tileSize, expected }) => { - const scope = new ObservableScope(); - const videoSize$ = constant(videoSize); - const tileSize$ = constant(tileSize); - - const fit = videoFit$(scope, videoSize$, tileSize$); - expect(fit.value).toBe(expected); - }, + "videoFit$ returns $expected when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio", + ({ videoAspectRatio, tileAspectRatio, expected }) => + expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe(expected), ); - -describe("extracting video size from participant stats", () => { - function createMockRtpStats( - isInbound: boolean, - props: Partial = {}, - ): RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats { - const baseStats = { - id: "mock-stats-id", - timestamp: Date.now(), - type: isInbound ? "inbound-rtp" : "outbound-rtp", - kind: "video", - ...props, - }; - - return baseStats as RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; - } - - test("get stats for local user", async () => { - const localParticipant = mockLocalParticipant({ - identity: "@local:example.org:AAAAAA", - }); - - const mockReport: RTCStatsReport = new Map([ - [ - "OT01V639885149", - createMockRtpStats(false, { - frameWidth: 1280, - frameHeight: 720, - }), - ], - ]); - - const track = { - source: Track.Source.Camera, - getRTCStatsReport: vi - .fn() - .mockImplementation(async () => Promise.resolve(mockReport)), - } as Partial as LocalTrack; - - // Set up the prototype chain (there is an instanceof check in getRTCStatsReport) - Object.setPrototypeOf(track, LocalTrack.prototype); - - localParticipant.getTrackPublication = vi - .fn() - .mockImplementation((source: Track.Source) => { - if (source === Track.Source.Camera) { - return { - track, - } as unknown as LocalTrackPublication; - } else { - return undefined; - } - }); - - const videoDimensions$ = videoSizeFromParticipant$( - constant(localParticipant), - ); - - const publishedDimensions: { width: number; height: number }[] = []; - videoDimensions$.subscribe((dimensions) => { - if (dimensions) publishedDimensions.push(dimensions); - }); - - await flushPromises(); - - const dimension = publishedDimensions.pop(); - expect(dimension).toEqual({ width: 1280, height: 720 }); - }); - - test("get stats for remote user", async () => { - // vi.useFakeTimers() - const remoteParticipant = mockRemoteParticipant({ - identity: "@bob:example.org:AAAAAA", - }); - - const mockReport: RTCStatsReport = new Map([ - [ - "OT01V639885149", - createMockRtpStats(true, { - frameWidth: 480, - frameHeight: 640, - }), - ], - ]); - - const track = { - source: Track.Source.Camera, - getRTCStatsReport: vi - .fn() - .mockImplementation(async () => Promise.resolve(mockReport)), - } as Partial as LocalTrack; - - // Set up the prototype chain (there is an instanceof check in getRTCStatsReport) - Object.setPrototypeOf(track, LocalTrack.prototype); - - remoteParticipant.getTrackPublication = vi - .fn() - .mockImplementation((source: Track.Source) => { - if (source === Track.Source.Camera) { - return { - track, - } as unknown as RemoteTrackPublication; - } else { - return undefined; - } - }); - - const videoDimensions$ = videoSizeFromParticipant$( - constant(remoteParticipant), - ); - - const publishedDimensions: { width: number; height: number }[] = []; - videoDimensions$.subscribe((dimensions) => { - if (dimensions) publishedDimensions.push(dimensions); - }); - - await flushPromises(); - - const dimension = publishedDimensions.pop(); - expect(dimension).toEqual({ width: 480, height: 640 }); - }); -}); diff --git a/src/utils/videoFit.ts b/src/utils/videoFit.ts index 39dc28c9f..2e9935c6a 100644 --- a/src/utils/videoFit.ts +++ b/src/utils/videoFit.ts @@ -5,107 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { combineLatest, map, type Observable, of, switchMap } from "rxjs"; -import { - type LocalParticipant, - type RemoteParticipant, - Track, -} from "livekit-client"; - -import { type ObservableScope } from "../state/ObservableScope.ts"; -import { type Behavior } from "../state/Behavior.ts"; -import { - observeInboundRtpStreamStats$, - observeOutboundRtpStreamStats$, -} from "../state/media/observeRtpStreamStats"; - -type Size = { - width: number; - height: number; -}; - /** * Computes the appropriate video fit mode ("cover" or "contain") based on the aspect ratios of the video and the tile. * - If the video and tile have the same orientation (both landscape or both portrait), we use "cover" to fill the tile, even if it means cropping. * - If the video and tile have different orientations, we use "contain" to ensure the entire video is visible, even if it means letterboxing (black bars). - * @param scope - the ObservableScope to create the Behavior in - * @param videoSize$ - an Observable of the video size (width and height) or undefined if the size is not yet known (no data yet received). - * @param tileSize$ - an Observable of the tile size (width and height) or undefined if the size is not yet known (not yet rendered). */ -export function videoFit$( - scope: ObservableScope, - videoSize$: Observable, - tileSize$: Observable, -): Behavior<"cover" | "contain"> { - const fit$ = combineLatest([videoSize$, tileSize$]).pipe( - map(([videoSize, tileSize]) => { - if (!videoSize || !tileSize) { - // If we don't have the sizes, default to cover to avoid black bars. - // This is a reasonable default as it will ensure the video fills the tile, even if it means cropping. - return "cover"; - } - if ( - videoSize.width === 0 || - videoSize.height === 0 || - tileSize.width === 0 || - tileSize.height === 0 - ) { - // If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars. - return "cover"; - } - const videoAspectRatio = videoSize.width / videoSize.height; - const tileAspectRatio = tileSize.width / tileSize.height; +export function autoVideoFit( + videoAspectRatio: number, + tileAspectRatio: number, +): "cover" | "contain" { + if (Number.isNaN(videoAspectRatio) || Number.isNaN(tileAspectRatio)) { + // If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars. + return "cover"; + } - // If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa, - // we want to use "contain" (fit) mode to avoid excessive cropping - const videoIsLandscape = videoAspectRatio > 1; - const tileIsLandscape = tileAspectRatio > 1; + // If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa, + // we want to use "contain" (fit) mode to avoid excessive cropping + const videoIsLandscape = videoAspectRatio > 1; + const tileIsLandscape = tileAspectRatio > 1; - // If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.) - // If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.) - return videoIsLandscape === tileIsLandscape ? "cover" : "contain"; - }), - ); - - return scope.behavior(fit$, "cover"); -} - -/** - * Helper function to get the video size from a participant. - * It observes the participant's video track stats and extracts the frame width and height. - * @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected. - * @returns an Observable of the video size (width and height) or undefined if the size cannot be determined. - */ -export function videoSizeFromParticipant$( - participant$: Observable, -): Observable<{ width: number; height: number } | undefined> { - return participant$ - .pipe( - // If we have a participant, observe their video track stats. If not, return undefined. - switchMap((p) => { - if (!p) return of(undefined); - if (p.isLocal) { - return observeOutboundRtpStreamStats$(p, Track.Source.Camera); - } else { - return observeInboundRtpStreamStats$(p, Track.Source.Camera); - } - }), - ) - .pipe( - // Extract the frame width and height from the stats. If we don't have valid stats, return undefined. - map((stats) => { - if (!stats) return undefined; - if ( - // For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size. - typeof stats.frameWidth !== "number" || - typeof stats.frameHeight !== "number" - ) { - return undefined; - } - return { - width: stats.frameWidth, - height: stats.frameHeight, - }; - }), - ); + // If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.) + // If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.) + return videoIsLandscape === tileIsLandscape ? "cover" : "contain"; } From 8aead74ab94031ddde3f203721a0c1b4a3fb3f86 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 19:15:48 +0200 Subject: [PATCH 02/30] Performance: Lower the RTP stats refresh interval Because we no longer use it for computing the fit to frame setting; it's only for developer tools. --- src/state/media/observeRtpStreamStats.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts index 5edc28ef7..d1640382a 100644 --- a/src/state/media/observeRtpStreamStats.ts +++ b/src/state/media/observeRtpStreamStats.ts @@ -32,9 +32,7 @@ export function observeRtpStreamStats$( > { return combineLatest([ observeTrackReference$(participant, source), - // The update frequency is high because we use this value to update the PiP orientation and the fit/fill video tile props based on that - // We want it to be responsive. For just the debug tools 1s would be sufficient. - interval(350).pipe(startWith(0)), + interval(1000).pipe(startWith(0)), ]).pipe( switchMap(async ([trackReference]) => { const track = trackReference?.publication?.track; From 33fc8997f8e5c85be340659064c078a0b589ee67 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 4 Aug 2026 19:21:33 +0200 Subject: [PATCH 03/30] Performance: Use a shared timer for polling RTP stats This is now only relevant in case the user has enabled the developer option to show advanced media statistics, but still an easy performance fix. --- src/state/media/observeRtpStreamStats.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts index d1640382a..afb0da62d 100644 --- a/src/state/media/observeRtpStreamStats.ts +++ b/src/state/media/observeRtpStreamStats.ts @@ -19,10 +19,15 @@ import { startWith, switchMap, map, + share, } from "rxjs"; import { observeTrackReference$ } from "../observeTrackReference"; +// Use a shared timer for all the stats observers so that we don't clog up the +// event loop with hundreds of timers in large calls +const refreshStats$ = interval(1000).pipe(share()); + export function observeRtpStreamStats$( participant: Participant, source: Track.Source, @@ -32,7 +37,7 @@ export function observeRtpStreamStats$( > { return combineLatest([ observeTrackReference$(participant, source), - interval(1000).pipe(startWith(0)), + refreshStats$.pipe(startWith(0)), ]).pipe( switchMap(async ([trackReference]) => { const track = trackReference?.publication?.track; From 1bb342e9e1fb77367fcbefa41f8ad792614e9910 Mon Sep 17 00:00:00 2001 From: Alex Maras Date: Fri, 7 Aug 2026 19:24:21 +0800 Subject: [PATCH 04/30] Room joining: if join_rule is absent, assume public --- src/room/useLoadGroupCall.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 2cd0d40b0..8a7617d85 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -280,7 +280,7 @@ export const useLoadGroupCall = ( ); } if ( - roomSummary === undefined || + roomSummary?.join_rule === undefined || roomSummary.join_rule === JoinRule.Public ) { room = await client.joinRoom(roomId, { From 64a5b2ee5c791974461a40472f6b25f55fd1accf Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 17 Aug 2026 11:19:43 +0200 Subject: [PATCH 05/30] Clarify how an invalid width and height manifest --- src/utils/videoFit.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/videoFit.ts b/src/utils/videoFit.ts index 2e9935c6a..16d7cb843 100644 --- a/src/utils/videoFit.ts +++ b/src/utils/videoFit.ts @@ -15,7 +15,8 @@ export function autoVideoFit( tileAspectRatio: number, ): "cover" | "contain" { if (Number.isNaN(videoAspectRatio) || Number.isNaN(tileAspectRatio)) { - // If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars. + // If we have invalid sizes (e.g. useMeasure returns 0×0 on an initial render), + // default to cover to avoid black bars. return "cover"; } From 43ce68e80afb4fc61ebfcac7219acc94b1d694e6 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 17 Aug 2026 12:08:30 +0200 Subject: [PATCH 06/30] Fix: error screen cannot be closed in embedded mode. --- src/room/GroupCallView.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 831ee9194..fbd589e78 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -555,6 +555,9 @@ export const GroupCallView: FC = ({ }} 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} From b554fa63d17e38cc4f8ee8c8f97012a5d71d916b Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 17 Aug 2026 16:45:26 +0200 Subject: [PATCH 07/30] 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. --- src/components/CallFooter.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index f5c54ee38..b3e0e4f30 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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"; @@ -135,7 +136,6 @@ export const CallFooter: FC = ({ 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$); @@ -283,7 +283,9 @@ export const CallFooter: FC = ({ /> )} - {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined} + {debugTileLayout ? ( + + ) : undefined} ); @@ -316,3 +318,14 @@ export const CallFooter: FC = ({ ); }; + +interface TilesDebugInfoProps { + generation$: Behavior; +} + +// Isolated in its own component since the layout generation updates frequently +// and we can avoid re-rendering the footer this way +const TilesDebugInfo: FC = ({ generation$ }) => { + const generation = useBehavior(generation$); + return `Tiles generation: ${generation}`; +}; From 061d25a54cf6b10cbe2cfe1069e797b18476bf7d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:48:41 +0200 Subject: [PATCH 08/30] 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> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 4dac164ff..2e46e4b1b 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:073e6a49baeac1fb0595593098328ee50589acec7f793bfed12e182c9857080d 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:073e6a49baeac1fb0595593098328ee50589acec7f793bfed12e182c9857080d From a1b37bb3c93be51770c2a990e81c2026d7b3f4d9 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 17 Aug 2026 16:48:03 +0200 Subject: [PATCH 09/30] 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. --- src/state/CallViewModel/CallViewModel.ts | 14 +++-- src/state/layout-types.test.ts | 74 ++++++++++++++++++++++++ src/state/layout-types.ts | 28 +++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/state/layout-types.test.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index f25804599..e0a50e147 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -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, @@ -941,7 +942,7 @@ export function createCallViewModel$( bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), ); }), - distinctUntilChanged(shallowEquals), + distinctUntilChanged(shallowArrayEquals), ), ); @@ -1000,7 +1001,7 @@ export function createCallViewModel$( const spotlight$ = scope.behavior( spotlightAndPip$.pipe( map(({ spotlight }) => spotlight), - distinctUntilChanged(shallowEquals), + distinctUntilChanged(shallowArrayEquals), ), ); @@ -1209,6 +1210,7 @@ export function createCallViewModel$( } return layout; }), + distinctUntilChanged(), scope.bind(), ) .subscribe((orientation) => { @@ -1574,7 +1576,11 @@ export function createCallViewModel$( * The layout of tiles in the call interface. */ const layout$ = scope.behavior( - layoutInternals$.pipe(map(({ layout }) => layout)), + layoutInternals$.pipe( + map(({ layout }) => layout), + // Drop redundant layout updates before they would hit React. + distinctUntilChanged(layoutShallowEquals), + ), ); const overflowing$ = scope.behavior( diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts new file mode 100644 index 000000000..468c15bb5 --- /dev/null +++ b/src/state/layout-types.test.ts @@ -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({ + 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)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 5c63a9c90..be86f2a36 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -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; +} From b1c29e9a41848020fcf75c3622af456234e02e91 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 18 Aug 2026 16:54:07 +0200 Subject: [PATCH 10/30] 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). --- src/graphics/desktop-gradient.png | Bin 0 -> 2651 bytes src/graphics/desktop-gradient.svg | 16 ------ src/graphics/mobile-gradient.png | Bin 0 -> 1690 bytes src/graphics/mobile-gradient.svg | 86 ------------------------------ src/index.css | 15 +++--- 5 files changed, 9 insertions(+), 108 deletions(-) create mode 100644 src/graphics/desktop-gradient.png delete mode 100644 src/graphics/desktop-gradient.svg create mode 100644 src/graphics/mobile-gradient.png delete mode 100644 src/graphics/mobile-gradient.svg diff --git a/src/graphics/desktop-gradient.png b/src/graphics/desktop-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..84b414cfc6e489df43d966d69420b1cbc2040801 GIT binary patch literal 2651 zcmV-h3Z(UkP)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBU**-1n}RA}DqnoFoHM-_&@Ro#8|+2?W3)m)NDj3x$O!6?M&NKg=+ zhz?9f0Y$-)m>>=eIExb>5JfWak&%MKh`HcQ5C@ScLGc+fiG~m`$@RSUdw17zP^+q| zdv|VP(3lfWZ5GwtclX)-^}k-#ec^nZkMnUp&d`uo1&k}WuzL$tSZS2?2^{?cKKU*E z?*Q-JFTD3=xOEH{8^N!kRsn=Upj5#T0V9lL(AhD9s)a}wRwKM7&?3Pq&OqZ05EQT* z;1pLu=@dVsd}WlM6E1xaZoeLGsRg%!)m%7|08mPRR0J=AsAQMY-_FfM*ziit=GMX) zeq%fCizuk#SISBioc@QvUluOCAMV(L8y7-76;1#rLMe_SLgJ7F<%UfJ)$pC**kVYs z$=d<;Jvg6CvTaZ_9W!|R%LKkx*!vLNQNdegLOl@RKyWy7MBQ{m z18r|i8)A}l?8EjUp&6JPy8%t_yDtU&d7=0i+_nqvoCtm;93er`1QsC0zxzqGr$F~+V=Z%$6f^Z;{tcWjW@viPK51K;Zy`cDD2|Ar4OONQzDsZ zvMS6{p&E`c0N;X3N9Ev_eRaD^N&;IOWVCf}u3rTBK4JV}xP2mAe<;kHaAJxg4%H%E zY!|FDunJ`hr2^XR09&PVpadyU&5MkfJ4-KqbA=;tu4Ij{Hn`kYz#6d`>J0d!0-uGO zu7TUG2sq)`Idj+;b0sQ;K}FE5uE_}1t%2iJc3zoOrmdvCIp)f8@% zm1GKcRm~w6`CP(xg=^joA9z9765+@>;zup{U>8hMY=p`-s@n$Iw;VcpL22OK*d>c! zMM4?YHikFB`x$!3tyiXU1YH1gU{Ugpn}u7SgGJ$(nESR(U1b-!1&qwLQN{)~?wJ91 z+&U_o$9@lTBW`19(t&zBq<9y+ReEVQZ-H++6;6uKEVQse4e5Z6lPNo9<802=%|Xka znk}H}aPeu64K*RX8=!fY<$G}*p?4y5$)UNhSW;@;w+pK5*onJH0*)48b5*-)s>Uv= zEj21?(D7*AYG@~x+t|*_Rw$KWj<+TUUn6WA^avQ0xU}{tQ=CRnWo&S}2)kgW5n9He zIcx?hE~%&qu1PJQ+J%K}&Dy~MO+`TSEbay@-i){gD|!N!K-Fg7R@H4&H7dGiwu{<~ zDJyJS#;&aSDAJ-qQM`H_9;FE;4XRDMpEVI`H-ZhNrB||w)Y)|xk!1$z>`q0mjb7pU zE*#@Ds<{X31A8cY2zz!D#%-)>mBnY!%u#1hwb?q&q<^n_;H}EqS+nP==74>{^8Fl~ zcwX6Y;TWS4682H{5%x1>$G`}Q?kfhWp}$pXJZl_lgIwAED(!1ZDoqA&?ycG@M>%kj zsaEz%W=OIj(Z*x(=8!KQ#^%Pm8SDj{qqVT%50)s-QiYnE)&{dO$R$pes4neToY$G! zwZX?E6Jxobv~qXuD;%rIRwiEDt}<6A2j0oxE@ZZ6?!05!uF7IU%@xc+|<33`+%?Eo!PLTVuoGKKdt4~Sh7SueXnBBU%(oujlC zU>BLfo3Qlaw^NN3RPyrE##(GLzuWV7VK;yedagW9u%W&4mQ|Msb4Y*_ z#AaoN%FNt)fwIK2!n3k6RpV*!Y{F`{{|c~by@p!jVQROXl>_aTs!i~`=}5o_nacYe zWy6S16BfV>m;e)l4RfpR++w+#k-ao{ZIhF?Cc|g#Wd!vK-UnFC5_QBR&4Ne^J?!Rr zP3|b1$7~;VWsUdLKu-})fm7R$YR@e(E%CBKbd8v*l7V_WDvH;(!OUT`u>$1VZc?(f zf*^=Qt>ecHw3U5x5%_Ab%MA7uILYRy^30N!!Lmklji*NN2JC6X9?_{%s3HQTD5V1t zrSY&{LcIW~VcWyFfN}@6x9nAm3vVr)2k~NW8U(@H+zj;CMc!+dR+6p;JINrAffG}C zVxaT%R3UU!t*RbRZ5o!UloH+oOhkER4p$E0U;l)^eDmsyzP+d2k7-)n&?zplf*j3ge*LVreTB7IM}KDcxX6njd&7JMxh1AZX8OPLjB8Hm}a>`_t|OH$E|#bCv1YlEl2v#rXq zRQ^~gPdy9sdtN!@)rP$!1K?NRhTR*KMucXi&`4I+h`>b#Tbp9jo*N%$@jJC_-6|WB zQ|Ok)>ZzUb`(5R!XMivLZ$O8}N4^?<>*0Igx_3c!Q9*>JQM3WoPRuqL>}0@=PwjYo zKD0_J9#2DoXY7@~?KtlWBZDJ|E#el;3MK%w|()n=)s1i-N-ACUQ||>fnS_Cb@Ldm z*?i3y-o%Nui~FiPxuab96Y#?`2^)b=>Arwkr - - - - - - - - - - - - - - - diff --git a/src/graphics/mobile-gradient.png b/src/graphics/mobile-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..ab6d1ae432d4713c7360a3325ba9de064ce3b245 GIT binary patch literal 1690 zcmV;L24(q)P)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBU&7)eAyR7l6Q*4=Jf#}UBs|IF?=B=zCQX<48^3j_hu0?uU~AbJTu zkE7?&OX#|QZw=HJK|g}FmfQw0Bavr!K6-IzOLFYgX;T7=-9vyohd<2BuJH4G1QK%p zbap22WL`fy7kCWME@b}V6#n*9K4{nHUnE@2>-A~EIeh+jALR_bdXjIwM*a+c z3-BfI{CJM>_m%L^KMS9I2LJkIANP~{JO1gdhZnz@cRy1bz#LFN9aa9P_%)^Ub#czl4_n zFJbv`-hA_mgcq<{C*OL)U*YQ4$;y(WmOsGxd=Kyy{}YV-R}g1s!jmU60*_&RAv`~x z5spUT#W%v?LRgK$UpC5e5U#6mQ-wtlc4dw`?dR|AM0o{_o_x2TuOB31BpXi_p6nh5 zOHZ!s7U%12a=gj+XU&Jh$WP7)5YEqq$B*ISLO4DaRx9BTm-+@_yHVOo8LRMetBfBD zzEvg_rXn;Ix(aP8a4_$B!|mKMsgX>pWVc_N1vj1?90uQCB}d0`4`+Q7Gt3vsnmaH* zEn|KhB=>#dZdPzET&!;)E-#hELf9NCyNzAf&5l(0e{6|7*#%FYX3lxBsUDYg{Sl$3WPL;6N|8{liM&;!GJ5VrJ=t z7$F^3qe#cxaP5d0CLO7UG^11_CMQ;o-#DA=;A9}jaiz4V zSXMkMOarm3bQ_VyKxe>x#D@WAe(nrq$3QO?mpw)nnkZcqL+EAh9}{4ixCTvw5T~c( z;dC{U?x9BaPS!?9C#B)mDcZ5#Dbne>$C(Trjcp6o51j9JKZbG#0dkm$C&=mT5aNqyx=O<~E$tNNXhX2x$b3QaY`4m<$;W+d0z%=j1Jv^JjUd(Wbn0 z&k#5lo_?xt%H*Th7!N9OGY~p}jZjBKN31OHSP*4VLKpN{V7kBS?R#_ zG_tHCEthyM(OlM$RYqBotT10eS*FPn-)D-;h{?#L1D=(p6;ox(eN)x>*4mJ{TqJ2a zCx4f~axNQJqvzaEP3MwGCW1+l9g#ayw`6YJ*`d0{`wpo)XcM`ZaBb9WM@OR`8k;`` kzdLoFUnHle@%oPPZ-Xm;P0z+m%>V!Z07*qoM6N<$g3_c3r~m)} literal 0 HcmV?d00001 diff --git a/src/graphics/mobile-gradient.svg b/src/graphics/mobile-gradient.svg deleted file mode 100644 index e401846ed..000000000 --- a/src/graphics/mobile-gradient.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/index.css b/src/index.css index d32f1fcfa..93614e29a 100644 --- a/src/index.css +++ b/src/index.css @@ -74,20 +74,23 @@ body { -webkit-tap-highlight-color: transparent; } -@media (min-height: 330px) { +@media (min-height: 33px) { 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; } } From 33a0653ea52b3f4ef458ae2f7d4a67b3c74aa408 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 18 Aug 2026 17:49:14 +0200 Subject: [PATCH 11/30] Fix lobby video preview losing its rounded corners in Firefox Same fix as e1bc4a096b0385047371a4fd7546ecc78c0afdbf, but for the video preview in the lobby. --- src/room/VideoPreview.module.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index c92404365..67eae10bb 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -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 { From f17670b73173abea2e19660447eaa6ab5684c06c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:09:58 +0200 Subject: [PATCH 12/30] 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> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 2e46e4b1b..dd5480b81 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:073e6a49baeac1fb0595593098328ee50589acec7f793bfed12e182c9857080d + image: ghcr.io/element-hq/element-web:develop@sha256:9ac7cde6e2f53684f0c28215130d3aca5c41025bb93c57acd0a6fb2246b685f4 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:073e6a49baeac1fb0595593098328ee50589acec7f793bfed12e182c9857080d + image: ghcr.io/element-hq/element-web:develop@sha256:9ac7cde6e2f53684f0c28215130d3aca5c41025bb93c57acd0a6fb2246b685f4 From cab9698fc76e450a59833d43414c77452fcff39c Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 19 Aug 2026 10:24:26 +0200 Subject: [PATCH 13/30] Undo breakpoint change made during testing --- src/index.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.css b/src/index.css index 93614e29a..77db3394c 100644 --- a/src/index.css +++ b/src/index.css @@ -74,7 +74,7 @@ body { -webkit-tap-highlight-color: transparent; } -@media (min-height: 33px) { +@media (min-height: 330px) { body[data-background="gradient"]::before { content: ""; position: fixed; From 3448a3a2ef0d85f37949072e968e8d9e83d8b240 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 19 Aug 2026 11:46:56 +0200 Subject: [PATCH 14/30] 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. --- docs/self_hosting.md | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/docs/self_hosting.md b/docs/self_hosting.md index 0bdfe6284..e80fd4218 100644 --- a/docs/self_hosting.md +++ b/docs/self_hosting.md @@ -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] From 1c03ad335cf88b06cd36dda2fac488f167ad07f7 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 19 Aug 2026 17:35:29 +0200 Subject: [PATCH 15/30] Hide reactions interface in PiP mode --- src/components/CallFooter.stories.tsx | 1 + src/components/CallFooter.tsx | 5 ++++- src/components/CallFooterViewModel.test.ts | 1 + src/components/CallFooterViewModel.tsx | 8 +++++--- src/room/InCallView.tsx | 3 ++- src/state/CallViewModel/CallViewModel.ts | 11 +++++++++++ 6 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index f95751094..667cb6070 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -127,6 +127,7 @@ export const Default: Story = { showFooter: true, hideControls: false, asOverlay: false, + showModals: true, sharingScreen: false, audioOutputSwitcher: undefined, reactionIdentifier: undefined, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index b3e0e4f30..4f79f236c 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -77,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; @@ -121,6 +122,7 @@ export const CallFooter: FC = ({ 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$); @@ -235,7 +237,8 @@ export const CallFooter: FC = ({ ); } - if (reactionIdentifier && reactionData) { + // Reaction button contains a pretty large menu, so treat it like a modal + if (reactionIdentifier && reactionData && showModals) { buttons.push( {}), } as unknown as CallViewModel; diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index a374a0258..7e391b169 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -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((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, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 322380eee..cab450bc5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -261,6 +261,7 @@ export const InCallView: FC = ({ 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 = ({ {earpieceOverlay} {footer} - {layout.type !== "pip" && ( + {showModals && ( <> ; + /** + * Whether modals such as settings and reactions should be accessible at all. + */ + showModals$: Behavior; + settingsOpen$: Behavior; setSettingsOpen$: Behavior<(open: boolean) => void>; @@ -1450,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); @@ -1826,6 +1836,7 @@ export function createCallViewModel$( showNameTags$, showHeader$: showHeader$, showFooter$: showFooter$, + showModals$, settingsOpen$: settingsOpen$, setSettingsOpen$: setSettingsOpen$, edgeToEdge$, From b60e7452b78d6acfee3b31d307f3506ddc3d8120 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:14:58 +0200 Subject: [PATCH 16/30] 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> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index dd5480b81..55b778a44 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:9ac7cde6e2f53684f0c28215130d3aca5c41025bb93c57acd0a6fb2246b685f4 + image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f 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:9ac7cde6e2f53684f0c28215130d3aca5c41025bb93c57acd0a6fb2246b685f4 + image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f From ff57c217aab047f3d7a5dbe56900addb49ddbe4f Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 20 Aug 2026 09:21:42 +0200 Subject: [PATCH 17/30] Switch label sync workflow to LABEL_SYNC_GITHUB_TOKEN --- .github/workflows/sync-labels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index ec3639365..f7a121b17 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -9,7 +9,7 @@ on: - .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 }} From de3e4c6a7826be9b672255fc36aa65d4e843c56f Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Thu, 20 Aug 2026 15:44:13 +0200 Subject: [PATCH 18/30] update branches used in ci and tooling to main --- .github/workflows/publish-embedded-packages.yaml | 2 +- .github/workflows/sync-labels.yml | 2 +- .github/workflows/test.yaml | 2 +- .github/workflows/translations-upload.yaml | 2 +- .github/workflows/zizmor.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index 34e5885d0..2f1ef2974 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -9,7 +9,7 @@ on: - opened - labeled push: - branches: [livekit] + branches: [main] jobs: versioning: diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index f7a121b17..d50216307 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -4,7 +4,7 @@ on: push: branches: - - livekit + - main paths: - .github/labels.yml - .github/workflows/sync-labels.yml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bf1894eff..3f86f093d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,7 +2,7 @@ name: Test on: pull_request: {} push: - branches: [livekit] + branches: [main] jobs: vitest: name: Run unit tests diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index daf968958..06d9b8721 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -2,7 +2,7 @@ name: Upload translation files to Localazy on: push: branches: - - livekit + - main paths-ignore: - ".github/**" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index caeac8286..35ab5cb36 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈 on: push: - branches: ["livekit", "full-mesh"] + branches: ["main", "full-mesh"] pull_request: {} permissions: {} From 69eddd7846578dc5c8d430459785612c2fafc3f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:10:30 +0200 Subject: [PATCH 19/30] 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> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 55b778a44..0a82c111f 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f + 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:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f + image: ghcr.io/element-hq/element-web:develop@sha256:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b From c3837464a30a43aa46b9aa96070a45d68d061647 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 21 Aug 2026 12:04:24 +0200 Subject: [PATCH 20/30] Add docs about currently used matrixRTC mode --- docs/README.md | 1 + docs/matrix_rtc_modes.md | 60 +++++++++++++++++++++++++++++++++++++ src/config/ConfigOptions.ts | 3 +- 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 docs/matrix_rtc_modes.md diff --git a/docs/README.md b/docs/README.md index e5a5d08a3..56c96a6ca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/matrix_rtc_modes.md b/docs/matrix_rtc_modes.md new file mode 100644 index 000000000..595b881ea --- /dev/null +++ b/docs/matrix_rtc_modes.md @@ -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. diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 75704cfc8..ffe04d69e 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -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; From 4ec75ecda465886ddaf6d65786902ff17a37cbed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:37:19 +0000 Subject: [PATCH 21/30] Update dependency @vector-im/compound-design-tokens to v10.2.4 --- pnpm-lock.yaml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0519571fa..8f40eabf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,10 +132,10 @@ importers: version: 10.3.1(react@19.2.8) '@vector-im/compound-design-tokens': specifier: ^10.0.0 - version: 10.2.3(@types/react@19.2.17)(react@19.2.8) + version: 10.2.4(@types/react@19.2.17)(react@19.2.8) '@vector-im/compound-web': specifier: ^10.0.0 - version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) @@ -3240,14 +3240,20 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vector-im/compound-design-tokens@10.2.3': - resolution: {integrity: sha512-K2xNkjOzYn2Ya0kUqCy7JIYyx9jbkNIWpex2t9m3FUG4wzkNscvaiRJ/a9Wse1aJmwDDARcHU6mUjtHf5os5MA==} + '@vector-im/compound-design-tokens@10.2.4': + resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==} peerDependencies: + '@adobe/leonardo-contrast-colors': ^1.0.0 '@types/react': '*' + chroma-js: ^3.0.0 react: ^17 || ^18 || ^19.0.0 peerDependenciesMeta: + '@adobe/leonardo-contrast-colors': + optional: true '@types/react': optional: true + chroma-js: + optional: true react: optional: true @@ -8711,12 +8717,12 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 - '@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8)': + '@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8)': optionalDependencies: '@types/react': 19.2.17 react: 19.2.8 - '@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -8728,7 +8734,7 @@ snapshots: '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) - '@vector-im/compound-design-tokens': 10.2.3(@types/react@19.2.17)(react@19.2.8) + '@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.17)(react@19.2.8) classnames: 2.5.1 react: 19.2.8 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) From ce4426858baff18e1c2a80e0e50211d607fe8c93 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:37:56 +0000 Subject: [PATCH 22/30] Update dependency livekit-client to v2.22.0 --- pnpm-lock.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0519571fa..b5d410bb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,13 +39,13 @@ importers: version: 11.7.12 '@livekit/components-core': specifier: ^0.12.0 - version: 0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + version: 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) '@livekit/components-react': specifier: ^2.0.0 - version: 2.9.24(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1) + version: 2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1) '@livekit/track-processors': specifier: ^0.7.1 - version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22)) + version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22)) '@mediapipe/tasks-vision': specifier: ^0.10.18 version: 0.10.35 @@ -183,7 +183,7 @@ importers: version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3) livekit-client: specifier: ^2.18.1 - version: 2.21.0(@types/dom-mediacapture-record@1.0.22) + version: 2.22.0(@types/dom-mediacapture-record@1.0.22) lodash-es: specifier: ^4.17.21 version: 4.18.1 @@ -4459,8 +4459,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} @@ -4608,8 +4608,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - livekit-client@2.21.0: - resolution: {integrity: sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==} + livekit-client@2.22.0: + resolution: {integrity: sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -7064,21 +7064,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/components-core@0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + '@livekit/components-core@0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': dependencies: '@floating-ui/dom': 1.7.6 - livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) loglevel: 1.9.1 rxjs: 7.8.2 tslib: 2.8.1 - '@livekit/components-react@2.9.24(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)': + '@livekit/components-react@2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)': dependencies: - '@livekit/components-core': 0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + '@livekit/components-core': 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 events: 3.3.0 jose: 6.2.9 - livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -7090,11 +7090,11 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))': + '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))': dependencies: '@mediapipe/tasks-vision': 0.10.35 '@types/dom-mediacapture-transform': 0.1.11 - livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {} @@ -10053,7 +10053,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.4: {} + jose@6.2.10: {} jose@6.2.9: {} @@ -10197,13 +10197,13 @@ snapshots: lines-and-columns@1.2.4: {} - livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22): dependencies: '@livekit/mutex': 1.1.1 '@livekit/protocol': 1.50.4 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 - jose: 6.2.4 + jose: 6.2.10 loglevel: 1.9.2 sdp-transform: 2.15.0 tslib: 2.8.1 From bfa20ef747233bfe69ce19932d491e3225ea709a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:35:45 +0200 Subject: [PATCH 23/30] Update ghcr.io/element-hq/element-web:develop Docker digest to ccee84c (#4195) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 0a82c111f..964beb35a 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b + image: ghcr.io/element-hq/element-web:develop@sha256:07aa9a386878a0cc472b0b314504caae9438b05f6770ca25936d5c9f2de29ede 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:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b + image: ghcr.io/element-hq/element-web:develop@sha256:07aa9a386878a0cc472b0b314504caae9438b05f6770ca25936d5c9f2de29ede From 465c4136919ffe66c19db15a4cb7c88ef38cfea8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:54:28 +0200 Subject: [PATCH 24/30] Update ghcr.io/element-hq/element-web:develop Docker digest to 0332028 (#4204) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 964beb35a..27d1f3480 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:07aa9a386878a0cc472b0b314504caae9438b05f6770ca25936d5c9f2de29ede + image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e 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:07aa9a386878a0cc472b0b314504caae9438b05f6770ca25936d5c9f2de29ede + image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e From 0443ef55d19bc9ac1e96a4f677b73202748beeb7 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 26 Aug 2026 17:05:38 +0200 Subject: [PATCH 25/30] Remove feature_use_device_session_member_events Support for events with an array of memberships from different devices was removed over a year ago in matrix-js-sdk ffd3c9575e9def576739baf6b1dc329b0db55c0c. --- config/config.devenv.json | 3 --- config/config.sample.json | 3 --- src/config/ConfigOptions.ts | 12 ------------ src/state/CallViewModel/localMember/LocalMember.ts | 7 +------ 4 files changed, 1 insertion(+), 24 deletions(-) diff --git a/config/config.devenv.json b/config/config.devenv.json index df0ff4c18..48602406b 100644 --- a/config/config.devenv.json +++ b/config/config.devenv.json @@ -5,9 +5,6 @@ "server_name": "synapse.m.localhost" } }, - "features": { - "feature_use_device_session_member_events": true - }, "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, diff --git a/config/config.sample.json b/config/config.sample.json index 78f9536da..0baa522e4 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -8,9 +8,6 @@ "livekit": { "livekit_service_url": "https://livekit-jwt.mydomain.com" }, - "features": { - "feature_use_device_session_member_events": true - }, "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", "matrix_rtc_mode": "compatibility", "matrix_rtc_session": { diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index ffe04d69e..de6500b1d 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -86,15 +86,6 @@ export interface ConfigOptions { * Allow to join group calls without audio and video. */ feature_group_calls_without_video_and_audio?: boolean; - - /** - * Send device-specific call session membership state events instead of - * legacy user-specific call membership state events. - * This setting has no effect when the user joins an active call with - * legacy state events. For compatibility, Element Call will always join - * active legacy calls with legacy state events. - */ - feature_use_device_session_member_events?: boolean; }; /** @@ -267,9 +258,6 @@ export interface ResolvedConfigOptions extends ConfigOptions { } export const DEFAULT_CONFIG: ResolvedConfigOptions = { - features: { - feature_use_device_session_member_events: true, - }, sync_disconnect_grace_period_ms: 10000, ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", media_quality: { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index fcc8ed0a6..5108b7e3b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -851,9 +851,7 @@ export function enterRTCSession( // have started tracking by the time calls start getting created. // groupCallOTelMembership?.onJoinCall(); - const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); - const useDeviceSessionMemberEvents = - features?.feature_use_device_session_member_events; + const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode === MatrixRTCMode.Compatibility || @@ -895,9 +893,6 @@ export function enterRTCSession( notificationType, callIntent, manageMediaKeys: encryptMedia, - ...(useDeviceSessionMemberEvents !== undefined && { - useLegacyMemberEvents: !useDeviceSessionMemberEvents, - }), delayedLeaveEventRestartMs: matrixRtcSessionConfig?.delayed_leave_event_restart_ms, delayedLeaveEventDelayMs: From 25e379546eed9a9ac906c6bd10605501c349e9f8 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 26 Aug 2026 18:32:14 +0200 Subject: [PATCH 26/30] Retry CI From 21ca382306e4444069ea174bd31ef7c05e922cef Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 26 Aug 2026 19:14:19 +0200 Subject: [PATCH 27/30] Fix tests --- src/state/CallViewModel/localMember/LocalMember.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 16ffe1493..d273d1acb 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -130,10 +130,7 @@ describe("LocalMembership", () => { }, ], undefined, - expect.objectContaining({ - manageMediaKeys: true, - useLegacyMemberEvents: false, - }), + expect.objectContaining({ manageMediaKeys: true }), ); }); }); From ede29bdf1ba644a457911d43395a0c44d71e3621 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 26 Aug 2026 17:53:06 +0200 Subject: [PATCH 28/30] Remove MatrixRTC legacy mode This is the mode in which we sent membership events with the 'oldest membership' transport selection algorithm, which stopped being the default back in version 0.21.0. Users will no longer be able to select this mode in developer settings, and admins will no longer be able to select legacy mode through the config either. The app will still continue to support *receiving* membership events with the 'oldest membership' transport selection algorithm from others, however. --- docs/matrix_rtc_modes.md | 32 ++-- locales/en/app.json | 4 - playwright/spa-helpers.ts | 4 +- playwright/widget/federated-call.test.ts | 4 +- .../federation-oldest-membership-bug.spec.ts | 85 ---------- .../widget/hotswap-legacy-compat.test.ts | 91 ----------- playwright/widget/test-helpers.ts | 6 +- src/config/ConfigOptions.ts | 4 +- src/settings/DeveloperSettingsTab.test.tsx | 22 +-- src/settings/DeveloperSettingsTab.tsx | 16 -- .../DeveloperSettingsTab.test.tsx.snap | 70 ++------ src/state/CallViewModel/CallViewModel.test.ts | 8 +- src/state/CallViewModel/CallViewModel.ts | 3 +- .../localMember/LocalMember.test.ts | 26 +-- .../CallViewModel/localMember/LocalMember.ts | 1 - .../localMember/LocalTransport.test.ts | 131 +-------------- .../localMember/LocalTransport.ts | 154 +----------------- .../CallViewModel/remoteMembers/Connection.ts | 6 - src/state/CallViewModelWidget.test.ts | 6 +- src/utils/test-viewmodel.ts | 2 +- src/utils/test.ts | 5 +- 21 files changed, 51 insertions(+), 629 deletions(-) delete mode 100644 playwright/widget/federation-oldest-membership-bug.spec.ts delete mode 100644 playwright/widget/hotswap-legacy-compat.test.ts diff --git a/docs/matrix_rtc_modes.md b/docs/matrix_rtc_modes.md index 595b881ea..a30248ba0 100644 --- a/docs/matrix_rtc_modes.md +++ b/docs/matrix_rtc_modes.md @@ -1,36 +1,24 @@ # MatrixRTC modes Element Call is in the middle of a transition of how a call session is -represented and how participants pick an SFU: +represented: 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. -- **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. +Not every homeserver supports sticky events yet. The two MatrixRTC modes +controls whether Element Call uses them. ## 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) +**`compatibility`** — multi SFU, but still state events. Use it when the +homeserver does not support sticky events. This is the 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 @@ -54,7 +42,7 @@ disables the Developer Settings choice: } ``` -Valid values are `legacy`, `compatibility` and `matrix_2_0`; an invalid value is +Valid values are `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. diff --git a/locales/en/app.json b/locales/en/app.json index 2b3e08358..543942e2e 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -80,10 +80,6 @@ "description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)", "label": "Compatibility: state events & multi SFU" }, - "Legacy": { - "description": "Compatible with old versions of EC that do not support multi SFU", - "label": "Legacy: state events & oldest membership SFU" - }, "Matrix_2_0": { "description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later", "label": "Matrix 2.0: sticky events & multi SFU" diff --git a/playwright/spa-helpers.ts b/playwright/spa-helpers.ts index 24869141f..5f99e32d3 100644 --- a/playwright/spa-helpers.ts +++ b/playwright/spa-helpers.ts @@ -101,9 +101,7 @@ async function setRtcModeFromSettings( // Move to Developer tab now await page.getByRole("tab", { name: "Developer" }).click(); - if (mode == "legacy") { - await page.getByText("Legacy: state events").click(); - } else if (mode == "2_0") { + if (mode == "2_0") { await page.getByText("Matrix 2.0").click(); } else { // compat diff --git a/playwright/widget/federated-call.test.ts b/playwright/widget/federated-call.test.ts index 560636a5d..61f4750b3 100644 --- a/playwright/widget/federated-call.test.ts +++ b/playwright/widget/federated-call.test.ts @@ -12,9 +12,7 @@ import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers"; const modePairs: [RtcMode, RtcMode][] = [ ["compat", "compat"], - ["legacy", "legacy"], - ["legacy", "compat"], - ["compat", "legacy"], + // TODO: Compatibility + Matrix 2.0? ]; modePairs.forEach(([rtcMode1, rtcMode2]) => { diff --git a/playwright/widget/federation-oldest-membership-bug.spec.ts b/playwright/widget/federation-oldest-membership-bug.spec.ts deleted file mode 100644 index ab5c70fc8..000000000 --- a/playwright/widget/federation-oldest-membership-bug.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* -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 { expect, test } from "@playwright/test"; - -import { widgetTest } from "../fixtures/widget-user"; -import { HOST1, HOST2, TestHelpers } from "./test-helpers"; - -widgetTest( - "Bug new joiner was not publishing on correct SFU", - async ({ addUser, browserName }) => { - test.skip( - browserName === "firefox", - "This is a bug in the old widget, not a browser problem.", - ); - - test.slow(); - - // 2 users in federation - const florian = await addUser("floriant", HOST1); - const timo = await addUser("timo", HOST2); - - // Florian creates a room and invites Timo to it - const roomName = "Call Room"; - await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]); - - // Timo joins the room - await TestHelpers.acceptRoomInvite(roomName, timo.page); - - // Ensure we are in legacy mode (should be the default) - await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( - florian.page, - "legacy", - ); - await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( - timo.page, - "legacy", - ); - - // Let timo create a call - await TestHelpers.startCallInCurrentRoom(timo.page, false); - await TestHelpers.joinCallFromLobby(timo.page); - - // We want to simulate that the oldest membership authentication is way slower than - // the preffered auth. - // In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian - await florian.page.route( - "**/matrix-rtc.othersite.m.localhost/livekit/jwt/**", - async (route) => { - await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay - await route.continue(); - }, - ); - - // Florian joins the call - await expect(florian.page.getByTestId("join-call-button")).toBeVisible(); - await florian.page.getByTestId("join-call-button").click(); - await TestHelpers.joinCallFromLobby(florian.page); - - await florian.page.waitForTimeout(3000); - await timo.page.waitForTimeout(3000); - - // We should see 2 video tiles everywhere now - for (const user of [timo, florian]) { - const frame = user.page - .locator('iframe[title="Element Call"]') - .contentFrame(); - await expect(frame.getByTestId("videoTile")).toHaveCount(2); - - // No one should be waiting for media - await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); - - // There should be 2 video elements, visible and autoplaying - await expect(frame.locator("video")).toHaveCount(2, { - timeout: 10000, - }); - - await TestHelpers.expectVisibleVideoCount(frame, 2); - } - }, -); diff --git a/playwright/widget/hotswap-legacy-compat.test.ts b/playwright/widget/hotswap-legacy-compat.test.ts deleted file mode 100644 index ed6f15083..000000000 --- a/playwright/widget/hotswap-legacy-compat.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* -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 { expect, test } from "@playwright/test"; - -import { widgetTest } from "../fixtures/widget-user"; -import { HOST1, HOST2, TestHelpers } from "./test-helpers"; - -// ## Issue -// This test reproduces an issue with the publisher. -// When switching local focus, we need to recreate the publisher. -// This failed because of a dead lock in the old publishers destruction. -// -// There are numerus ways to enforece this situation: -// - oldest member swap (manually set the oldest member focus and leave with the prev oldest member) -// This almost never happens in the real worls since clients will set their preferredFoci list to what the oldest member is. -// - switch from oldest member to multi sfu as the NOT the first joiner + the first joiner is on a different sfu than your preferred sfu. -// -// This test uses the "switch from oldest member to multi sfu" approach. -// -// It is a copy of federated-call.test.ts in the `["legacy", "legacy"]` setup, -// which once connected will make the second user switch to multi sfu. -widgetTest( - `Test swapping publisher from ${HOST1} to ${HOST2}`, - async ({ addUser, browserName }) => { - test.slow(); - test.skip( - browserName === "firefox", - "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", - ); - - const florian = await addUser("floriant", HOST1); - const timo = await addUser("timo", HOST2); - - const roomName = "Call Room"; - - await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]); - - await TestHelpers.acceptRoomInvite(roomName, timo.page); - - await florian.page.pause(); - - await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( - florian.page, - "legacy", - ); - await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( - timo.page, - "legacy", - ); - - await TestHelpers.startCallInCurrentRoom(florian.page, false); - await TestHelpers.joinCallFromLobby(florian.page); - - // timo joins - await TestHelpers.joinCallInCurrentRoom(timo.page); - - // We should see 2 video tiles everywhere now - for (const user of [timo, florian]) { - const frame = user.page - .locator('iframe[title="Element Call"]') - .contentFrame(); - await expect(frame.getByTestId("videoTile")).toHaveCount(2); - - // Wait for "Waiting for media..." to disappear (with timeout) - await expect(frame.getByText("Waiting for media...")).not.toBeVisible({ - timeout: 10000, // Maximum time to wait - }); - - // There should be 2 video elements, visible and autoplaying - await expect(frame.locator("video")).toHaveCount(2, { - timeout: 10000, - }); - - await TestHelpers.expectVisibleVideoCount(frame, 2); - } - - // now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1) - await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat"); - await timo.page.waitForTimeout(3000); - - await TestHelpers.expectVisibleVideoCount( - timo.page.locator('iframe[title="Element Call"]').contentFrame(), - 2, - ); - }, -); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 632b2592b..0322596aa 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -21,7 +21,7 @@ const PASSWORD = "foobarbaz1!"; export const HOST1 = "https://app.m.localhost/#/welcome"; export const HOST2 = "https://app.othersite.m.localhost/#/welcome"; -export type RtcMode = "legacy" | "compat" | "2_0"; +export type RtcMode = "compat" | "2_0"; export class TestHelpers { public static async startCallInCurrentRoom( @@ -309,9 +309,7 @@ export class TestHelpers { // Move to Developer tab now await iframe.getByRole("tab", { name: "Developer" }).click(); - if (mode == "legacy") { - await iframe.getByText("Legacy: state events").click(); - } else if (mode == "2_0") { + if (mode == "2_0") { await iframe.getByText("Matrix 2.0").click(); } else { // compat diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index de6500b1d..edd17e4c6 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -12,9 +12,7 @@ Please see LICENSE in the repository root for full details. * Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json. */ export enum MatrixRTCMode { - /** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */ - Legacy = "legacy", - /** Multi-SFU transport, legacy JWT endpoint, no sticky events. */ + /** Multi-SFU transport, legacy JWT endpoint, state events. */ Compatibility = "compatibility", /** * Multi-SFU transport with: diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index d4c7b8c8f..a3a19938c 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -317,19 +317,15 @@ describe("DeveloperSettingsTab", () => { describe("matrix rtc mode", () => { afterEach(() => { - matrixRTCModeSetting.setValue(MatrixRTCMode.Legacy); + matrixRTCModeSetting.setValue(MatrixRTCMode.Compatibility); vi.restoreAllMocks(); }); function getModeRadios(): { - legacy: HTMLInputElement; compatibility: HTMLInputElement; matrix20: HTMLInputElement; } { return { - legacy: screen.getByDisplayValue( - MatrixRTCMode.Legacy, - ) as HTMLInputElement, compatibility: screen.getByDisplayValue( MatrixRTCMode.Compatibility, ) as HTMLInputElement, @@ -359,27 +355,21 @@ describe("DeveloperSettingsTab", () => { const radios = getModeRadios(); expect(radios.compatibility).toBeChecked(); - expect(radios.legacy).not.toBeChecked(); expect(radios.matrix20).not.toBeChecked(); // None are disabled by config; only Matrix_2_0 may be disabled by sticky-events support. - expect(radios.legacy).not.toBeDisabled(); expect(radios.compatibility).not.toBeDisabled(); }); - it.each([ - MatrixRTCMode.Legacy, - MatrixRTCMode.Compatibility, - MatrixRTCMode.Matrix_2_0, - ])( + it.each([MatrixRTCMode.Compatibility, MatrixRTCMode.Matrix_2_0])( "disables all radios and shows the config value (%s) as checked when matrix_rtc_mode is set", async (configMode) => { mockConfig({ matrix_rtc_mode: configMode }); // Local setting is intentionally different from the config value to // prove config wins. matrixRTCModeSetting.setValue( - configMode === MatrixRTCMode.Legacy - ? MatrixRTCMode.Compatibility - : MatrixRTCMode.Legacy, + configMode === MatrixRTCMode.Compatibility + ? MatrixRTCMode.Matrix_2_0 + : MatrixRTCMode.Compatibility, ); const client = createMockMatrixClient(); @@ -397,13 +387,11 @@ describe("DeveloperSettingsTab", () => { ); const radios = getModeRadios(); - expect(radios.legacy).toBeDisabled(); expect(radios.compatibility).toBeDisabled(); expect(radios.matrix20).toBeDisabled(); const checkedValue = ( { - [MatrixRTCMode.Legacy]: radios.legacy, [MatrixRTCMode.Compatibility]: radios.compatibility, [MatrixRTCMode.Matrix_2_0]: radios.matrix20, } as const diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 70db13db9..0b45b4d3e 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -520,22 +520,6 @@ export const DeveloperSettingsTab: FC = ({ {matrixRTCModeForced &&

Your deployment overrides the mode.

}
- - } - > - - - {t("developer_mode.matrixRTCMode.Legacy.description")} - - renders and matches snapshot 1`] = ` class="_container_1ug7n_10" > -
-
- -
- - - Compatible with old versions of EC that do not support multi SFU - -
- -
-
-
- renders and matches snapshot 1`] = ` > Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later) @@ -366,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1ug7n_10" > renders and matches snapshot 1`] = ` > Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later @@ -491,7 +451,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -521,7 +481,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.

@@ -543,7 +503,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -573,7 +533,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

Configure resolution, framerate, bitrate, and codec for screen sharing

@@ -598,7 +558,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > { +const modes = [[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]]; + +describe.each(modes)("CallViewModel (%s mode)", (mode) => { const withCallViewModel = withCallViewModelInMode(mode); test("participants are retained during a focus switch", () => { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 87512b446..43d42a98c 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -441,7 +441,7 @@ export function createCallViewModel$( const matrixRTCMode$ = configMatrixRTCMode !== undefined ? constant(configMatrixRTCMode) - : (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy)); + : (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Compatibility)); // Each hbar seperates a block of input variables required for the CallViewModel to function. // The outputs of this block is written under the hbar. @@ -503,7 +503,6 @@ export function createCallViewModel$( mode === MatrixRTCMode.Matrix_2_0 ? JwtEndpointVersion.Matrix_2_0 : JwtEndpointVersion.Legacy, - useOldestMember: mode === MatrixRTCMode.Legacy, }), ), ), diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index d273d1acb..8743559da 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -59,7 +59,7 @@ import { initializeWidget(); -const MATRIX_RTC_MODE = MatrixRTCMode.Legacy; +const MATRIX_RTC_MODE = MatrixRTCMode.Compatibility; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../../../UrlParams", () => ({ getUrlParams })); vi.mock("@livekit/components-core", () => ({ @@ -71,12 +71,6 @@ vi.mock("@livekit/components-core", () => ({ describe("LocalMembership", () => { describe("enterRTCSession", () => { it("It joins the correct Session", () => { - const focusFromOlderMembership = { - type: "livekit", - livekit_service_url: "http://my-oldest-member-service-url.com", - livekit_alias: "my-oldest-member-service-alias", - }; - mockConfig({ livekit: { livekit_service_url: "http://my-default-service-url.com" }, }); @@ -95,10 +89,6 @@ describe("LocalMembership", () => { }, }, memberships: [], - getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership), - getOldestMembership: vi.fn().mockReturnValue({ - getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]), - }), joinRTCSession: vi.fn(), }) as unknown as MatrixRTCSession; @@ -122,14 +112,12 @@ describe("LocalMembership", () => { memberId: "@alice:example.org:DEVICE", userId: "@alice:example.org", }, - [ - { - livekit_alias: "roomId", - livekit_service_url: "http://my-livekit-service-url.com", - type: "livekit", - }, - ], - undefined, + [], + { + livekit_alias: "roomId", + livekit_service_url: "http://my-livekit-service-url.com", + type: "livekit", + }, expect.objectContaining({ manageMediaKeys: true }), ); }); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 5108b7e3b..bf4ce01cf 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -117,7 +117,6 @@ export type LocalMemberState = }; /* - * - get oldest membership * - get transport to use * - get openId + jwt token * - wait for createTrack() call diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index 89cb831da..09f6ecec0 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -13,7 +13,6 @@ import { it, type MockedObject, vi, - type MockInstance, } from "vitest"; import { type CallMembership, @@ -26,7 +25,6 @@ import { mockConfig, flushPromises, ownMemberMock, - mockRtcMembership, testScope, } from "../../../utils/test"; import { @@ -35,7 +33,7 @@ import { type LocalTransportWithSFUConfig, } from "./LocalTransport"; import { constant } from "../../Behavior"; -import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope"; +import { Epoch, ObservableScope } from "../../ObservableScope"; import { MatrixRTCTransportMissingError, FailToGetOpenIdToken, @@ -58,7 +56,6 @@ describe("LocalTransport", () => { const { advertised$, active$ } = createLocalTransport$({ scope: testScope(), roomId: "!room:example.org", - useOldestMember: false, memberships$: constant(new Epoch([])), client: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -101,7 +98,6 @@ describe("LocalTransport", () => { const { advertised$, active$ } = createLocalTransport$({ scope, roomId: "!example_room_id", - useOldestMember: false, memberships$: constant(new Epoch([])), client: { baseUrl: "https://example.org", @@ -144,7 +140,6 @@ describe("LocalTransport", () => { const { advertised$, active$ } = createLocalTransport$({ scope: testScope(), roomId: "!room:example.org", - useOldestMember: false, memberships$: constant(new Epoch([])), client: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -185,127 +180,6 @@ describe("LocalTransport", () => { }); }); - describe("oldest member mode", () => { - const aliceTransport: LivekitTransportConfig = { - type: "livekit", - livekit_service_url: "https://alice.example.org", - }; - const bobTransport: LivekitTransportConfig = { - type: "livekit", - livekit_service_url: "https://bob.example.org", - }; - const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", { - fociPreferred: [aliceTransport], - }); - const bobMembership = mockRtcMembership("@bob:example.org", "BBB", { - fociPreferred: [bobTransport], - }); - - let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>; - beforeEach(() => { - openIdSpy = vi - .spyOn(openIDSFU, "getSFUConfigWithOpenID") - .mockResolvedValue(openIdResponse); - }); - - it("updates active transport when oldest member changes", async () => { - // Initially, Alice is the only member - const memberships$ = new BehaviorSubject([aliceMembership]); - - const scope = testScope(); - const { advertised$, active$ } = createLocalTransport$({ - scope, - roomId: "!example_room_id", - useOldestMember: true, - memberships$: scope.behavior(memberships$.pipe(trackEpoch())), - client: { - getDomain: () => "example.org", - // eslint-disable-next-line @typescript-eslint/naming-convention - _unstable_getRTCTransports: async () => Promise.resolve([]), - getOpenIdToken: vi.fn(), - getDeviceId: vi.fn(), - baseUrl: "https://example.org", - }, - ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint: JwtEndpointVersion.Legacy, - delayId$: constant("delay_id_mock"), - }); - - expect(active$.value).toBe(null); - await flushPromises(); - // SFU config should've been fetched - expect(openIdSpy).toHaveBeenCalled(); - // Alice's transport should be active and advertised - expect(active$.value?.transport).toStrictEqual(aliceTransport); - expect(advertised$.value).toStrictEqual(aliceTransport); - - // Now Bob joins the call, but Alice is still the oldest member - openIdSpy.mockClear(); - memberships$.next([aliceMembership, bobMembership]); - await flushPromises(); - // No new SFU config should've been fetched - expect(openIdSpy).not.toHaveBeenCalled(); - // Alice's transport should still be active and advertised - expect(active$.value?.transport).toStrictEqual(aliceTransport); - expect(advertised$.value).toStrictEqual(aliceTransport); - - // Now Bob takes Alice's place as the oldest member - openIdSpy.mockClear(); - memberships$.next([bobMembership, aliceMembership]); - // Active transport should reset to null until we have Bob's SFU config - expect(active$.value).toStrictEqual(null); - await flushPromises(); - // Bob's SFU config should've been fetched - expect(openIdSpy).toHaveBeenCalled(); - // Bob's transport should be active, but Alice's should remain advertised - // (since we don't want the change in oldest member to cause a wave of new - // state events) - expect(active$.value?.transport).toStrictEqual(bobTransport); - expect(advertised$.value).toStrictEqual(aliceTransport); - }); - - it("advertises preferred transport when no other member exists", async () => { - // Initially, there are no members - const memberships$ = new BehaviorSubject([]); - - const scope = testScope(); - const { advertised$, active$ } = createLocalTransport$({ - scope, - roomId: "!example_room_id", - useOldestMember: true, - memberships$: scope.behavior(memberships$.pipe(trackEpoch())), - client: { - getDomain: () => "example.org", - // eslint-disable-next-line @typescript-eslint/naming-convention - _unstable_getRTCTransports: async () => - Promise.resolve([aliceTransport]), - getOpenIdToken: vi.fn(), - getDeviceId: vi.fn(), - baseUrl: "https://example.org", - }, - ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint: JwtEndpointVersion.Legacy, - delayId$: constant("delay_id_mock"), - }); - - expect(active$.value).toBe(null); - await flushPromises(); - // Our own preferred transport should be advertised - expect(advertised$.value).toStrictEqual(aliceTransport); - // No transport should be active however (there is still no oldest member) - expect(active$.value).toBe(null); - - // Now Bob joins the call and becomes the oldest member - memberships$.next([bobMembership]); - await flushPromises(); - // We should still advertise our own preferred transport (to avoid - // unnecessary state changes) - expect(advertised$.value).toStrictEqual(aliceTransport); - // Bob's transport should become active - expect(active$.value?.transport).toBe(bobTransport); - }); - }); - type LocalTransportProps = Parameters[0]; describe("transport configuration mechanisms", () => { @@ -320,7 +194,6 @@ describe("LocalTransport", () => { ownMembershipIdentity: ownMemberMock, scope: testScope(), roomId: "!example_room_id", - useOldestMember: false, forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant(null), memberships$: constant(new Epoch([])), @@ -433,7 +306,6 @@ describe("LocalTransport", () => { scope: testScope(), ownMembershipIdentity: ownMemberMock, roomId: "!example_room_id", - useOldestMember: false, forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant(null), memberships$: constant(new Epoch([])), @@ -473,7 +345,6 @@ describe("LocalTransport", () => { ownMembershipIdentity: ownMemberMock, roomId: "!example_room_id", // We want multi-sdu - useOldestMember: false, forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: delayId$, memberships$: constant(new Epoch([])), diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 1a6dddc1f..f98a266fd 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -7,23 +7,16 @@ Please see LICENSE in the repository root for full details. import { type CallMembership, - isLivekitTransportConfig, type LivekitTransportConfig, } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixClient } from "matrix-js-sdk"; import { - catchError, combineLatest, distinctUntilChanged, - first, from, map, - merge, - type Observable, of, - startWith, switchMap, - tap, } from "rxjs"; import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; @@ -47,8 +40,7 @@ import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts"; /* * It figures out “which LiveKit focus URL/alias the local user should use,” - * optionally aligning with the oldest member, and ensures the SFU path is primed - * before advertising that choice. + * and ensures the SFU path is primed before advertising that choice. */ interface Props { scope: ObservableScope; @@ -61,7 +53,6 @@ interface Props { OpenIDClientParts; // Used by the jwt service to create the livekit room and compute the livekit alias. roomId: string; - useOldestMember: boolean; forceJwtEndpoint: JwtEndpointVersion; delayId$: Behavior; } @@ -119,8 +110,6 @@ export interface LocalTransport { /** * Connects to the JWT service and determines the transports that the local member should use. * - * @prop useOldestMember Whether to use the same transport as the oldest member. - * This will only update once the first oldest member appears. Will not recompute if the oldest member leaves. * @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint. * This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity. * (which is expected for non sticky event based rtc member events) @@ -133,18 +122,10 @@ export const createLocalTransport$ = ({ ownMembershipIdentity, client, roomId, - useOldestMember, forceJwtEndpoint, delayId$, }: Props): LocalTransport => { const logger = rootLogger.getChild("[LocalTransport]"); - // The LiveKit transport in use by the oldest RTC membership. `null` when the - // oldest member has no such transport. - const oldestMemberTransport$ = observerOldestMembership$( - scope, - memberships$, - logger, - ); const transportDiscovery = new RtcTransportAutoDiscovery({ client: client, @@ -203,19 +184,6 @@ export const createLocalTransport$ = ({ }), ); - if (useOldestMember) { - return observeLocalTransportForOldestMembership( - scope, - oldestMemberTransport$, - preferredTransport$, - client, - ownMembershipIdentity, - roomId, - logger, - ); - } - - // --- Multi-SFU mode --- // Always publish on and advertise the preferred transport. return { advertised$: scope.behavior( @@ -243,47 +211,6 @@ export const createLocalTransport$ = ({ }; }; -/** - * Observes the oldest member in the room and returns the transport that it uses if it is a livekit transport. - * @param scope - The observable scope. - * @param memberships$ - The observable of the call's memberships.' - */ -function observerOldestMembership$( - scope: ObservableScope, - memberships$: Behavior>, - logger: Logger, -): Behavior { - return scope.behavior( - memberships$.pipe( - map((memberships) => { - const oldestMember = memberships.value[0]; - if (oldestMember === undefined) { - logger.info("Oldest member: not found"); - return null; - } - const transport = oldestMember.getTransport(oldestMember); - if (transport === undefined) { - logger.warn( - `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`, - ); - return null; - } - if (!isLivekitTransportConfig(transport)) { - logger.warn( - `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`, - ); - return null; - } - logger.info( - "Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport", - ); - return transport; - }), - distinctUntilChanged(areLivekitTransportsEqual), - ), - ); -} - /** * Utility to ensure the user can authenticate with the SFU. * We will call `getSFUConfigWithOpenID` once per transport here as it's our @@ -331,85 +258,6 @@ async function doOpenIdAndJWTFromUrl( }; } -function observeLocalTransportForOldestMembership( - scope: ObservableScope, - oldestMemberTransport$: Behavior, - preferredTransport$: Observable, - client: Pick< - MatrixClient, - "getDomain" | "baseUrl" | "_unstable_getRTCTransports" - > & - OpenIDClientParts, - ownMembershipIdentity: CallMembershipIdentityParts, - roomId: string, - logger: Logger, -): LocalTransport { - // Ensure we can authenticate with the SFU. - const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe( - switchMap((transport) => { - // Oldest member not available -we are first- (or invalid SFU config). - if (transport === null) return of(null); - - // Whenever there is transport change we want to revert - // to no transport while we do the authentication. - // So do a from(promise) here to be able to startWith(null) - return from( - doOpenIdAndJWTFromUrl( - transport, - JwtEndpointVersion.Legacy, - ownMembershipIdentity, - roomId, - client, - undefined, - logger, - ), - ).pipe( - catchError((e: unknown) => { - logger.error( - `Failed to authenticate to transport ${transport.livekit_service_url}`, - e, - ); - throw mapAuthErrorToUserFriendlyError(e); - }), - startWith(null), - ); - }), - ); - - // --- Oldest member mode --- - return { - // Never update the transport that we advertise in our membership. Just - // take the first valid oldest member or preferred transport that we learn - // about, and stick with that. This avoids unnecessary SFU hops and room - // state changes. - advertised$: scope.behavior( - merge( - authenticatedOldestMemberTransport$.pipe( - map((t) => t?.transport ?? null), - ), - preferredTransport$.pipe(map((t) => t.transport)), - ).pipe( - first((t) => t !== null), - tap((t) => - logger.info(`Advertise transport: ${t.livekit_service_url}`), - ), - ), - null, - ), - // Publish on the transport used by the oldest member. - active$: scope.behavior( - authenticatedOldestMemberTransport$.pipe( - tap((t) => - logger.info( - `Publish on transport: ${t?.transport.livekit_service_url}`, - ), - ), - ), - null, - ), - }; -} - function mapAuthErrorToUserFriendlyError(e: unknown): Error { if ( e instanceof FailToGetOpenIdToken || diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 013bd96c7..f320e6303 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -36,7 +36,6 @@ import { SFURoomCreationRestrictedError, UnknownCallError, } from "../../../utils/errors.ts"; -import { type JwtEndpointVersion } from "../localMember/LocalTransport.ts"; export interface ConnectionOpts { /** @@ -44,11 +43,6 @@ export interface ConnectionOpts { * On top the local transport will send additional data to the jwt server to use delayed event delegation. */ existingSFUConfig?: SFUConfig; - /** - * For local connections that use the oldest member pattern. here we have not prefetched the sfuConfig - * and hence we need to let the connection do the jwt token fetching. - */ - forceJwtEndpoint?: JwtEndpointVersion; /** The identity parts to use on this connection */ ownMembershipIdentity: CallMembershipIdentityParts; /** The media transport to connect to. */ diff --git a/src/state/CallViewModelWidget.test.ts b/src/state/CallViewModelWidget.test.ts index 2e4ef39dd..2f331bd32 100644 --- a/src/state/CallViewModelWidget.test.ts +++ b/src/state/CallViewModelWidget.test.ts @@ -35,11 +35,7 @@ vi.mock("../widget", () => ({ }, })); -it.each([ - [MatrixRTCMode.Legacy], - [MatrixRTCMode.Compatibility], - [MatrixRTCMode.Matrix_2_0], -])( +it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])( "expect leave when ElementWidgetActions.HangupCall is called (%s mode)", async (mode) => { const pr = Promise.withResolvers(); diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 526fc95c9..c8282ce51 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -171,7 +171,7 @@ export function getBasicCallViewModelEnvironment( setE2EEEnabled: async () => Promise.resolve(), }), connectionState$: constant(ConnectionState.Connected), - matrixRTCMode$: constant(MatrixRTCMode.Legacy), + matrixRTCMode$: constant(MatrixRTCMode.Compatibility), ...callViewModelOptions, }, handRaisedSubject$, diff --git a/src/utils/test.ts b/src/utils/test.ts index 206db88f5..fd4ce58f1 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -237,7 +237,7 @@ export function mockRtcMembership( fociPreferred: [exampleTransport], focusActive: { type: "livekit" as const, - focus_selection: "oldest_membership" as const, + focus_selection: "multi_sfu" as const, }, callId: "", membership: {}, @@ -463,9 +463,6 @@ export class MockRTCSession extends TypedEventEmitter< session.reemitEncryptionKeys = vi .fn<() => void>() .mockReturnValue(undefined); - session.getOldestMembership = vi - .fn<() => CallMembership | undefined>() - .mockReturnValue(this.memberships[0]); return session; } From 5982586f245c3e10418baa2d455bc7eaa5830cc2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:06:37 +0000 Subject: [PATCH 29/30] Update ghcr.io/element-hq/element-web:develop Docker digest to f163e7a (#4211) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 27d1f3480..d6fc3d274 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e + image: ghcr.io/element-hq/element-web:develop@sha256:f163e7a3fa18d4de4e6fae3591b713eacb4bfc78732b7bcad9dcd4e36378a6db 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:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e + image: ghcr.io/element-hq/element-web:develop@sha256:f163e7a3fa18d4de4e6fae3591b713eacb4bfc78732b7bcad9dcd4e36378a6db From a4dda428f501ff5d9e32cf72cf366a1cd2185f44 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:01:16 +0000 Subject: [PATCH 30/30] Update ghcr.io/element-hq/element-web:develop Docker digest to 133160c (#4213) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index d6fc3d274..3ed066063 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -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:f163e7a3fa18d4de4e6fae3591b713eacb4bfc78732b7bcad9dcd4e36378a6db + image: ghcr.io/element-hq/element-web:develop@sha256:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10 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:f163e7a3fa18d4de4e6fae3591b713eacb4bfc78732b7bcad9dcd4e36378a6db + image: ghcr.io/element-hq/element-web:develop@sha256:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10