From 8c1e69f15aa2f0bc595d28bad5efd3286415350f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 01:31:26 +0000 Subject: [PATCH 01/58] Update dependency @vector-im/compound-design-tokens to v4.0.5 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index d964f4b5..ed50c03e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5604,8 +5604,8 @@ __metadata: linkType: hard "@vector-im/compound-design-tokens@npm:^4.0.0": - version: 4.0.4 - resolution: "@vector-im/compound-design-tokens@npm:4.0.4" + version: 4.0.5 + resolution: "@vector-im/compound-design-tokens@npm:4.0.5" peerDependencies: "@types/react": "*" react: ^17 || ^18 || ^19.0.0 @@ -5614,7 +5614,7 @@ __metadata: optional: true react: optional: true - checksum: 10c0/e6ff6a956082f4a288237e7c7e60044319d7195cad0d5175dad7115270119f80c43252520db8f1a514b762f92dd5b7059c1217d7ccbe81daf71c426cbfeaf3dd + checksum: 10c0/ae84195a5c854ac54e64b615729cb5f669da1b798b961be4370e1baf257a57e871f53c6df555702513a99fcc5576bff50033a2ed676b36c7344f1d470826f10f languageName: node linkType: hard From 23ff26a9882e1b9156710e651055793d8538a9bc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:39:41 +0000 Subject: [PATCH 02/58] Update dependency @livekit/components-react to v2.9.13 --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index d964f4b5..d8ddd89f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2714,14 +2714,14 @@ __metadata: linkType: hard "@livekit/components-react@npm:^2.0.0": - version: 2.9.12 - resolution: "@livekit/components-react@npm:2.9.12" + version: 2.9.13 + resolution: "@livekit/components-react@npm:2.9.13" dependencies: "@livekit/components-core": "npm:0.12.8" clsx: "npm:2.1.1" usehooks-ts: "npm:3.1.1" peerDependencies: - "@livekit/krisp-noise-filter": ^0.2.12 + "@livekit/krisp-noise-filter": ^0.2.12 || ^0.3.0 livekit-client: ^2.13.3 react: ">=18" react-dom: ">=18" @@ -2729,7 +2729,7 @@ __metadata: peerDependenciesMeta: "@livekit/krisp-noise-filter": optional: true - checksum: 10c0/445099ff7d335f8b9908e814e327d43477ea19fb93c428fadb0bf2aa56e323171af10fcc9493f686ad5060fb75e0d5c66632e55cd648a76fafe146827c53bd87 + checksum: 10c0/161d7ccb3f4e3e7bca541ba01c8d4196e997761d55f3fb5a639ccb29dbc342b4957c3a485692167cd07343aeacafa04b0f72f2407f1dae1f16a95153d68053f2 languageName: node linkType: hard From 7e81eca0683dd95724c7eae9ca9026f2506f50b8 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Jun 2025 17:14:03 -0400 Subject: [PATCH 03/58] Create a Behavior type --- .eslintrc.cjs | 2 +- src/state/Behavior.ts | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/state/Behavior.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index cada6b46..b734c520 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -44,7 +44,7 @@ module.exports = { ], // To encourage good usage of RxJS: "rxjs/no-exposed-subjects": "error", - "rxjs/finnish": "error", + "rxjs/finnish": ["error", { names: { "^this$": false } }], }, settings: { react: { diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts new file mode 100644 index 00000000..8b2ce9a5 --- /dev/null +++ b/src/state/Behavior.ts @@ -0,0 +1,52 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { BehaviorSubject, Observable } from "rxjs"; + +import { type ObservableScope } from "./ObservableScope"; + +/** + * A stateful, read-only reactive value. As an Observable, it is "hot" and + * always replays the current value upon subscription. + * + * A Behavior is to BehaviorSubject what Observable is to Subject; it does not + * provide a way to imperatively set new values. For more info on the + * distinction between Behaviors and Observables, see + * https://monoid.dk/post/behaviors-and-streams-why-both/. + */ +export type Behavior = Omit, "next" | "observers">; + +/** + * Creates a Behavior which never changes in value. + */ +export function constant(value: T): Behavior { + return new BehaviorSubject(value); +} + +declare module "rxjs" { + interface Observable { + /** + * Converts this Observable into a Behavior. This requires the Observable to + * synchronously emit an initial value. + */ + behavior(scope: ObservableScope): Behavior; + } +} + +const nothing = Symbol("nothing"); + +Observable.prototype.behavior = function ( + this: Observable, + scope: ObservableScope, +): Behavior { + const subject$ = new BehaviorSubject(nothing); + // Push values from the Observable into the BehaviorSubject + this.pipe(scope.bind()).subscribe(subject$); + if (subject$.value === nothing) + throw new Error("Behavior failed to synchronously emit an initial value"); + return subject$ as Behavior; +}; From 35ed313577bcf5e1e4247812c1a97f92e2e9a46d Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Jun 2025 17:14:21 -0400 Subject: [PATCH 04/58] Replace ObservableScope.state with Observable.behavior --- src/state/CallViewModel.ts | 1008 +++++++++++++++++----------------- src/state/MediaViewModel.ts | 286 +++++----- src/state/ObservableScope.ts | 24 +- src/utils/test.ts | 9 +- 4 files changed, 670 insertions(+), 657 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index fc1222c4..a9bf5413 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -94,6 +94,7 @@ import { observeSpeaker$ } from "./observeSpeaker"; import { shallowEquals } from "../utils/array"; import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; +import { type Behavior } from "./Behavior"; // How long we wait after a focus switch before showing the real participant // list again @@ -271,9 +272,9 @@ class UserMedia { this.participant$.asObservable() as Observable, encryptionSystem, livekitRoom, - displayname$, - handRaised$, - reaction$, + displayname$.behavior(this.scope), + handRaised$.behavior(this.scope), + reaction$.behavior(this.scope), ); } else { this.vm = new RemoteUserMediaViewModel( @@ -284,29 +285,30 @@ class UserMedia { >, encryptionSystem, livekitRoom, - displayname$, - handRaised$, - reaction$, + displayname$.behavior(this.scope), + handRaised$.behavior(this.scope), + reaction$.behavior(this.scope), ); } - this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state()); + this.speaker$ = observeSpeaker$(this.vm.speaking$).behavior(this.scope); - this.presenter$ = this.participant$.pipe( - switchMap( - (p) => - (p && - observeParticipantEvents( - p, - ParticipantEvent.TrackPublished, - ParticipantEvent.TrackUnpublished, - ParticipantEvent.LocalTrackPublished, - ParticipantEvent.LocalTrackUnpublished, - ).pipe(map((p) => p.isScreenShareEnabled))) ?? - of(false), - ), - this.scope.state(), - ); + this.presenter$ = this.participant$ + .pipe( + switchMap( + (p) => + (p && + observeParticipantEvents( + p, + ParticipantEvent.TrackPublished, + ParticipantEvent.TrackUnpublished, + ParticipantEvent.LocalTrackPublished, + ParticipantEvent.LocalTrackUnpublished, + ).pipe(map((p) => p.isScreenShareEnabled))) ?? + of(false), + ), + ) + .behavior(this.scope); } public updateParticipant( @@ -325,6 +327,7 @@ class UserMedia { } class ScreenShare { + private readonly scope = new ObservableScope(); public readonly vm: ScreenShareViewModel; private readonly participant$: BehaviorSubject< LocalParticipant | RemoteParticipant @@ -346,12 +349,13 @@ class ScreenShare { this.participant$.asObservable(), encryptionSystem, liveKitRoom, - displayname$, + displayname$.behavior(this.scope), participant.isLocal, ); } public destroy(): void { + this.scope.end(); this.vm.destroy(); } } @@ -397,7 +401,7 @@ export class CallViewModel extends ViewModel { * The raw list of RemoteParticipants as reported by LiveKit */ private readonly rawRemoteParticipants$: Observable = - connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state()); + connectedParticipantsObserver(this.livekitRoom).behavior(this.scope); /** * Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that @@ -471,38 +475,42 @@ export class CallViewModel extends ViewModel { fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), // Handle room membership changes (and displayname updates) fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), - ).pipe( - startWith(null), - map(() => { - const displaynameMap = new Map(); - const { room, memberships } = this.matrixRTCSession; + ) + .pipe( + startWith(null), + map(() => { + const displaynameMap = new Map(); + const { room, memberships } = this.matrixRTCSession; - // We only consider RTC members for disambiguation as they are the only visible members. - for (const rtcMember of memberships) { - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - const { member } = getRoomMemberFromRtcMember(rtcMember, room); - if (!member) { - logger.error("Could not find member for media id:", matrixIdentifier); - continue; + // We only consider RTC members for disambiguation as they are the only visible members. + for (const rtcMember of memberships) { + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + const { member } = getRoomMemberFromRtcMember(rtcMember, room); + if (!member) { + logger.error( + "Could not find member for media id:", + matrixIdentifier, + ); + continue; + } + const disambiguate = shouldDisambiguate(member, memberships, room); + displaynameMap.set( + matrixIdentifier, + calculateDisplayName(member, disambiguate), + ); } - const disambiguate = shouldDisambiguate(member, memberships, room); - displaynameMap.set( - matrixIdentifier, - calculateDisplayName(member, disambiguate), - ); - } - return displaynameMap; - }), - // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower - // than on Chrome/Firefox). This means it is important that we share() the result so that we - // don't do this work more times than we need to. This is achieve through the state() operator: - this.scope.state(), - ); + return displaynameMap; + }), + // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower + // than on Chrome/Firefox). This means it is important that we multicast the result so that we + // don't do this work more times than we need to. This is achieved by converting to a behavior: + ) + .behavior(this.scope); /** * List of MediaItems that we want to display */ - private readonly mediaItems$: Observable = combineLatest([ + private readonly mediaItems$: Behavior = combineLatest([ this.remoteParticipants$, observeParticipantMedia(this.livekitRoom.localParticipant), duplicateTiles.value$, @@ -514,90 +522,68 @@ export class CallViewModel extends ViewModel { MatrixRTCSessionEvent.MembershipsChanged, ).pipe(startWith(null)), showNonMemberTiles.value$, - ]).pipe( - scan( - ( - prevItems, - [ - remoteParticipants, - { participant: localParticipant }, - duplicateTiles, - _membershipsChanged, - showNonMemberTiles, - ], - ) => { - const newItems = new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - const room = this.matrixRTCSession.room; - // m.rtc.members are the basis for calculating what is visible in the call - for (const rtcMember of this.matrixRTCSession.memberships) { - const { member, id: livekitParticipantId } = - getRoomMemberFromRtcMember(rtcMember, room); - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + ]) + .pipe( + scan( + ( + prevItems, + [ + remoteParticipants, + { participant: localParticipant }, + duplicateTiles, + _membershipsChanged, + showNonMemberTiles, + ], + ) => { + const newItems = new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + const room = this.matrixRTCSession.room; + // m.rtc.members are the basis for calculating what is visible in the call + for (const rtcMember of this.matrixRTCSession.memberships) { + const { member, id: livekitParticipantId } = + getRoomMemberFromRtcMember(rtcMember, room); + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - let participant: - | LocalParticipant - | RemoteParticipant - | undefined = undefined; - if (livekitParticipantId === "local") { - participant = localParticipant; - } else { - participant = remoteParticipants.find( - (p) => p.identity === livekitParticipantId, - ); - } - - if (!member) { - logger.error( - "Could not find member for media id: ", - livekitParticipantId, - ); - } - for (let i = 0; i < 1 + duplicateTiles; i++) { - const indexedMediaId = `${livekitParticipantId}:${i}`; - let prevMedia = prevItems.get(indexedMediaId); - if (prevMedia && prevMedia instanceof UserMedia) { - prevMedia.updateParticipant(participant); - if (prevMedia.vm.member === undefined) { - // We have a previous media created because of the `debugShowNonMember` flag. - // In this case we actually replace the media item. - // This "hack" never occurs if we do not use the `debugShowNonMember` debugging - // option and if we always find a room member for each rtc member (which also - // only fails if we have a fundamental problem) - prevMedia = undefined; - } + let participant: + | LocalParticipant + | RemoteParticipant + | undefined = undefined; + if (livekitParticipantId === "local") { + participant = localParticipant; + } else { + participant = remoteParticipants.find( + (p) => p.identity === livekitParticipantId, + ); } - yield [ - indexedMediaId, - // We create UserMedia with or without a participant. - // This will be the initial value of a BehaviourSubject. - // Once a participant appears we will update the BehaviourSubject. (see above) - prevMedia ?? - new UserMedia( - indexedMediaId, - member, - participant, - this.encryptionSystem, - this.livekitRoom, - this.memberDisplaynames$.pipe( - map((m) => m.get(matrixIdentifier) ?? "[πŸ‘»]"), - ), - this.handsRaised$.pipe( - map((v) => v[matrixIdentifier]?.time ?? null), - ), - this.reactions$.pipe( - map((v) => v[matrixIdentifier] ?? undefined), - ), - ), - ]; - if (participant?.isScreenShareEnabled) { - const screenShareId = `${indexedMediaId}:screen-share`; + if (!member) { + logger.error( + "Could not find member for media id: ", + livekitParticipantId, + ); + } + for (let i = 0; i < 1 + duplicateTiles; i++) { + const indexedMediaId = `${livekitParticipantId}:${i}`; + let prevMedia = prevItems.get(indexedMediaId); + if (prevMedia && prevMedia instanceof UserMedia) { + prevMedia.updateParticipant(participant); + if (prevMedia.vm.member === undefined) { + // We have a previous media created because of the `debugShowNonMember` flag. + // In this case we actually replace the media item. + // This "hack" never occurs if we do not use the `debugShowNonMember` debugging + // option and if we always find a room member for each rtc member (which also + // only fails if we have a fundamental problem) + prevMedia = undefined; + } + } yield [ - screenShareId, - prevItems.get(screenShareId) ?? - new ScreenShare( - screenShareId, + indexedMediaId, + // We create UserMedia with or without a participant. + // This will be the initial value of a BehaviourSubject. + // Once a participant appears we will update the BehaviourSubject. (see above) + prevMedia ?? + new UserMedia( + indexedMediaId, member, participant, this.encryptionSystem, @@ -605,77 +591,103 @@ export class CallViewModel extends ViewModel { this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[πŸ‘»]"), ), + this.handsRaised$.pipe( + map((v) => v[matrixIdentifier]?.time ?? null), + ), + this.reactions$.pipe( + map((v) => v[matrixIdentifier] ?? undefined), + ), ), ]; - } - } - } - }.bind(this)(), - ); - // Generate non member items (items without a corresponding MatrixRTC member) - // Those items should not be rendered, they are participants in LiveKit that do not have a corresponding - // MatrixRTC members. This cannot be any good: - // - A malicious user impersonates someone - // - Someone injects abusive content - // - The user cannot have encryption keys so it makes no sense to participate - // We can only trust users that have a MatrixRTC member event. - // - // This is still available as a debug option. This can be useful - // - If one wants to test scalability using the LiveKit CLI. - // - If an experimental project does not yet do the MatrixRTC bits. - // - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive. - const newNonMemberItems = showNonMemberTiles - ? new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - for (const participant of remoteParticipants) { - for (let i = 0; i < 1 + duplicateTiles; i++) { - const maybeNonMemberParticipantId = - participant.identity + ":" + i; - if (!newItems.has(maybeNonMemberParticipantId)) { - const nonMemberId = maybeNonMemberParticipantId; - yield [ - nonMemberId, - prevItems.get(nonMemberId) ?? - new UserMedia( - nonMemberId, - undefined, - participant, - this.encryptionSystem, - this.livekitRoom, - this.memberDisplaynames$.pipe( - map((m) => m.get(participant.identity) ?? "[πŸ‘»]"), - ), - of(null), - of(null), + if (participant?.isScreenShareEnabled) { + const screenShareId = `${indexedMediaId}:screen-share`; + yield [ + screenShareId, + prevItems.get(screenShareId) ?? + new ScreenShare( + screenShareId, + member, + participant, + this.encryptionSystem, + this.livekitRoom, + this.memberDisplaynames$.pipe( + map((m) => m.get(matrixIdentifier) ?? "[πŸ‘»]"), ), - ]; - } + ), + ]; } } - }.bind(this)(), - ) - : new Map(); - if (newNonMemberItems.size > 0) { - logger.debug("Added NonMember items: ", newNonMemberItems); - } + } + }.bind(this)(), + ); - const combinedNew = new Map([ - ...newNonMemberItems.entries(), - ...newItems.entries(), - ]); + // Generate non member items (items without a corresponding MatrixRTC member) + // Those items should not be rendered, they are participants in LiveKit that do not have a corresponding + // MatrixRTC members. This cannot be any good: + // - A malicious user impersonates someone + // - Someone injects abusive content + // - The user cannot have encryption keys so it makes no sense to participate + // We can only trust users that have a MatrixRTC member event. + // + // This is still available as a debug option. This can be useful + // - If one wants to test scalability using the LiveKit CLI. + // - If an experimental project does not yet do the MatrixRTC bits. + // - If someone wants to debug if the LiveKit connection works but MatrixRTC room state failed to arrive. + const newNonMemberItems = showNonMemberTiles + ? new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + for (const participant of remoteParticipants) { + for (let i = 0; i < 1 + duplicateTiles; i++) { + const maybeNonMemberParticipantId = + participant.identity + ":" + i; + if (!newItems.has(maybeNonMemberParticipantId)) { + const nonMemberId = maybeNonMemberParticipantId; + yield [ + nonMemberId, + prevItems.get(nonMemberId) ?? + new UserMedia( + nonMemberId, + undefined, + participant, + this.encryptionSystem, + this.livekitRoom, + this.memberDisplaynames$.pipe( + map( + (m) => m.get(participant.identity) ?? "[πŸ‘»]", + ), + ), + of(null), + of(null), + ), + ]; + } + } + } + }.bind(this)(), + ) + : new Map(); + if (newNonMemberItems.size > 0) { + logger.debug("Added NonMember items: ", newNonMemberItems); + } - for (const [id, t] of prevItems) if (!combinedNew.has(id)) t.destroy(); - return combinedNew; - }, - new Map(), - ), - map((mediaItems) => [...mediaItems.values()]), - finalizeValue((ts) => { - for (const t of ts) t.destroy(); - }), - this.scope.state(), - ); + const combinedNew = new Map([ + ...newNonMemberItems.entries(), + ...newItems.entries(), + ]); + + for (const [id, t] of prevItems) + if (!combinedNew.has(id)) t.destroy(); + return combinedNew; + }, + new Map(), + ), + map((mediaItems) => [...mediaItems.values()]), + finalizeValue((ts) => { + for (const t of ts) t.destroy(); + }), + ) + .behavior(this.scope); /** * List of MediaItems that we want to display, that are of type UserMedia @@ -702,52 +714,53 @@ export class CallViewModel extends ViewModel { /** * List of MediaItems that we want to display, that are of type ScreenShare */ - private readonly screenShares$: Observable = - this.mediaItems$.pipe( + private readonly screenShares$: Behavior = this.mediaItems$ + .pipe( map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), - this.scope.state(), - ); + ) + .behavior(this.scope); - private readonly spotlightSpeaker$: Observable = - this.userMedia$.pipe( - switchMap((mediaItems) => - mediaItems.length === 0 - ? of([]) - : combineLatest( - mediaItems.map((m) => - m.vm.speaking$.pipe(map((s) => [m, s] as const)), + private readonly spotlightSpeaker$: Behavior = + this.userMedia$ + .pipe( + switchMap((mediaItems) => + mediaItems.length === 0 + ? of([]) + : combineLatest( + mediaItems.map((m) => + m.vm.speaking$.pipe(map((s) => [m, s] as const)), + ), ), - ), - ), - scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>( - (prev, mediaItems) => { - // Only remote users that are still in the call should be sticky - const [stickyMedia, stickySpeaking] = - (!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || []; - // Decide who to spotlight: - // If the previous speaker is still speaking, stick with them rather - // than switching eagerly to someone else - return stickySpeaking - ? stickyMedia! - : // Otherwise, select any remote user who is speaking - (mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ?? - // Otherwise, stick with the person who was last speaking - stickyMedia ?? - // Otherwise, spotlight an arbitrary remote user - mediaItems.find(([m]) => !m.vm.local)?.[0] ?? - // Otherwise, spotlight the local user - mediaItems.find(([m]) => m.vm.local)?.[0]); - }, - null, - ), - map((speaker) => speaker?.vm ?? null), - this.scope.state(), - ); + ), + scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>( + (prev, mediaItems) => { + // Only remote users that are still in the call should be sticky + const [stickyMedia, stickySpeaking] = + (!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || []; + // Decide who to spotlight: + // If the previous speaker is still speaking, stick with them rather + // than switching eagerly to someone else + return stickySpeaking + ? stickyMedia! + : // Otherwise, select any remote user who is speaking + (mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ?? + // Otherwise, stick with the person who was last speaking + stickyMedia ?? + // Otherwise, spotlight an arbitrary remote user + mediaItems.find(([m]) => !m.vm.local)?.[0] ?? + // Otherwise, spotlight the local user + mediaItems.find(([m]) => m.vm.local)?.[0]); + }, + null, + ), + map((speaker) => speaker?.vm ?? null), + ) + .behavior(this.scope); - private readonly grid$: Observable = - this.userMedia$.pipe( + private readonly grid$: Behavior = this.userMedia$ + .pipe( switchMap((mediaItems) => { const bins = mediaItems.map((m) => combineLatest( @@ -784,11 +797,11 @@ export class CallViewModel extends ViewModel { ); }), distinctUntilChanged(shallowEquals), - this.scope.state(), - ); + ) + .behavior(this.scope); - private readonly spotlight$: Observable = - this.screenShares$.pipe( + private readonly spotlight$: Behavior = this.screenShares$ + .pipe( switchMap((screenShares) => { if (screenShares.length > 0) { return of(screenShares.map((m) => m.vm)); @@ -799,45 +812,46 @@ export class CallViewModel extends ViewModel { ); }), distinctUntilChanged(shallowEquals), - this.scope.state(), - ); + ) + .behavior(this.scope); - private readonly pip$: Observable = combineLatest([ + private readonly pip$: Behavior = combineLatest([ this.screenShares$, this.spotlightSpeaker$, this.mediaItems$, - ]).pipe( - switchMap(([screenShares, spotlight, mediaItems]) => { - if (screenShares.length > 0) { - return this.spotlightSpeaker$; - } - if (!spotlight || spotlight.local) { - return of(null); - } + ]) + .pipe( + switchMap(([screenShares, spotlight, mediaItems]) => { + if (screenShares.length > 0) { + return this.spotlightSpeaker$; + } + if (!spotlight || spotlight.local) { + return of(null); + } - const localUserMedia = mediaItems.find( - (m) => m.vm instanceof LocalUserMediaViewModel, - ) as UserMedia | undefined; + const localUserMedia = mediaItems.find( + (m) => m.vm instanceof LocalUserMediaViewModel, + ) as UserMedia | undefined; - const localUserMediaViewModel = localUserMedia?.vm as - | LocalUserMediaViewModel - | undefined; + const localUserMediaViewModel = localUserMedia?.vm as + | LocalUserMediaViewModel + | undefined; - if (!localUserMediaViewModel) { - return of(null); - } - return localUserMediaViewModel.alwaysShow$.pipe( - map((alwaysShow) => { - if (alwaysShow) { - return localUserMediaViewModel; - } + if (!localUserMediaViewModel) { + return of(null); + } + return localUserMediaViewModel.alwaysShow$.pipe( + map((alwaysShow) => { + if (alwaysShow) { + return localUserMediaViewModel; + } - return null; - }), - ); - }), - this.scope.state(), - ); + return null; + }), + ); + }), + ) + .behavior(this.scope); private readonly hasRemoteScreenShares$: Observable = this.spotlight$.pipe( @@ -851,64 +865,72 @@ export class CallViewModel extends ViewModel { startWith(false), ); - private readonly naturalWindowMode$: Observable = fromEvent( + private readonly naturalWindowMode$: Behavior = fromEvent( window, "resize", - ).pipe( - startWith(null), - map(() => { - const height = window.innerHeight; - const width = window.innerWidth; - if (height <= 400 && width <= 340) return "pip"; - // Our layouts for flat windows are better at adapting to a small width - // than our layouts for narrow windows are at adapting to a small height, - // so we give "flat" precedence here - if (height <= 600) return "flat"; - if (width <= 600) return "narrow"; - return "normal"; - }), - this.scope.state(), - ); + ) + .pipe( + startWith(null), + map(() => { + const height = window.innerHeight; + const width = window.innerWidth; + if (height <= 400 && width <= 340) return "pip"; + // Our layouts for flat windows are better at adapting to a small width + // than our layouts for narrow windows are at adapting to a small height, + // so we give "flat" precedence here + if (height <= 600) return "flat"; + if (width <= 600) return "narrow"; + return "normal"; + }), + ) + .behavior(this.scope); /** * The general shape of the window. */ - public readonly windowMode$: Observable = this.pipEnabled$.pipe( - switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode$)), - ); + public readonly windowMode$: Behavior = this.pipEnabled$ + .pipe( + switchMap((pip) => + pip ? of("pip") : this.naturalWindowMode$, + ), + ) + .behavior(this.scope); private readonly spotlightExpandedToggle$ = new Subject(); - public readonly spotlightExpanded$: Observable = - this.spotlightExpandedToggle$.pipe( - accumulate(false, (expanded) => !expanded), - this.scope.state(), - ); + public readonly spotlightExpanded$: Behavior = + this.spotlightExpandedToggle$ + .pipe(accumulate(false, (expanded) => !expanded)) + .behavior(this.scope); private readonly gridModeUserSelection$ = new Subject(); /** * The layout mode of the media tile grid. */ - public readonly gridMode$: Observable = + public readonly gridMode$: Behavior = // If the user hasn't selected spotlight and somebody starts screen sharing, // automatically switch to spotlight mode and reset when screen sharing ends - this.gridModeUserSelection$.pipe( - startWith(null), - switchMap((userSelection) => - (userSelection === "spotlight" - ? EMPTY - : combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe( - skip(userSelection === null ? 0 : 1), - map( - ([hasScreenShares, windowMode]): GridMode => - hasScreenShares || windowMode === "flat" - ? "spotlight" - : "grid", - ), - ) - ).pipe(startWith(userSelection ?? "grid")), - ), - this.scope.state(), - ); + this.gridModeUserSelection$ + .pipe( + startWith(null), + switchMap((userSelection) => + (userSelection === "spotlight" + ? EMPTY + : combineLatest([ + this.hasRemoteScreenShares$, + this.windowMode$, + ]).pipe( + skip(userSelection === null ? 0 : 1), + map( + ([hasScreenShares, windowMode]): GridMode => + hasScreenShares || windowMode === "flat" + ? "spotlight" + : "grid", + ), + ) + ).pipe(startWith(userSelection ?? "grid")), + ), + ) + .behavior(this.scope); public setGridMode(value: GridMode): void { this.gridModeUserSelection$.next(value); @@ -969,8 +991,8 @@ export class CallViewModel extends ViewModel { /** * The media to be used to produce a layout. */ - private readonly layoutMedia$: Observable = - this.windowMode$.pipe( + private readonly layoutMedia$: Behavior = this.windowMode$ + .pipe( switchMap((windowMode) => { switch (windowMode) { case "normal": @@ -1032,8 +1054,8 @@ export class CallViewModel extends ViewModel { return this.pipLayoutMedia$; } }), - this.scope.state(), - ); + ) + .behavior(this.scope); // There is a cyclical dependency here: the layout algorithms want to know // which tiles are on screen, but to know which tiles are on screen we have to @@ -1043,117 +1065,116 @@ export class CallViewModel extends ViewModel { private readonly setVisibleTiles = (value: number): void => this.visibleTiles$.next(value); - public readonly layoutInternals$: Observable< + private readonly layoutInternals$: Behavior< LayoutScanState & { layout: Layout } > = combineLatest([ this.layoutMedia$, this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()), - ]).pipe( - scan< - [LayoutMedia, number], - LayoutScanState & { layout: Layout }, - LayoutScanState - >( - ({ tiles: prevTiles }, [media, visibleTiles]) => { - let layout: Layout; - let newTiles: TileStore; - switch (media.type) { - case "grid": - case "spotlight-landscape": - case "spotlight-portrait": - [layout, newTiles] = gridLikeLayout( - media, - visibleTiles, - this.setVisibleTiles, - prevTiles, - ); - break; - case "spotlight-expanded": - [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); - break; - case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, prevTiles); - break; - case "pip": - [layout, newTiles] = pipLayout(media, prevTiles); - break; - } + ]) + .pipe( + scan< + [LayoutMedia, number], + LayoutScanState & { layout: Layout }, + LayoutScanState + >( + ({ tiles: prevTiles }, [media, visibleTiles]) => { + let layout: Layout; + let newTiles: TileStore; + switch (media.type) { + case "grid": + case "spotlight-landscape": + case "spotlight-portrait": + [layout, newTiles] = gridLikeLayout( + media, + visibleTiles, + this.setVisibleTiles, + prevTiles, + ); + break; + case "spotlight-expanded": + [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); + break; + case "one-on-one": + [layout, newTiles] = oneOnOneLayout(media, prevTiles); + break; + case "pip": + [layout, newTiles] = pipLayout(media, prevTiles); + break; + } - return { layout, tiles: newTiles }; - }, - { layout: null, tiles: TileStore.empty() }, - ), - this.scope.state(), - ); + return { layout, tiles: newTiles }; + }, + { layout: null, tiles: TileStore.empty() }, + ), + ) + .behavior(this.scope); /** * The layout of tiles in the call interface. */ - public readonly layout$: Observable = this.layoutInternals$.pipe( - map(({ layout }) => layout), - this.scope.state(), - ); + public readonly layout$: Behavior = this.layoutInternals$ + .pipe(map(({ layout }) => layout)) + .behavior(this.scope); /** * The current generation of the tile store, exposed for debugging purposes. */ - public readonly tileStoreGeneration$: Observable = - this.layoutInternals$.pipe( - map(({ tiles }) => tiles.generation), - this.scope.state(), - ); + public readonly tileStoreGeneration$: Behavior = this.layoutInternals$ + .pipe(map(({ tiles }) => tiles.generation)) + .behavior(this.scope); - public showSpotlightIndicators$: Observable = this.layout$.pipe( - map((l) => l.type !== "grid"), - this.scope.state(), - ); + public showSpotlightIndicators$: Behavior = this.layout$ + .pipe(map((l) => l.type !== "grid")) + .behavior(this.scope); - public showSpeakingIndicators$: Observable = this.layout$.pipe( - switchMap((l) => { - switch (l.type) { - case "spotlight-landscape": - case "spotlight-portrait": - // If the spotlight is showing the active speaker, we can do without - // speaking indicators as they're a redundant visual cue. But if - // screen sharing feeds are in the spotlight we still need them. - return l.spotlight.media$.pipe( - map((models: MediaViewModel[]) => - models.some((m) => m instanceof ScreenShareViewModel), - ), - ); - // In expanded spotlight layout, the active speaker is always shown in - // the picture-in-picture tile so there is no need for speaking - // indicators. And in one-on-one layout there's no question as to who is - // speaking. - case "spotlight-expanded": - case "one-on-one": - return of(false); - default: - return of(true); - } - }), - this.scope.state(), - ); - - public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> = - this.windowMode$.pipe( - switchMap((mode) => - mode === "normal" - ? this.layout$.pipe( - map( - (l) => - l.type === "spotlight-landscape" || - l.type === "spotlight-expanded", + public showSpeakingIndicators$: Behavior = this.layout$ + .pipe( + switchMap((l) => { + switch (l.type) { + case "spotlight-landscape": + case "spotlight-portrait": + // If the spotlight is showing the active speaker, we can do without + // speaking indicators as they're a redundant visual cue. But if + // screen sharing feeds are in the spotlight we still need them. + return l.spotlight.media$.pipe( + map((models: MediaViewModel[]) => + models.some((m) => m instanceof ScreenShareViewModel), ), - ) - : of(false), - ), - distinctUntilChanged(), - map((enabled) => - enabled ? (): void => this.spotlightExpandedToggle$.next() : null, - ), - this.scope.state(), - ); + ); + // In expanded spotlight layout, the active speaker is always shown in + // the picture-in-picture tile so there is no need for speaking + // indicators. And in one-on-one layout there's no question as to who is + // speaking. + case "spotlight-expanded": + case "one-on-one": + return of(false); + default: + return of(true); + } + }), + ) + .behavior(this.scope); + + public readonly toggleSpotlightExpanded$: Behavior<(() => void) | null> = + this.windowMode$ + .pipe( + switchMap((mode) => + mode === "normal" + ? this.layout$.pipe( + map( + (l) => + l.type === "spotlight-landscape" || + l.type === "spotlight-expanded", + ), + ) + : of(false), + ), + distinctUntilChanged(), + map((enabled) => + enabled ? (): void => this.spotlightExpandedToggle$.next() : null, + ), + ) + .behavior(this.scope); private readonly screenTap$ = new Subject(); private readonly controlsTap$ = new Subject(); @@ -1188,64 +1209,64 @@ export class CallViewModel extends ViewModel { this.screenUnhover$.next(); } - public readonly showHeader$: Observable = this.windowMode$.pipe( - map((mode) => mode !== "pip" && mode !== "flat"), - this.scope.state(), - ); + public readonly showHeader$: Behavior = this.windowMode$ + .pipe(map((mode) => mode !== "pip" && mode !== "flat")) + .behavior(this.scope); - public readonly showFooter$: Observable = this.windowMode$.pipe( - switchMap((mode) => { - switch (mode) { - case "pip": - return of(false); - case "normal": - case "narrow": - return of(true); - case "flat": - // Sadly Firefox has some layering glitches that prevent the footer - // from appearing properly. They happen less often if we never hide - // the footer. - if (isFirefox()) return of(true); - // Show/hide the footer in response to interactions - return merge( - this.screenTap$.pipe(map(() => "tap screen" as const)), - this.controlsTap$.pipe(map(() => "tap controls" as const)), - this.screenHover$.pipe(map(() => "hover" as const)), - ).pipe( - switchScan((state, interaction) => { - switch (interaction) { - case "tap screen": - return state - ? // Toggle visibility on tap - of(false) - : // Hide after a timeout - timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "tap controls": - // The user is interacting with things, so reset the timeout - return timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "hover": - // Show on hover and hide after a timeout - return race( - timer(showFooterMs), - this.screenUnhover$.pipe(take(1)), - ).pipe( - map(() => false), - startWith(true), - ); - } - }, false), - startWith(false), - ); - } - }), - this.scope.state(), - ); + public readonly showFooter$: Behavior = this.windowMode$ + .pipe( + switchMap((mode) => { + switch (mode) { + case "pip": + return of(false); + case "normal": + case "narrow": + return of(true); + case "flat": + // Sadly Firefox has some layering glitches that prevent the footer + // from appearing properly. They happen less often if we never hide + // the footer. + if (isFirefox()) return of(true); + // Show/hide the footer in response to interactions + return merge( + this.screenTap$.pipe(map(() => "tap screen" as const)), + this.controlsTap$.pipe(map(() => "tap controls" as const)), + this.screenHover$.pipe(map(() => "hover" as const)), + ).pipe( + switchScan((state, interaction) => { + switch (interaction) { + case "tap screen": + return state + ? // Toggle visibility on tap + of(false) + : // Hide after a timeout + timer(showFooterMs).pipe( + map(() => false), + startWith(true), + ); + case "tap controls": + // The user is interacting with things, so reset the timeout + return timer(showFooterMs).pipe( + map(() => false), + startWith(true), + ); + case "hover": + // Show on hover and hide after a timeout + return race( + timer(showFooterMs), + this.screenUnhover$.pipe(take(1)), + ).pipe( + map(() => false), + startWith(true), + ); + } + }, false), + startWith(false), + ); + } + }), + ) + .behavior(this.scope); /** * Whether audio is currently being output through the earpiece. @@ -1292,35 +1313,42 @@ export class CallViewModel extends ViewModel { }, ); - public readonly reactions$ = this.reactionsSubject$.pipe( - map((v) => - Object.fromEntries( - Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]), + public readonly reactions$ = this.reactionsSubject$ + .pipe( + map((v) => + Object.fromEntries( + Object.entries(v).map(([a, { reactionOption }]) => [ + a, + reactionOption, + ]), + ), ), - ), - ); + ) + .behavior(this.scope); - public readonly handsRaised$ = this.handsRaisedSubject$.pipe(); + public readonly handsRaised$ = this.handsRaisedSubject$.behavior(this.scope); /** * Emits an array of reactions that should be visible on the screen. */ - public readonly visibleReactions$ = showReactions.value$.pipe( - switchMap((show) => (show ? this.reactions$ : of({}))), - scan< - Record, - { sender: string; emoji: string; startX: number }[] - >((acc, latest) => { - const newSet: { sender: string; emoji: string; startX: number }[] = []; - for (const [sender, reaction] of Object.entries(latest)) { - const startX = - acc.find((v) => v.sender === sender && v.emoji)?.startX ?? - Math.ceil(Math.random() * 80) + 10; - newSet.push({ sender, emoji: reaction.emoji, startX }); - } - return newSet; - }, []), - ); + public readonly visibleReactions$ = showReactions.value$ + .pipe( + switchMap((show) => (show ? this.reactions$ : of({}))), + scan< + Record, + { sender: string; emoji: string; startX: number }[] + >((acc, latest) => { + const newSet: { sender: string; emoji: string; startX: number }[] = []; + for (const [sender, reaction] of Object.entries(latest)) { + const startX = + acc.find((v) => v.sender === sender && v.emoji)?.startX ?? + Math.ceil(Math.random() * 80) + 10; + newSet.push({ sender, emoji: reaction.emoji, startX }); + } + return newSet; + }, []), + ) + .behavior(this.scope); /** * Emits an array of reactions that should be played. diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 424d003e..8c7136aa 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -51,6 +51,7 @@ import { accumulate } from "../utils/observable"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { type ReactionOption } from "../reactions"; +import { type Behavior } from "./Behavior"; export function observeTrackReference$( participant$: Observable, @@ -223,13 +224,13 @@ abstract class BaseMediaViewModel extends ViewModel { /** * The LiveKit video track for this media. */ - public readonly video$: Observable; + public readonly video$: Behavior; /** * Whether there should be a warning that this media is unencrypted. */ - public readonly unencryptedWarning$: Observable; + public readonly unencryptedWarning$: Behavior; - public readonly encryptionStatus$: Observable; + public readonly encryptionStatus$: Behavior; /** * Whether this media corresponds to the local participant. @@ -260,11 +261,11 @@ abstract class BaseMediaViewModel extends ViewModel { public readonly displayname$: Observable, ) { super(); - const audio$ = observeTrackReference$(participant$, audioSource).pipe( - this.scope.state(), + const audio$ = observeTrackReference$(participant$, audioSource).behavior( + this.scope, ); - this.video$ = observeTrackReference$(participant$, videoSource).pipe( - this.scope.state(), + this.video$ = observeTrackReference$(participant$, videoSource).behavior( + this.scope, ); this.unencryptedWarning$ = combineLatest( [audio$, this.video$], @@ -272,70 +273,71 @@ abstract class BaseMediaViewModel extends ViewModel { encryptionSystem.kind !== E2eeType.NONE && (a?.publication?.isEncrypted === false || v?.publication?.isEncrypted === false), - ).pipe(this.scope.state()); + ).behavior(this.scope); - this.encryptionStatus$ = this.participant$.pipe( - switchMap((participant): Observable => { - if (!participant) { - return of(EncryptionStatus.Connecting); - } else if ( - participant.isLocal || - encryptionSystem.kind === E2eeType.NONE - ) { - return of(EncryptionStatus.Okay); - } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { - return combineLatest([ - encryptionErrorObservable$( - livekitRoom, - participant, - encryptionSystem, - "MissingKey", - ), - encryptionErrorObservable$( - livekitRoom, - participant, - encryptionSystem, - "InvalidKey", - ), - observeRemoteTrackReceivingOkay$(participant, audioSource), - observeRemoteTrackReceivingOkay$(participant, videoSource), - ]).pipe( - map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { - if (keyMissing) return EncryptionStatus.KeyMissing; - if (keyInvalid) return EncryptionStatus.KeyInvalid; - if (audioOkay || videoOkay) return EncryptionStatus.Okay; - return undefined; // no change - }), - filter((x) => !!x), - startWith(EncryptionStatus.Connecting), - ); - } else { - return combineLatest([ - encryptionErrorObservable$( - livekitRoom, - participant, - encryptionSystem, - "InvalidKey", - ), - observeRemoteTrackReceivingOkay$(participant, audioSource), - observeRemoteTrackReceivingOkay$(participant, videoSource), - ]).pipe( - map( - ([keyInvalid, audioOkay, videoOkay]): - | EncryptionStatus - | undefined => { - if (keyInvalid) return EncryptionStatus.PasswordInvalid; + this.encryptionStatus$ = this.participant$ + .pipe( + switchMap((participant): Observable => { + if (!participant) { + return of(EncryptionStatus.Connecting); + } else if ( + participant.isLocal || + encryptionSystem.kind === E2eeType.NONE + ) { + return of(EncryptionStatus.Okay); + } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { + return combineLatest([ + encryptionErrorObservable$( + livekitRoom, + participant, + encryptionSystem, + "MissingKey", + ), + encryptionErrorObservable$( + livekitRoom, + participant, + encryptionSystem, + "InvalidKey", + ), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), + ]).pipe( + map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { + if (keyMissing) return EncryptionStatus.KeyMissing; + if (keyInvalid) return EncryptionStatus.KeyInvalid; if (audioOkay || videoOkay) return EncryptionStatus.Okay; return undefined; // no change - }, - ), - filter((x) => !!x), - startWith(EncryptionStatus.Connecting), - ); - } - }), - this.scope.state(), - ); + }), + filter((x) => !!x), + startWith(EncryptionStatus.Connecting), + ); + } else { + return combineLatest([ + encryptionErrorObservable$( + livekitRoom, + participant, + encryptionSystem, + "InvalidKey", + ), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), + ]).pipe( + map( + ([keyInvalid, audioOkay, videoOkay]): + | EncryptionStatus + | undefined => { + if (keyInvalid) return EncryptionStatus.PasswordInvalid; + if (audioOkay || videoOkay) return EncryptionStatus.Okay; + return undefined; // no change + }, + ), + filter((x) => !!x), + startWith(EncryptionStatus.Connecting), + ); + } + }), + ) + .behavior(this.scope); } } @@ -354,31 +356,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether the participant is speaking. */ - public readonly speaking$ = this.participant$.pipe( - switchMap((p) => - p - ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( - map((p) => p.isSpeaking), - ) - : of(false), - ), - this.scope.state(), - ); + public readonly speaking$ = this.participant$ + .pipe( + switchMap((p) => + p + ? observeParticipantEvents( + p, + ParticipantEvent.IsSpeakingChanged, + ).pipe(map((p) => p.isSpeaking)) + : of(false), + ), + ) + .behavior(this.scope); /** * Whether this participant is sending audio (i.e. is unmuted on their side). */ - public readonly audioEnabled$: Observable; + public readonly audioEnabled$: Behavior; /** * Whether this participant is sending video. */ - public readonly videoEnabled$: Observable; + public readonly videoEnabled$: Behavior; private readonly _cropVideo$ = new BehaviorSubject(true); /** * Whether the tile video should be contained inside the tile or be cropped to fit. */ - public readonly cropVideo$: Observable = this._cropVideo$; + public readonly cropVideo$: Behavior = this._cropVideo$; public constructor( id: string, @@ -387,8 +391,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, displayname$: Observable, - public readonly handRaised$: Observable, - public readonly reaction$: Observable, + public readonly handRaised$: Behavior, + public readonly reaction$: Behavior, ) { super( id, @@ -401,16 +405,17 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { displayname$, ); - const media$ = participant$.pipe( - switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), - this.scope.state(), - ); - this.audioEnabled$ = media$.pipe( - map((m) => m?.microphoneTrack?.isMuted === false), - ); - this.videoEnabled$ = media$.pipe( - map((m) => m?.cameraTrack?.isMuted === false), - ); + const media$ = participant$ + .pipe( + switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), + ) + .behavior(this.scope); + this.audioEnabled$ = media$ + .pipe(map((m) => m?.microphoneTrack?.isMuted === false)) + .behavior(this.scope); + this.videoEnabled$ = media$ + .pipe(map((m) => m?.cameraTrack?.isMuted === false)) + .behavior(this.scope); } public toggleFitContain(): void { @@ -436,19 +441,20 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether the video should be mirrored. */ - public readonly mirror$ = this.video$.pipe( - switchMap((v) => { - const track = v?.publication?.track; - if (!(track instanceof LocalTrack)) return of(false); - // Watch for track restarts, because they indicate a camera switch - return fromEvent(track, TrackEvent.Restarted).pipe( - startWith(null), - // Mirror only front-facing cameras (those that face the user) - map(() => facingModeFromLocalTrack(track).facingMode === "user"), - ); - }), - this.scope.state(), - ); + public readonly mirror$ = this.video$ + .pipe( + switchMap((v) => { + const track = v?.publication?.track; + if (!(track instanceof LocalTrack)) return of(false); + // Watch for track restarts, because they indicate a camera switch + return fromEvent(track, TrackEvent.Restarted).pipe( + startWith(null), + // Mirror only front-facing cameras (those that face the user) + map(() => facingModeFromLocalTrack(track).facingMode === "user"), + ); + }), + ) + .behavior(this.scope); /** * Whether to show this tile in a highly visible location near the start of @@ -464,8 +470,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, displayname$: Observable, - handRaised$: Observable, - reaction$: Observable, + handRaised$: Behavior, + reaction$: Behavior, ) { super( id, @@ -512,43 +518,43 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { * The volume to which this participant's audio is set, as a scalar * multiplier. */ - public readonly localVolume$: Observable = merge( + public readonly localVolume$: Behavior = merge( this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), this.localVolumeAdjustment$, this.localVolumeCommit$.pipe(map(() => "commit" as const)), - ).pipe( - accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { - switch (event) { - case "toggle mute": - return { - ...state, - volume: state.volume === 0 ? state.committedVolume : 0, - }; - case "commit": - // Dragging the slider to zero should have the same effect as - // muting: keep the original committed volume, as if it were never - // dragged - return { - ...state, - committedVolume: - state.volume === 0 ? state.committedVolume : state.volume, - }; - default: - // Volume adjustment - return { ...state, volume: event }; - } - }), - map(({ volume }) => volume), - this.scope.state(), - ); + ) + .pipe( + accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { + switch (event) { + case "toggle mute": + return { + ...state, + volume: state.volume === 0 ? state.committedVolume : 0, + }; + case "commit": + // Dragging the slider to zero should have the same effect as + // muting: keep the original committed volume, as if it were never + // dragged + return { + ...state, + committedVolume: + state.volume === 0 ? state.committedVolume : state.volume, + }; + default: + // Volume adjustment + return { ...state, volume: event }; + } + }), + map(({ volume }) => volume), + ) + .behavior(this.scope); /** * Whether this participant's audio is disabled. */ - public readonly locallyMuted$: Observable = this.localVolume$.pipe( - map((volume) => volume === 0), - this.scope.state(), - ); + public readonly locallyMuted$: Behavior = this.localVolume$ + .pipe(map((volume) => volume === 0)) + .behavior(this.scope); public constructor( id: string, @@ -557,8 +563,8 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, displayname$: Observable, - handRaised$: Observable, - reaction$: Observable, + handRaised$: Behavior, + reaction$: Behavior, ) { super( id, diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index 727082cf..d475e0af 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -5,13 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - distinctUntilChanged, - type Observable, - shareReplay, - Subject, - takeUntil, -} from "rxjs"; +import { type Observable, Subject, takeUntil } from "rxjs"; type MonoTypeOperator = (o: Observable) => Observable; @@ -31,22 +25,6 @@ export class ObservableScope { return this.bindImpl; } - private readonly stateImpl: MonoTypeOperator = (o$) => - o$.pipe( - this.bind(), - distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: false }), - ); - - /** - * Transforms an Observable into a hot state Observable which replays its - * latest value upon subscription, skips updates with identical values, and - * is bound to this scope. - */ - public state(): MonoTypeOperator { - return this.stateImpl; - } - /** * Ends the scope, causing any bound Observables to complete. */ diff --git a/src/utils/test.ts b/src/utils/test.ts index 8f8b19a3..92c091e6 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -47,6 +47,7 @@ import { } from "../config/ConfigOptions"; import { Config } from "../config/Config"; import { type MediaDevices } from "../state/MediaDevices"; +import { constant } from "../state/Behavior"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); @@ -217,8 +218,8 @@ export async function withLocalMedia( }, mockLivekitRoom({ localParticipant }), of(roomMember.rawDisplayName ?? "nodisplayname"), - of(null), - of(null), + constant(null), + constant(null), ); try { await continuation(vm); @@ -256,8 +257,8 @@ export async function withRemoteMedia( }, mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), of(roomMember.rawDisplayName ?? "nodisplayname"), - of(null), - of(null), + constant(null), + constant(null), ); try { await continuation(vm); From b3863748dc40893aeb5e2e686bea0c6e664cff6e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Jun 2025 18:33:35 -0400 Subject: [PATCH 05/58] Replace many usages of useObservableEagerState with useBehavior This hook is simpler in its implementation (therefore hopefully more correct & performant) and enforces a type-level distinction between raw Observables and Behaviors. --- src/button/ReactionToggleButton.tsx | 11 +- src/grid/Grid.tsx | 17 +-- src/grid/OneOnOneLayout.tsx | 3 +- src/grid/SpotlightExpandedLayout.tsx | 4 +- src/grid/SpotlightPortraitLayout.tsx | 4 +- src/reactions/useReactionsSender.tsx | 6 +- src/room/GroupCallView.tsx | 4 +- src/room/InCallView.tsx | 33 +++-- src/room/ReactionsOverlay.tsx | 6 +- src/settings/SettingsModal.tsx | 4 +- src/settings/settings.ts | 9 +- src/state/Behavior.ts | 4 +- src/state/CallViewModel.test.ts | 7 +- src/state/CallViewModel.ts | 14 +- src/state/MediaDevices.ts | 195 ++++++++++++++------------- src/state/MediaViewModel.ts | 14 +- src/state/MuteAllAudioModel.ts | 4 +- src/state/ObservableScope.ts | 5 + src/state/TileViewModel.ts | 9 +- src/tile/GridTile.test.tsx | 8 +- src/tile/GridTile.tsx | 33 ++--- src/tile/SpotlightTile.test.tsx | 9 +- src/tile/SpotlightTile.tsx | 21 +-- src/useAudioContext.test.tsx | 10 +- src/useBehavior.ts | 25 ++++ src/utils/test.ts | 4 +- 26 files changed, 251 insertions(+), 212 deletions(-) create mode 100644 src/useBehavior.ts diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index f1a6f0aa..69673293 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -24,8 +24,6 @@ import { import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import classNames from "classnames"; -import { useObservableState } from "observable-hooks"; -import { map } from "rxjs"; import { useReactionsSender } from "../reactions/useReactionsSender"; import styles from "./ReactionToggleButton.module.css"; @@ -36,6 +34,7 @@ import { } from "../reactions"; import { Modal } from "../Modal"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -180,12 +179,8 @@ export function ReactionToggleButton({ const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = useObservableState( - vm.handsRaised$.pipe(map((v) => !!v[identifier])), - ); - const canReact = useObservableState( - vm.reactions$.pipe(map((v) => !v[identifier])), - ); + const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier]; + const canReact = !useBehavior(vm.reactions$)[identifier]; useEffect(() => { // Clear whenever the reactions menu state changes. diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index d0588fb6..6c85b8af 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -24,16 +24,16 @@ import { createContext, memo, use, + useCallback, useEffect, useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import useMeasure from "react-use-measure"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/lib/logger"; -import { useObservableEagerState } from "observable-hooks"; -import { fromEvent, map, startWith } from "rxjs"; import styles from "./Grid.module.css"; import { useMergedRefs } from "../useMergedRefs"; @@ -155,11 +155,6 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void { ); } -const windowHeightObservable$ = fromEvent(window, "resize").pipe( - startWith(null), - map(() => window.innerHeight), -); - export interface LayoutProps { ref?: Ref; model: LayoutModel; @@ -261,7 +256,13 @@ export function Grid< const [gridRoot, gridRef2] = useState(null); const gridRef = useMergedRefs(gridRef1, gridRef2); - const windowHeight = useObservableEagerState(windowHeightObservable$); + const windowHeight = useSyncExternalStore( + useCallback((onChange) => { + window.addEventListener("resize", onChange); + return (): void => window.removeEventListener("resize", onChange); + }, []), + useCallback(() => window.innerHeight, []), + ); const [layoutRoot, setLayoutRoot] = useState(null); const [generation, setGeneration] = useState(null); const [visibleTilesCallback, setVisibleTilesCallback] = diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 8e1bffbe..675e4d0a 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod import { type CallLayout, arrangeTiles } from "./CallLayout"; import styles from "./OneOnOneLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "one-on-one" layout, in which the remote participant @@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout = ({ scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index 88271752..9dd2a109 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useCallback } from "react"; -import { useObservableEagerState } from "observable-hooks"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type CallLayout } from "./CallLayout"; import { type DragCallback, useUpdateLayout } from "./Grid"; import styles from "./SpotlightExpandedLayout.module.css"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 3b4de6a1..ad11ed11 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightPortraitLayout.module.css"; import { useUpdateLayout, useVisibleTiles } from "./Grid"; +import { useBehavior } from "../useBehavior"; interface GridCSSProperties extends CSSProperties { "--grid-gap": string; @@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout< width, model.grid.length, ); - const withIndicators = - useObservableEagerState(model.spotlight.media$).length > 1; + const withIndicators = useBehavior(model.spotlight.media$).length > 1; return (
myMembershipIdentifier !== undefined @@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({ [myMembershipIdentifier, reactions], ); - const handsRaised = useObservableEagerState(vm.handsRaised$); + const handsRaised = useBehavior(vm.handsRaised$); const myRaisedHand = useMemo( () => myMembershipIdentifier !== undefined diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 5b9b1f02..4af599bb 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -24,7 +24,6 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import { useObservableEagerState } from "observable-hooks"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { @@ -72,6 +71,7 @@ import { import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; declare global { interface Window { @@ -110,7 +110,7 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const muteAllAudio = useObservableEagerState(muteAllAudio$); + const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( useAudioContext({ sounds: callEventAudioSounds, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 452e8572..74b738d9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,7 @@ import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; import { BehaviorSubject, map } from "rxjs"; -import { useObservable, useObservableEagerState } from "observable-hooks"; +import { useObservable } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -112,6 +112,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership import { useMediaDevices } from "../MediaDevicesContext.ts"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -251,7 +252,7 @@ export const InCallView: FC = ({ room: livekitRoom, }); - const muteAllAudio = useObservableEagerState(muteAllAudio$); + const muteAllAudio = useBehavior(muteAllAudio$); // This seems like it might be enough logic to use move it into the call view model? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -302,15 +303,15 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const windowMode = useObservableEagerState(vm.windowMode$); - const layout = useObservableEagerState(vm.layout$); - const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); + const windowMode = useBehavior(vm.windowMode$); + const layout = useBehavior(vm.layout$); + const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useObservableEagerState(vm.gridMode$); - const showHeader = useObservableEagerState(vm.showHeader$); - const showFooter = useObservableEagerState(vm.showFooter$); - const earpieceMode = useObservableEagerState(vm.earpieceMode$); - const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); + const gridMode = useBehavior(vm.gridMode$); + const showHeader = useBehavior(vm.showHeader$); + const showFooter = useBehavior(vm.showFooter$); + const earpieceMode = useBehavior(vm.earpieceMode$); + const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking @@ -527,16 +528,12 @@ export const InCallView: FC = ({ targetHeight, model, }: TileProps): ReactNode { - const spotlightExpanded = useObservableEagerState( - vm.spotlightExpanded$, - ); - const onToggleExpanded = useObservableEagerState( - vm.toggleSpotlightExpanded$, - ); - const showSpeakingIndicatorsValue = useObservableEagerState( + const spotlightExpanded = useBehavior(vm.spotlightExpanded$); + const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); + const showSpeakingIndicatorsValue = useBehavior( vm.showSpeakingIndicators$, ); - const showSpotlightIndicatorsValue = useObservableEagerState( + const showSpotlightIndicatorsValue = useBehavior( vm.showSpotlightIndicators$, ); diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 9bf7ab66..f3dff848 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode } from "react"; -import { useObservableState } from "observable-hooks"; import styles from "./ReactionsOverlay.module.css"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { - const reactionsIcons = useObservableState(vm.visibleReactions$); + const reactionsIcons = useBehavior(vm.visibleReactions$); return (
- {reactionsIcons?.map(({ sender, emoji, startX }) => ( + {reactionsIcons.map(({ sender, emoji, startX }) => ( = ({ // rather than the input section. const { controlledAudioDevices } = useUrlParams(); // If we are on iOS we will show a button to open the native audio device picker. - const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$); + const iosDeviceMenu = useBehavior(iosDeviceMenu$); const audioTab: Tab = { key: "audio", diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 50e70671..7c7f1250 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { logger } from "matrix-js-sdk/lib/logger"; -import { BehaviorSubject, type Observable } from "rxjs"; -import { useObservableEagerState } from "observable-hooks"; +import { BehaviorSubject } from "rxjs"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; +import { type Behavior } from "../state/Behavior"; +import { useBehavior } from "../useBehavior"; export class Setting { public constructor( @@ -38,7 +39,7 @@ export class Setting { private readonly key: string; private readonly _value$: BehaviorSubject; - public readonly value$: Observable; + public readonly value$: Behavior; public readonly setValue = (value: T): void => { this._value$.next(value); @@ -53,7 +54,7 @@ export class Setting { * React hook that returns a settings's current value and a setter. */ export function useSetting(setting: Setting): [T, (value: T) => void] { - return [useObservableEagerState(setting.value$), setting.setValue]; + return [useBehavior(setting.value$), setting.setValue]; } // null = undecided diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts index 8b2ce9a5..4ae651f2 100644 --- a/src/state/Behavior.ts +++ b/src/state/Behavior.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { BehaviorSubject, Observable } from "rxjs"; +import { BehaviorSubject, distinctUntilChanged, Observable } from "rxjs"; import { type ObservableScope } from "./ObservableScope"; @@ -45,7 +45,7 @@ Observable.prototype.behavior = function ( ): Behavior { const subject$ = new BehaviorSubject(nothing); // Push values from the Observable into the BehaviorSubject - this.pipe(scope.bind()).subscribe(subject$); + this.pipe(scope.bind(), distinctUntilChanged()).subscribe(subject$); if (subject$.value === nothing) throw new Error("Behavior failed to synchronously emit an initial value"); return subject$ as Behavior; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 42b04079..2ad254f2 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -75,6 +75,7 @@ import { import { ObservableScope } from "./ObservableScope"; import { MediaDevices } from "./MediaDevices"; import { getValue } from "../utils/observable"; +import { constant } from "./Behavior"; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); @@ -157,9 +158,10 @@ function summarizeLayout$(l$: Observable): Observable { case "grid": return combineLatest( [ - l.spotlight?.media$ ?? of(undefined), + l.spotlight?.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], + // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight?.map((vm) => vm.id), @@ -178,7 +180,8 @@ function summarizeLayout$(l$: Observable): Observable { ); case "spotlight-expanded": return combineLatest( - [l.spotlight.media$, l.pip?.media$ ?? of(undefined)], + [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], + // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index a9bf5413..20105a85 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -339,7 +339,7 @@ class ScreenShare { participant: LocalParticipant | RemoteParticipant, encryptionSystem: EncryptionSystem, liveKitRoom: LivekitRoom, - displayname$: Observable, + displayName$: Observable, ) { this.participant$ = new BehaviorSubject(participant); @@ -349,7 +349,7 @@ class ScreenShare { this.participant$.asObservable(), encryptionSystem, liveKitRoom, - displayname$.behavior(this.scope), + displayName$.behavior(this.scope), participant.isLocal, ); } @@ -1271,14 +1271,14 @@ export class CallViewModel extends ViewModel { /** * Whether audio is currently being output through the earpiece. */ - public readonly earpieceMode$: Observable = combineLatest( + public readonly earpieceMode$: Behavior = combineLatest( [ this.mediaDevices.audioOutput.available$, this.mediaDevices.audioOutput.selected$, ], (available, selected) => selected !== undefined && available.get(selected.id)?.type === "earpiece", - ).pipe(this.scope.state()); + ).behavior(this.scope); /** * Callback to toggle between the earpiece and the loudspeaker. @@ -1286,7 +1286,7 @@ export class CallViewModel extends ViewModel { * This will be `null` in case the target does not exist in the list * of available audio outputs. */ - public readonly audioOutputSwitcher$: Observable<{ + public readonly audioOutputSwitcher$: Behavior<{ targetOutput: "earpiece" | "speaker"; switch: () => void; } | null> = combineLatest( @@ -1298,7 +1298,7 @@ export class CallViewModel extends ViewModel { const selectionType = selected && available.get(selected.id)?.type; // If we are in any output mode other than spaeker switch to speaker. - const newSelectionType = + const newSelectionType: "earpiece" | "speaker" = selectionType === "speaker" ? "earpiece" : "speaker"; const newSelection = [...available].find( ([, d]) => d.type === newSelectionType, @@ -1311,7 +1311,7 @@ export class CallViewModel extends ViewModel { switch: () => this.mediaDevices.audioOutput.select(id), }; }, - ); + ).behavior(this.scope); public readonly reactions$ = this.reactionsSubject$ .pipe( diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index f251e759..b27120b5 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -10,7 +10,6 @@ import { filter, map, merge, - of, pairwise, startWith, Subject, @@ -34,6 +33,7 @@ import { import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; +import { type Behavior, constant } from "./Behavior"; // This hardcoded id is used in EX ios! It can only be changed in coordination with // the ios swift team. @@ -74,11 +74,11 @@ export interface MediaDevice { /** * A map from available device IDs to labels. */ - available$: Observable>; + available$: Behavior>; /** * The selected device. */ - selected$: Observable; + selected$: Behavior; /** * Selects a new device. */ @@ -94,36 +94,37 @@ export interface MediaDevice { * `availableOutputDevices$.includes((d)=>d.forEarpiece)` */ export const iosDeviceMenu$ = - platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$; + platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$; function availableRawDevices$( kind: MediaDeviceKind, - usingNames$: Observable, + usingNames$: Behavior, scope: ObservableScope, -): Observable { +): Behavior { const logError = (e: Error): void => logger.error("Error creating MediaDeviceObserver", e); const devices$ = createMediaDeviceObserver(kind, logError, false); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); - return usingNames$.pipe( - switchMap((withNames) => - withNames - ? // It might be that there is already a media stream running somewhere, - // and so we can do without requesting a second one. Only switch to the - // device observer that explicitly requests the names if we see that - // names are in fact missing from the initial device enumeration. - devices$.pipe( - switchWhen( - (devices, i) => i === 0 && devices.every((d) => !d.label), - devicesWithNames$, - ), - ) - : devices$, - ), - startWith([]), - scope.state(), - ); + return usingNames$ + .pipe( + switchMap((withNames) => + withNames + ? // It might be that there is already a media stream running somewhere, + // and so we can do without requesting a second one. Only switch to the + // device observer that explicitly requests the names if we see that + // names are in fact missing from the initial device enumeration. + devices$.pipe( + switchWhen( + (devices, i) => i === 0 && devices.every((d) => !d.label), + devicesWithNames$, + ), + ) + : devices$, + ), + startWith([]), + ) + .behavior(scope); } function buildDeviceMap( @@ -161,42 +162,44 @@ function selectDevice$
diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css index 20760406..fd46d0e2 100644 --- a/src/room/EarpieceOverlay.module.css +++ b/src/room/EarpieceOverlay.module.css @@ -61,3 +61,7 @@ .overlay > p { text-align: center; } + +.spacer { + min-height: var(--cpd-space-32x); +} diff --git a/src/room/EarpieceOverlay.tsx b/src/room/EarpieceOverlay.tsx index 054e3083..14ce33cc 100644 --- a/src/room/EarpieceOverlay.tsx +++ b/src/room/EarpieceOverlay.tsx @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { type FC } from "react"; import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web"; -import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useTranslation } from "react-i18next"; import styles from "./EarpieceOverlay.module.css"; @@ -22,12 +22,12 @@ export const EarpieceOverlay: FC = ({ show, onBackToVideoPressed }) => { return (
- + - {t("earpiece.overlay_title")} + {t("handset.overlay_title")} - {t("earpiece.overlay_description")} + {t("handset.overlay_description")} + {/* This spacer is used to give the overlay an offset to the top. */} +
); }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 74b738d9..f99e368b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,7 +29,7 @@ import { useObservable } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { - EarpieceIcon, + VoiceCallSolidIcon, VolumeOnSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useTranslation } from "react-i18next"; @@ -458,9 +458,9 @@ export const InCallView: FC = ({ useMemo(() => { if (audioOutputSwitcher === null) return null; const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece"; - const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon; + const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon; const label = isEarpieceTarget - ? t("settings.devices.earpiece") + ? t("settings.devices.handset") : t("settings.devices.loudspeaker"); return ( diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 5b5bd3e8..b45a30ad 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -98,18 +98,30 @@ exports[`InCallView > rendering > renders 1`] = ` width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + + + + + + +

- Earpiece Mode + Handset Mode

rendering > renders 1`] = ` > Back to Speaker Mode +

= ({ labelText = t("settings.devices.loudspeaker"); break; case "earpiece": - labelText = t("settings.devices.earpiece"); + labelText = t("settings.devices.handset"); break; } diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 0d09aa31..a3068c32 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1048,7 +1048,7 @@ test("audio output changes when toggling earpiece mode", () => { window.controls.setAvailableAudioDevices([ { id: "speaker", name: "Speaker", isSpeaker: true }, - { id: "earpiece", name: "Earpiece", isEarpiece: true }, + { id: "earpiece", name: "Handset", isEarpiece: true }, { id: "headphones", name: "Headphones" }, ]); window.controls.setAudioDevice("headphones"); From 0e0fba657558964376e6f2375271630d9cf52b84 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 18 Jul 2025 10:58:50 -0400 Subject: [PATCH 16/58] Send notification events when starting a call (#3248) * Send notification events when starting a call Previously this has been the responsibility of the hosting application (Element Web / Element X), but I would like to move this responsibility to Element Call itself to make it even more lightweight to integrate Element Call into a widget-capable client. * use RTCNotification event * add url param * bump to latest js-sdk * remove everything decline related * use notification type in url params * fix url .md docs * back to `head=develop` and using js-sdk with send notification feature * format --------- Co-authored-by: Timo --- docs/url-params.md | 1 + src/UrlParams.ts | 11 +++++++++++ src/rtcSessionHelpers.ts | 5 +++-- yarn.lock | 4 ++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/url-params.md b/docs/url-params.md index bc3846cd..e76c976e 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -69,6 +69,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | +| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | ### Widget-only parameters diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9f89fd47..689fc136 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { useMemo } from "react"; import { useLocation } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; +import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc"; import { Config } from "./config/Config"; import { type EncryptionSystem } from "./e2ee/sharedKeyManagement"; @@ -193,6 +194,10 @@ export interface UrlParams { * The Sentry environment. This is only used in the embedded package of Element Call. */ sentryEnvironment: string | null; + /** + * Whether and what type of notification EC should send, when the user joins the call. + */ + sendNotificationType?: RTCNotificationType; } // This is here as a stopgap, but what would be far nicer is a function that @@ -275,6 +280,11 @@ export const getUrlParams = ( ? HeaderStyle.None : HeaderStyle.Standard); + const sendNotificationType = ["ring", "notification"].includes( + parser.getParam("sendNotificationType") ?? "", + ) + ? (parser.getParam("sendNotificationType") as RTCNotificationType) + : undefined; const widgetId = parser.getParam("widgetId"); const parentUrl = parser.getParam("parentUrl"); const isWidget = !!widgetId && !!parentUrl; @@ -329,6 +339,7 @@ export const getUrlParams = ( rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), + sendNotificationType, }; }; diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index e4176dc0..3ff6b560 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -18,8 +18,8 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { PosthogAnalytics } from "./analytics/PosthogAnalytics"; import { Config } from "./config/Config"; import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget"; -import { MatrixRTCFocusMissingError } from "./utils/errors.ts"; -import { getUrlParams } from "./UrlParams.ts"; +import { MatrixRTCFocusMissingError } from "./utils/errors"; +import { getUrlParams } from "./UrlParams"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; @@ -116,6 +116,7 @@ export async function enterRTCSession( await makePreferredLivekitFoci(rtcSession, livekitAlias), makeActiveFocus(), { + notificationType: getUrlParams().sendNotificationType, useNewMembershipManager, manageMediaKeys: encryptMedia, ...(useDeviceSessionMemberEvents !== undefined && { diff --git a/yarn.lock b/yarn.lock index c8e2f592..4f37c7eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10247,7 +10247,7 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.11.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=f8f1bf38373a944f12a739a301c1770c7bf08265" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=aa79236ce23cef85583c802c0a9e8d1e77814d51" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.0.0" @@ -10263,7 +10263,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/e2090716731b04c52a6e23a6f06be59c39437c73f65b151d1c1d108c168f0fb1e3b6b23f229ddb0289cf3fb9cf304b4df63e84eae6ef198f29ee8d52f545c071 + checksum: 10c0/33fb0918b0742c6a00ed0e4fd3cc958ae824d407f8e0e65687a011f8bbb058163e4123a7affce47368ca8489b96ccdceddfe98becc6a7c7aff7ab68b4e63a072 languageName: node linkType: hard From 8bb79e6f331207bb9191490df3e2154e0367657c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:47:32 +0000 Subject: [PATCH 17/58] Update dependency @vector-im/compound-web to v8.2.0 --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4f37c7eb..dc01f72f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5610,8 +5610,8 @@ __metadata: linkType: hard "@vector-im/compound-web@npm:^8.0.0": - version: 8.1.2 - resolution: "@vector-im/compound-web@npm:8.1.2" + version: 8.2.0 + resolution: "@vector-im/compound-web@npm:8.2.0" dependencies: "@floating-ui/react": "npm:^0.27.0" "@radix-ui/react-context-menu": "npm:^2.2.1" @@ -5626,12 +5626,12 @@ __metadata: "@fontsource/inconsolata": ^5 "@fontsource/inter": ^5 "@types/react": "*" - "@vector-im/compound-design-tokens": ">=1.6.1 <5.0.0" + "@vector-im/compound-design-tokens": ">=1.6.1 <6.0.0" react: ^18 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true - checksum: 10c0/4d16794f20b5577bb32442a8261ccad87d83ef91d9212e4ff12af90c4423790859b9a712764211c751079d384bf4dc88522961e008a9bd9f46653fdd75cdc195 + checksum: 10c0/4ac4074dcf9611bdff7de4bf66763397c926d6312f31758bcabe3e7bf704cb76bc2ce1023fe5f2cf0d05e97c9c540fef8b63edea7a521a2f7b4b7fbcb883fb17 languageName: node linkType: hard From fb0fefae1025fe6bfffc248a41ca209c3eabd5fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 00:47:44 +0000 Subject: [PATCH 18/58] Update dependency @vector-im/compound-design-tokens to v5 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 689e3eee..c0349b46 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", "@use-gesture/react": "^10.2.11", - "@vector-im/compound-design-tokens": "^4.0.0", + "@vector-im/compound-design-tokens": "^5.0.0", "@vector-im/compound-web": "^8.0.0", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 4f37c7eb..98088cf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5594,9 +5594,9 @@ __metadata: languageName: node linkType: hard -"@vector-im/compound-design-tokens@npm:^4.0.0": - version: 4.0.5 - resolution: "@vector-im/compound-design-tokens@npm:4.0.5" +"@vector-im/compound-design-tokens@npm:^5.0.0": + version: 5.0.2 + resolution: "@vector-im/compound-design-tokens@npm:5.0.2" peerDependencies: "@types/react": "*" react: ^17 || ^18 || ^19.0.0 @@ -5605,7 +5605,7 @@ __metadata: optional: true react: optional: true - checksum: 10c0/ae84195a5c854ac54e64b615729cb5f669da1b798b961be4370e1baf257a57e871f53c6df555702513a99fcc5576bff50033a2ed676b36c7344f1d470826f10f + checksum: 10c0/4337ef74c269602a7801fe5361ee95aa453529e2eac2fa80620e6c8d089551c3e77061d4690755023487aae2281be0a5df959a355ce3fb13e151e40822b74e03 languageName: node linkType: hard @@ -7477,7 +7477,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.31.0" "@typescript-eslint/parser": "npm:^8.31.0" "@use-gesture/react": "npm:^10.2.11" - "@vector-im/compound-design-tokens": "npm:^4.0.0" + "@vector-im/compound-design-tokens": "npm:^5.0.0" "@vector-im/compound-web": "npm:^8.0.0" "@vitejs/plugin-react": "npm:^4.0.1" "@vitest/coverage-v8": "npm:^3.0.0" From 8f561c0319f827d972a7e229a5482b6f3a5b3451 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Mon, 21 Jul 2025 17:46:45 +0200 Subject: [PATCH 19/58] bump js-sdk to matrix-js-sdk 0ce944f3daa2c8926c673af33ae24e89b1e6796a (#3414) --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 496f19e8..ca229617 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10247,7 +10247,7 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.11.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=aa79236ce23cef85583c802c0a9e8d1e77814d51" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=0ce944f3daa2c8926c673af33ae24e89b1e6796a" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.0.0" @@ -10263,7 +10263,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/33fb0918b0742c6a00ed0e4fd3cc958ae824d407f8e0e65687a011f8bbb058163e4123a7affce47368ca8489b96ccdceddfe98becc6a7c7aff7ab68b4e63a072 + checksum: 10c0/9cfdff1221d55943e25a56027cccd077b7e74ce5b8c6fb12f54477319d179c8463e1f81e88e89bf77538b1207540455704fdcc89d9517b4b24648f185bd24842 languageName: node linkType: hard From 7dd45acd2985ec05d07a8e301ed5eb075f54af61 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 21 Jul 2025 21:01:00 +0200 Subject: [PATCH 20/58] Update matrix-js-sdk To include https://github.com/matrix-org/matrix-js-sdk/pull/4927. --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index ca229617..2fd0b95a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10247,7 +10247,7 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.11.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=0ce944f3daa2c8926c673af33ae24e89b1e6796a" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=11c9e39e5a1e56ea347dbd16ca7c75059127f7d5" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.0.0" @@ -10263,7 +10263,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/9cfdff1221d55943e25a56027cccd077b7e74ce5b8c6fb12f54477319d179c8463e1f81e88e89bf77538b1207540455704fdcc89d9517b4b24648f185bd24842 + checksum: 10c0/31018452c9578948c47f82648a58d4000b657103b20a38cb1672035bc530e0aaa68ac03bc600f4bfaad9f72f36842490be963251a02d2926dcfe697ff6e459b3 languageName: node linkType: hard From 3145bafd5eb8f534312f81aef6c9f3ed0a9c20cb Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 22 Jul 2025 20:11:53 +0100 Subject: [PATCH 21/58] Trim extra characters from roomId parameter in URL (#3412) * Trim roomId when parsing from URL * fix char * fixup * limit to roomId * Add a comment --- src/UrlParams.test.ts | 10 ++++++++++ src/UrlParams.ts | 14 ++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index b65638e0..495f7926 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -82,6 +82,16 @@ describe("UrlParams", () => { getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId, ).toBe(ROOM_ID); }); + it("(roomId with unprintable characters)", () => { + const invisibleChar = "\u2066"; + expect( + getRoomIdentifierFromUrl( + "", + `?roomId=${invisibleChar}${ROOM_ID}${invisibleChar}`, + "", + ).roomId, + ).toBe(ROOM_ID); + }); }); it("ignores room alias", () => { diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 689fc136..b3940acb 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -398,10 +398,16 @@ export function getRoomIdentifierFromUrl( // Make sure roomId is valid let roomId: string | null = parser.getParam("roomId"); - if (!roomId?.startsWith("!")) { - roomId = null; - } else if (!roomId.includes("")) { - roomId = null; + if (roomId !== null) { + // Replace any non-printable characters that another client may have inserted. + // For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL. + // This isn't valid for a roomId, so we can freely strip the content. + roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, ""); + if (!roomId.startsWith("!")) { + roomId = null; + } else if (!roomId.includes("")) { + roomId = null; + } } return { From 2f55d8e30c139fbd57f330a112913c274e6547e8 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:46:26 +0200 Subject: [PATCH 22/58] UrlParams: Intent system update, split into configuration and propreties (#3376) * refactor UrlParams to use a preset intent system * change defaults for intend headers * add: getEnumParam to ParamParser * remove deprecated url params * only allow skip lobby in widget (more strict needs test adjustment) * fix tests that now require the url to be a widget url Co-authored-by: Robin --------- Co-authored-by: Robin --- src/UrlParams.test.ts | 68 +++++-- src/UrlParams.ts | 337 +++++++++++++++++++++-------------- src/room/MuteStates.test.tsx | 6 +- 3 files changed, 267 insertions(+), 144 deletions(-) diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 495f7926..fbf0c095 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { getRoomIdentifierFromUrl, getUrlParams, - UserIntent, + HeaderStyle, } from "../src/UrlParams"; const ROOM_NAME = "roomNameHere"; @@ -211,24 +211,68 @@ describe("UrlParams", () => { }); describe("intent", () => { - it("defaults to unknown", () => { - expect(getUrlParams().intent).toBe(UserIntent.Unknown); + const noIntentDefaults = { + confineToRoom: false, + appPrompt: true, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + }; + const startNewCallDefaults = (platform: string): object => ({ + confineToRoom: true, + appPrompt: false, + preload: true, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification", + }); + const joinExistingCallDefaults = (platform: string): object => ({ + confineToRoom: true, + appPrompt: false, + preload: true, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: false, + returnToLobby: false, + sendNotificationType: "notification", + }); + it("use no-intent-defaults with unknown intent", () => { + expect(getUrlParams()).toMatchObject(noIntentDefaults); }); it("ignores intent if it is not a valid value", () => { - expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown); + expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults); }); it("accepts start_call", () => { - expect(getUrlParams("?intent=start_call").intent).toBe( - UserIntent.StartNewCall, - ); + expect( + getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"), + ).toMatchObject(startNewCallDefaults("desktop")); }); it("accepts join_existing", () => { - expect(getUrlParams("?intent=join_existing").intent).toBe( - UserIntent.JoinExistingCall, - ); + expect( + getUrlParams( + "?intent=join_existing&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject(joinExistingCallDefaults("desktop")); }); }); @@ -260,9 +304,5 @@ describe("UrlParams", () => { ); expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none"); }); - it("converts hideHeader to the correct header value", () => { - expect(getUrlParams("?hideHeader=true").header).toBe("none"); - expect(getUrlParams("?hideHeader=false").header).toBe("standard"); - }); }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index b3940acb..65e3d901 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -9,10 +9,12 @@ import { useMemo } from "react"; import { useLocation } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc"; +import { pickBy } from "lodash-es"; import { Config } from "./config/Config"; import { type EncryptionSystem } from "./e2ee/sharedKeyManagement"; import { E2eeType } from "./e2ee/e2eeType"; +import { platform } from "./Platform"; interface RoomIdentifier { roomAlias: string | null; @@ -21,6 +23,7 @@ interface RoomIdentifier { } export enum UserIntent { + // TODO: add DM vs room call StartNewCall = "start_call", JoinExistingCall = "join_existing", Unknown = "unknown", @@ -32,12 +35,12 @@ export enum HeaderStyle { AppBar = "app_bar", } -// If you need to add a new flag to this interface, prefer a name that describes -// a specific behavior (such as 'confineToRoom'), rather than one that describes -// the situations that call for this behavior ('isEmbedded'). This makes it -// clearer what each flag means, and helps us avoid coupling Element Call's -// behavior to the needs of specific consumers. -export interface UrlParams { +/** + * The UrlProperties are used to pass required data to the widget. + * Those are different in different rooms, users, devices. They do not configure the behavior of the + * widget but provide the required data to the widget. + */ +export interface UrlProperties { // Widget api related params widgetId: string | null; parentUrl: string | null; @@ -49,45 +52,11 @@ export interface UrlParams { * is also not validated, where it is in useRoomIdentifier(). */ roomId: string | null; - /** - * Whether the app should keep the user confined to the current call/room. - */ - confineToRoom: boolean; - /** - * Whether upon entering a room, the user should be prompted to launch the - * native mobile app. (Affects only Android and iOS.) - * - * The app prompt must also be enabled in the config for this to take effect. - */ - appPrompt: boolean; - /** - * Whether the app should pause before joining the call until it sees an - * io.element.join widget action, allowing it to be preloaded. - */ - preload: boolean; - /** - * The style of headers to show. "standard" is the default arrangement, "none" - * hides the header entirely, and "app_bar" produces a header with a back - * button like you might see in mobile apps. The callback for the back button - * is window.controls.onBackButtonPressed. - */ - header: HeaderStyle; - /** - * Whether the controls should be shown. For screen recording no controls can be desired. - */ - showControls: boolean; - /** - * Whether to hide the screen-sharing button. - */ - hideScreensharing: boolean; - /** - * Whether to use end-to-end encryption. - */ - e2eEnabled: boolean; /** * The user's ID (only used in matryoshka mode). */ userId: string | null; + /** * The display name to use for auto-registration. */ @@ -125,14 +94,96 @@ export interface UrlParams { */ posthogApiKey: string | null; /** - * Whether the app is allowed to use fallback STUN servers for ICE in case the - * user's homeserver doesn't provide any. + * Whether to use end-to-end encryption. */ - allowIceFallback: boolean; + e2eEnabled: boolean; /** * E2EE password */ password: string | null; + /** This defines the homeserver that is going to be used when joining a room. + * It has to be set to a non default value for links to rooms + * that are not on the default homeserver, + * that is in use for the current user. + */ + viaServers: string | null; + + /** + * This defines the homeserver that is going to be used when registering + * a new (guest) user. + * This can be user to configure a non default guest user server when + * creating a spa link. + */ + homeserver: string | null; + + /** + * The rageshake submit URL. This is only used in the embedded package of Element Call. + */ + rageshakeSubmitUrl: string | null; + + /** + * The Sentry DSN. This is only used in the embedded package of Element Call. + */ + sentryDsn: string | null; + + /** + * The Sentry environment. This is only used in the embedded package of Element Call. + */ + sentryEnvironment: string | null; + /** + * The theme to use for element call. + * can be "light", "dark", "light-high-contrast" or "dark-high-contrast". + */ + theme: string | null; +} + +/** + * The configuration for the app, which can be set via URL parameters. + * Those property are different to the UrlProperties, since they are all optional + * and configure the behavior of the app. Their value is the same if EC is used in + * the same context but with different accounts/users. + * + * Their defaults can be controlled by the `intent` property. + */ +export interface UrlConfiguration { + /** + * Whether the app should keep the user confined to the current call/room. + */ + confineToRoom: boolean; + /** + * Whether upon entering a room, the user should be prompted to launch the + * native mobile app. (Affects only Android and iOS.) + * + * The app prompt must also be enabled in the config for this to take effect. + */ + appPrompt: boolean; + /** + * Whether the app should pause before joining the call until it sees an + * io.element.join widget action, allowing it to be preloaded. + */ + preload: boolean; + /** + * The style of headers to show. "standard" is the default arrangement, "none" + * hides the header entirely, and "app_bar" produces a header with a back + * button like you might see in mobile apps. The callback for the back button + * is window.controls.onBackButtonPressed. + */ + header: HeaderStyle; + /** + * Whether the controls should be shown. For screen recording no controls can be desired. + */ + showControls: boolean; + /** + * Whether to hide the screen-sharing button. + */ + hideScreensharing: boolean; + + /** + * Whether the app is allowed to use fallback STUN servers for ICE in case the + * user's homeserver doesn't provide any. + */ + allowIceFallback: boolean; + /** * Whether the app should use per participant keys for E2EE. */ @@ -154,52 +205,19 @@ export interface UrlParams { * This is useful for video rooms. */ returnToLobby: boolean; - /** - * The theme to use for element call. - * can be "light", "dark", "light-high-contrast" or "dark-high-contrast". - */ - theme: string | null; - /** This defines the homeserver that is going to be used when joining a room. - * It has to be set to a non default value for links to rooms - * that are not on the default homeserver, - * that is in use for the current user. - */ - viaServers: string | null; - /** - * This defines the homeserver that is going to be used when registering - * a new (guest) user. - * This can be user to configure a non default guest user server when - * creating a spa link. - */ - homeserver: string | null; - - /** - * The user's intent with respect to the call. - * e.g. if they clicked a Start Call button, this would be `start_call`. - * If it was a Join Call button, it would be `join_existing`. - */ - intent: string | null; - - /** - * The rageshake submit URL. This is only used in the embedded package of Element Call. - */ - rageshakeSubmitUrl: string | null; - - /** - * The Sentry DSN. This is only used in the embedded package of Element Call. - */ - sentryDsn: string | null; - - /** - * The Sentry environment. This is only used in the embedded package of Element Call. - */ - sentryEnvironment: string | null; /** * Whether and what type of notification EC should send, when the user joins the call. */ sendNotificationType?: RTCNotificationType; } +// If you need to add a new flag to this interface, prefer a name that describes +// a specific behavior (such as 'confineToRoom'), rather than one that describes +// the situations that call for this behavior ('isEmbedded'). This makes it +// clearer what each flag means, and helps us avoid coupling Element Call's +// behavior to the needs of specific consumers. +export interface UrlParams extends UrlProperties, UrlConfiguration {} + // This is here as a stopgap, but what would be far nicer is a function that // takes a UrlParams and returns a query string. That would enable us to // consolidate all the data about URL parameters and their meanings to this one @@ -240,6 +258,17 @@ class ParamParser { return this.fragmentParams.get(name) ?? this.queryParams.get(name); } + public getEnumParam( + name: string, + type: { [s: string]: T } | ArrayLike, + ): T | undefined { + const value = this.getParam(name); + if (value !== null && Object.values(type).includes(value as T)) { + return value as T; + } + return undefined; + } + public getAllParams(name: string): string[] { return [ ...this.fragmentParams.getAll(name), @@ -251,6 +280,10 @@ class ParamParser { const param = this.getParam(name); return param === null ? defaultValue : param !== "false"; } + public getFlag(name: string): boolean | undefined { + const param = this.getParam(name); + return param !== null ? param !== "false" : undefined; + } } /** @@ -267,46 +300,79 @@ export const getUrlParams = ( const fontScale = parseFloat(parser.getParam("fontScale") ?? ""); - let intent = parser.getParam("intent"); - if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) { - intent = UserIntent.Unknown; - } - - // Check hideHeader for backwards compatibility. If header is set, hideHeader - // is ignored. - const header = - parser.getParam("header") ?? - (parser.getFlagParam("hideHeader") - ? HeaderStyle.None - : HeaderStyle.Standard); - - const sendNotificationType = ["ring", "notification"].includes( - parser.getParam("sendNotificationType") ?? "", - ) - ? (parser.getParam("sendNotificationType") as RTCNotificationType) - : undefined; const widgetId = parser.getParam("widgetId"); const parentUrl = parser.getParam("parentUrl"); const isWidget = !!widgetId && !!parentUrl; - return { + /** + * The user's intent with respect to the call. + * e.g. if they clicked a Start Call button, this would be `start_call`. + * If it was a Join Call button, it would be `join_existing`. + * This is a platform specific default set of parameters, that allows to minize the configuration + * needed to start a call. And empowers the EC codebase to control the platform/intent behavior in + * a central place. + * + * In short: either provide url query parameters of UrlConfiguration or set the intent + * (or the global defaults will be used). + */ + const intent = !isWidget + ? UserIntent.Unknown + : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); + // Here we only use constants and `platform` to determine the intent preset. + let intentPreset: UrlConfiguration; + const inAppDefault = { + confineToRoom: true, + appPrompt: false, + preload: true, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification" as RTCNotificationType, + }; + switch (intent) { + case UserIntent.StartNewCall: + intentPreset = { + ...inAppDefault, + skipLobby: true, + }; + break; + case UserIntent.JoinExistingCall: + intentPreset = { + ...inAppDefault, + skipLobby: false, + }; + break; + // Non widget usecase defaults + default: + intentPreset = { + confineToRoom: false, + appPrompt: true, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + }; + } + + const properties: UrlProperties = { widgetId, parentUrl, - // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // what would we do if it were invalid? If the widget API says that's what // the room ID is, then that's what it is. roomId: parser.getParam("roomId"), password: parser.getParam("password"), - // This flag has 'embed' as an alias for historical reasons - confineToRoom: - parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"), - appPrompt: parser.getFlagParam("appPrompt", true), - preload: isWidget ? parser.getFlagParam("preload") : false, - header: header as HeaderStyle, - showControls: parser.getFlagParam("showControls", true), - hideScreensharing: parser.getFlagParam("hideScreensharing"), - e2eEnabled: parser.getFlagParam("enableE2EE", true), userId: isWidget ? parser.getParam("userId") : null, displayName: parser.getParam("displayName"), deviceId: isWidget ? parser.getParam("deviceId") : null, @@ -314,24 +380,9 @@ export const getUrlParams = ( lang: parser.getParam("lang"), fonts: parser.getAllParams("font"), fontScale: Number.isNaN(fontScale) ? null : fontScale, - allowIceFallback: parser.getFlagParam("allowIceFallback"), - perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"), - controlledAudioDevices: parser.getFlagParam( - "controlledAudioDevices", - // the deprecated property name - parser.getFlagParam("controlledMediaDevices"), - ), - skipLobby: parser.getFlagParam( - "skipLobby", - isWidget && intent === UserIntent.StartNewCall, - ), - // In SPA mode the user should always exit to the home screen when hanging - // up, rather than being sent back to the lobby - returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false, theme: parser.getParam("theme"), viaServers: !isWidget ? parser.getParam("viaServers") : null, homeserver: !isWidget ? parser.getParam("homeserver") : null, - intent, posthogApiHost: parser.getParam("posthogApiHost"), posthogApiKey: parser.getParam("posthogApiKey"), posthogUserId: @@ -339,7 +390,35 @@ export const getUrlParams = ( rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), - sendNotificationType, + e2eEnabled: parser.getFlagParam("enableE2EE", true), + }; + + const configuration: Partial = { + confineToRoom: parser.getFlag("confineToRoom"), + appPrompt: parser.getFlag("appPrompt"), + preload: isWidget ? parser.getFlag("preload") : undefined, + // Check hideHeader for backwards compatibility. If header is set, hideHeader + // is ignored. + header: parser.getEnumParam("header", HeaderStyle), + showControls: parser.getFlag("showControls"), + hideScreensharing: parser.getFlag("hideScreensharing"), + allowIceFallback: parser.getFlag("allowIceFallback"), + perParticipantE2EE: parser.getFlag("perParticipantE2EE"), + controlledAudioDevices: parser.getFlag("controlledAudioDevices"), + skipLobby: isWidget ? parser.getFlag("skipLobby") : false, + // In SPA mode the user should always exit to the home screen when hanging + // up, rather than being sent back to the lobby + returnToLobby: isWidget ? parser.getFlag("returnToLobby") : false, + sendNotificationType: parser.getEnumParam("sendNotificationType", [ + "ring", + "notification", + ]), + }; + + return { + ...properties, + ...intentPreset, + ...pickBy(configuration, (v) => v !== undefined), }; }; diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index 13dc8ee0..d349a5c6 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -191,7 +191,11 @@ describe("useMuteStates", () => { mockConfig(); render( - + From f7565ef1043387519991921c7e5f756e4d035f76 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 24 Jul 2025 18:50:03 +0200 Subject: [PATCH 23/58] Request capability to send call notifications 0e0fba657558964376e6f2375271630d9cf52b84 added the ability to send call notification events when starting a call, but I forgot to give the widget the right capabilities to do this. The effect was that notifications just wouldn't send in widget mode. --- src/widget.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/widget.ts b/src/widget.ts index b7f51ace..a122f70d 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -106,6 +106,10 @@ export const widget = ((): WidgetHelpers | null => { if (!baseUrl) throw new Error("Base URL must be supplied"); // These are all the event types the app uses + const sendEvent = [ + EventType.CallNotify, // Sent as a deprecated fallback + EventType.RTCNotification, + ]; const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, @@ -129,6 +133,7 @@ export const widget = ((): WidgetHelpers | null => { { eventType: EventType.RoomEncryption }, { eventType: EventType.GroupCallMemberPrefix }, ]; + const sendRecvToDevice = [ EventType.CallInvite, EventType.CallCandidates, @@ -146,7 +151,7 @@ export const widget = ((): WidgetHelpers | null => { const client = createRoomWidgetClient( api, { - sendEvent: sendRecvEvent, + sendEvent: [...sendEvent, ...sendRecvEvent], receiveEvent: sendRecvEvent, sendState, receiveState, From 5dfa42013560b8dd2605508e2f04f3510aea54e2 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 28 Jul 2025 17:45:38 +0200 Subject: [PATCH 24/58] bump js-sdk version --- yarn.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2fd0b95a..fee2f46a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2761,10 +2761,10 @@ __metadata: languageName: node linkType: hard -"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.0.0": - version: 15.0.0 - resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.0.0" - checksum: 10c0/4db5dc78a0fd4850d95bcc18ba5c6436bf80f7a64cf66569d349687529d44aed4319659c988a106f9446e6aca9af982b59412bb674f151ef52d8ecee7ead6dd4 +"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.1.0": + version: 15.1.0 + resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.1.0" + checksum: 10c0/19edc6d0045ff49fad8d77b6e561cee994f7513f8c18a7176ae2d3f0116c1a91980e02d10300b09c2b72dea4da4a8c3392f2bf1752057f2d6b53030a056d76d8 languageName: node linkType: hard @@ -10247,10 +10247,10 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.11.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=11c9e39e5a1e56ea347dbd16ca7c75059127f7d5" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c57c47319e6e31216034e654ef0b092c73c90ad0" dependencies: "@babel/runtime": "npm:^7.12.5" - "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.0.0" + "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" another-json: "npm:^0.2.0" bs58: "npm:^6.0.0" content-type: "npm:^1.0.4" @@ -10263,7 +10263,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/31018452c9578948c47f82648a58d4000b657103b20a38cb1672035bc530e0aaa68ac03bc600f4bfaad9f72f36842490be963251a02d2926dcfe697ff6e459b3 + checksum: 10c0/2e99ef541163cc33b327bab77eb54badbf6dd47e3eadc1b10a1b001ae1c42632cfb7041adf234a2468bf260bf3aade02a0b624dabac00618d053b55172284f8c languageName: node linkType: hard From c7b0f9783de382dc40b65e61915f7233ebeba744 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:58:45 +0200 Subject: [PATCH 25/58] expose `delayed_leave_event_restart_local_timeout_ms` to `config.json` (#3415) --- src/config/ConfigOptions.ts | 11 +++++++++++ src/rtcSessionHelpers.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index c4dc9144..75bd7cbb 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -125,6 +125,17 @@ export interface ConfigOptions { /** @deprecated use delayed_leave_event_delay_ms instead */ membership_server_side_expiry_timeout?: number; + /** + * The time (in milliseconds) after which a we consider a delayed event restart http request to have failed. + * Setting this to a lower value will result in more frequent retries but also a higher chance of failiour. + * + * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs + * helps by keeping more delayed event reset candidates in flight, + * improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration, + * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) + */ + delayed_leave_event_restart_local_timeout_ms?: number; + /** * The time interval (in milliseconds) at which the client sends membership keep-alive * messages to the server by restarting the timer for the delayed leave event. diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index 3ff6b560..a193b8e5 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -128,6 +128,8 @@ export async function enterRTCSession( delayedLeaveEventDelayMs: matrixRtcSessionConfig?.delayed_leave_event_delay_ms ?? matrixRtcSessionConfig?.membership_server_side_expiry_timeout, + delayedLeaveEventRestartLocalTimeoutMs: + matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms, networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms, makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms ?? From 007a50d0f16f35a65784fc2a8287bc2e4bb381b0 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 31 Jul 2025 15:00:21 +0200 Subject: [PATCH 26/58] Fix missing button styling on error dialog --- src/room/GroupCallErrorBoundary.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index b5f195c1..3d55d005 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -21,6 +21,7 @@ import { OfflineIcon, WebBrowserIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Button } from "@vector-im/compound-web"; import { ConnectionLostError, @@ -93,9 +94,13 @@ const ErrorPage: FC = ({

{actions && actions.map((action, index) => ( - + ))} From 446a2d4080fd4c105e3c7202e9caf82516fddde3 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 31 Jul 2025 16:37:09 +0200 Subject: [PATCH 27/58] fix snapshot test following commit 007a50d --- .../GroupCallErrorBoundary.test.tsx.snap | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 79eb580b..ad4aff61 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -132,7 +132,13 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `

You were disconnected from the call.

-
- {onToggleExpanded && ( +
- )} + + {onToggleExpanded && ( + + )} +
+ {canGoToNext && (