From 735d63d393bd534aac375ef34df6eb9e2c73e026 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 10 Jul 2025 16:07:14 -0400 Subject: [PATCH 001/306] Prefer showing the reaction button at small screen widths Rather than the 'share screen' button. Small screens are most likely to be mobile devices which wouldn't have the ability to share their screen, anyways. --- src/room/InCallView.module.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 41d48db1..96b8a368 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -108,13 +108,6 @@ Please see LICENSE in the repository root for full details. } @media (max-width: 370px) { - .raiseHand { - display: none; - } -} - -@media (max-width: 340px) { - .invite, .shareScreen { display: none; } @@ -126,6 +119,13 @@ Please see LICENSE in the repository root for full details. } } +@media (max-width: 320px) { + .invite, + .raiseHand { + display: none; + } +} + @media (max-height: 400px) { .footer { padding-block: var(--cpd-space-4x); From 30024877fcd868d9dcc308c15115baf43d96d9b4 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 14 Aug 2025 17:00:56 +0200 Subject: [PATCH 002/306] Test that the reaction button is visible at small screen widths --- playwright/create-call.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts index 759cd2db..6f03272e 100644 --- a/playwright/create-call.spec.ts +++ b/playwright/create-call.spec.ts @@ -40,6 +40,11 @@ test("Start a new call then leave and show the feedback screen", async ({ // The tooltip with the name should be visible await expect(page.getByTestId("name_tag")).toContainText("John Doe"); + // Resize the window to resemble a small mobile phone + await page.setViewportSize({ width: 350, height: 660 }); + // We should still be able to send reactions at this screen size + await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible(); + // leave the call await page.getByTestId("incall_leave").click(); await expect(page.getByRole("heading")).toContainText( From e475f56af59ce952453ced57b530bf5da0232f3d Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 13:49:01 +0200 Subject: [PATCH 003/306] waitForNotificationAnswer Signed-off-by: Timo K --- src/UrlParams.ts | 12 ++ src/room/GroupCallView.tsx | 1 - src/room/InCallView.tsx | 3 +- src/state/CallViewModel.test.ts | 214 +++++++++++++++++++++++++++++++- src/state/CallViewModel.ts | 192 ++++++++++++++++++++++------ yarn.lock | 9 +- 6 files changed, 384 insertions(+), 47 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 30019d36..f2ee5d4d 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -216,6 +216,17 @@ export interface UrlConfiguration { * This is one part to make the call matrixRTC session behave like a telephone call. */ autoLeaveWhenOthersLeft: boolean; + + /** + * If the client should show behave like it is awaiting an answer if a notification was sent. + * This is a no-op if not combined with sendNotificationType. + * + * This entails: + * - show ui that it is awaiting an answer + * - play a sound that indicates that it is awaiting an answer + * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. + */ + awaitingAnswer: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -442,6 +453,7 @@ export const getUrlParams = ( "ring", "notification", ]), + awaitingAnswer: parser.getFlag("showAwaitingAnswerFeedback"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index ea57bd10..63fc942f 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -453,7 +453,6 @@ export const GroupCallView: FC = ({ matrixInfo={matrixInfo} rtcSession={rtcSession as MatrixRTCSession} matrixRoom={room} - participantCount={participantCount} onLeave={onLeave} header={header} muteStates={muteStates} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 4e3229a5..16e03987 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -216,7 +216,6 @@ export interface InCallViewProps { matrixRoom: MatrixRoom; livekitRoom: LivekitRoom; muteStates: MuteStates; - participantCount: number; /** Function to call when the user explicitly ends the call */ onLeave: () => void; header: HeaderStyle; @@ -233,7 +232,6 @@ export const InCallView: FC = ({ matrixRoom, livekitRoom, muteStates, - participantCount, onLeave, header: headerStyle, connState, @@ -312,6 +310,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 30fbad36..a4f5d2a3 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.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 { test, vi, onTestFinished, it } from "vitest"; +import { test, vi, onTestFinished, it, describe } from "vitest"; import EventEmitter from "events"; import { BehaviorSubject, @@ -32,6 +32,9 @@ import { Status, type CallMembership, type MatrixRTCSession, + type IRTCNotificationContent, + type ICallNotifyContent, + MatrixRTCSessionEvent, } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; @@ -1228,6 +1231,215 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option }); }); +describe("waitForNotificationAnswer$", () => { + test("unknown -> ringing -> timeout when notified and nobody joins", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // No one ever joins (only local user) + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + // Fire a call notification at 10ms with lifetime 30ms + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 30 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + + expectObservable(vm.waitForNotificationAnswer$).toBe( + "a 9ms b 29ms c", + { a: "unknown", b: "ringing", c: "timeout" }, + ); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("ringing -> success if someone joins before timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + const remote$ = scope.behavior( + hot("a--b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then success at 20ms + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 100 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + + expectObservable(vm.waitForNotificationAnswer$).toBe("a 2ms c", { + a: "unknown", + c: "success", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("success when someone joins before we notify", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + // Join at 10ms, notify later at 20ms (state should stay success) + const remote$ = scope.behavior( + hot("a-b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a-b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 20ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 50 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("a 1ms b", { + a: "unknown", + b: "success", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("notify without lifetime -> immediate timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 0 } as unknown as IRTCNotificationContent, // no lifetime + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("a 9ms b", { + a: "unknown", + b: "timeout", + }); + }, + { + waitForNotificationAnswer: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("stays null when waitForNotificationAnswer=false", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + const remote$ = scope.behavior( + hot("a--b", { a: [], b: [aliceParticipant] }), + [], + ); + const rtc$ = scope.behavior( + hot("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + [], + ); + + withCallViewModel( + { + remoteParticipants$: remote$, + rtcMembers$: rtc$, + connectionState$: of(ConnectionState.Connected), + speaking: new Map(), + mediaDevices: mockMediaDevices({}), + }, + (vm, rtcSession) => { + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { lifetime: 30 } as unknown as IRTCNotificationContent, + {} as unknown as ICallNotifyContent, + ); + }, + }); + expectObservable(vm.waitForNotificationAnswer$).toBe("(n)", { + n: null, + }); + }, + { + waitForNotificationAnswer: false, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); +}); + test("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { getUrlParams.mockReturnValue({ controlledAudioDevices: true }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d7bf1812..f22269c0 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -53,6 +53,8 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, + type ICallNotifyContent, + type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, MembershipManagerEvent, @@ -110,7 +112,13 @@ import { type Behavior } from "./Behavior"; export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; autoLeaveWhenOthersLeft?: boolean; + /** + * If the call is started in a way where we want it to behave like a telephone usecase + * If we sent a notification event, we want the ui to show a ringing state + */ + waitForNotificationAnswer?: boolean; } + // How long we wait after a focus switch before showing the real participant // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; @@ -427,7 +435,14 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.StatusChanged, ).pipe( startWith(null), - map(() => this.matrixRTCSession.membershipStatus === Status.Connected), + map( + () => + ( + this.matrixRTCSession as unknown as { + membershipStatus?: Status; + } + ).membershipStatus === Status.Connected, + ), ), // Also watch out for warnings that we've likely hit a timeout and our // delayed leave event is being sent (this condition is here because it @@ -438,7 +453,11 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.ProbablyLeft, ).pipe( startWith(null), - map(() => this.matrixRTCSession.probablyLeft !== true), + map( + () => + (this.matrixRTCSession as unknown as { probablyLeft?: boolean }) + .probablyLeft !== true, + ), ), ), ); @@ -825,49 +844,68 @@ export class CallViewModel extends ViewModel { * - Each participant has a corresponding MatrixRTC membership state event * - There can be multiple participants for one matrix user. */ - public readonly participantChanges$ = this.userMedia$.pipe( - map((mediaItems) => mediaItems.map((m) => m.id)), - scan( - (prev, ids) => { - const left = prev.ids.filter((id) => !ids.includes(id)); - const joined = ids.filter((id) => !prev.ids.includes(id)); - return { ids, joined, left }; - }, - { ids: [], joined: [], left: [] }, + public readonly participantChanges$ = this.scope.behavior( + this.userMedia$.pipe( + map((mediaItems) => mediaItems.map((m) => m.id)), + scan( + (prev, ids) => { + const left = prev.ids.filter((id) => !ids.includes(id)); + const joined = ids.filter((id) => !prev.ids.includes(id)); + return { ids, joined, left }; + }, + { ids: [], joined: [], left: [] }, + ), ), ); + /** + * The number of participants currently in the call. + * + * - Each participant has one livekit connection + * - Each participant has a corresponding MatrixRTC membership state event + * - There can be multiple participants for one matrix user. + */ + public readonly participantCount$ = this.scope.behavior( + this.participantChanges$.pipe(map(({ ids }) => ids.length)), + ); + /** * This observable tracks the matrix users that are currently in the call. * There can be just one matrix user with multiple participants (see also participantChanges$) */ - public readonly matrixUserChanges$ = this.userMedia$.pipe( - map( - (mediaItems) => - new Set( - mediaItems - .map((m) => m.vm.member?.userId) - .filter((id) => id !== undefined), - ), - ), - scan< - Set, - { - userIds: Set; - joinedUserIds: Set; - leftUserIds: Set; - } - >( - (prevState, userIds) => { - const left = new Set( - [...prevState.userIds].filter((id) => !userIds.has(id)), - ); - const joined = new Set( - [...userIds].filter((id) => !prevState.userIds.has(id)), - ); - return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; - }, - { userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() }, + public readonly matrixUserChanges$ = this.scope.behavior( + this.userMedia$.pipe( + map( + (mediaItems) => + new Set( + mediaItems + .map((m) => m.vm.member?.userId) + .filter((id) => id !== undefined), + ), + ), + scan< + Set, + { + userIds: Set; + joinedUserIds: Set; + leftUserIds: Set; + } + >( + (prevState, userIds) => { + const left = new Set( + [...prevState.userIds].filter((id) => !userIds.has(id)), + ); + const joined = new Set( + [...userIds].filter((id) => !prevState.userIds.has(id)), + ); + return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; + }, + { + userIds: new Set(), + joinedUserIds: new Set(), + leftUserIds: new Set(), + }, + ), ), ); @@ -891,6 +929,84 @@ export class CallViewModel extends ViewModel { map(() => {}), ); + /** + * "unknown": We don't know if the RTC session decides to send a notify event yet. + * It will only be known once we sent our own membership and know we were the first one to join. + * "ringing": The notification event was sent. + * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. + */ + private readonly notificationEventIsRingingOthers$: Observable< + "unknown" | "ringing" | "ringEnded" | null + > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( + this.matrixRTCSession, + MatrixRTCSessionEvent.DidSendCallNotification, + ).pipe( + switchMap(([notificationEvent]) => { + // event.lifetime is expected to be in ms + const lifetime = notificationEvent?.lifetime ?? 0; + if (lifetime > 0) { + // Emit true immediately, then false after lifetime ms + return concat( + of<"ringing" | null>("ringing"), + timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), + ); + } + // If no lifetime, just emit true once + return of(null); + }), + startWith("unknown" as "unknown" | null), + ); + + /** + * If some other matrix user has joined the call. It can start with true if there are already multiple matrix users. + */ + private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe( + scan( + (someoneJoined, { joinedUserIds }) => + someoneJoined || [...joinedUserIds].some((id) => id !== this.userId), + false, + ), + startWith(this.matrixUserChanges$.value.userIds.size > 1), + ); + + /** + * The current waiting for answer state of the call. + * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). + * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. + * Then we can conclude if we were the first one to join or not. + * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. + * The call failed. If desired this can be used as a trigger to exit the call. + * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. + * - null: EC is configured to never show any waiting for answer state. + */ + public readonly waitForNotificationAnswer$: Behavior< + "unknown" | "ringing" | "timeout" | "success" | null + > = this.scope.behavior( + combineLatest([ + this.notificationEventIsRingingOthers$, + this.someoneElseJoined$, + ]).pipe( + map(([isRingingOthers, someoneJoined]) => { + // Never enter waiting for answer state if the app is not configured with waitingForAnswer. + if (!this.options.waitForNotificationAnswer) return null; + // As soon as someone joins, we can consider the call "wait for answer" successful + if (someoneJoined) return "success"; + + switch (isRingingOthers) { + case "unknown": + return "unknown"; + case "ringing": + return "ringing"; + case "ringEnded": + return "timeout"; + default: + return "timeout"; + } + }), + distinctUntilChanged(), + ), + ); + /** * List of MediaItems that we want to display, that are of type ScreenShare */ diff --git a/yarn.lock b/yarn.lock index 0d2cf54a..15ed17d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": - version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=3a33c658bbcb8ce8791ec066db899f2571f5c52f" +"matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A.": + version: 0.0.0-use.local + resolution: "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A." dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,9 +10296,8 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1 languageName: node - linkType: hard + linkType: soft "matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0": version: 1.13.1 From acd26c61eff41c1fe17fb76fff1c7a2bf9a512c3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 14:02:47 +0200 Subject: [PATCH 004/306] use custom js-sdk from PR: https://github.com/matrix-org/matrix-js-sdk/pull/4976 Signed-off-by: Timo K --- package.json | 2 +- yarn.lock | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1df79c26..68d364a8 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c", "matrix-widget-api": "^1.13.0", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", diff --git a/yarn.lock b/yarn.lock index 15ed17d5..7e6352c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7537,7 +7537,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop" + matrix-js-sdk: "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c" matrix-widget-api: "npm:^1.13.0" normalize.css: "npm:^8.0.1" observable-hooks: "npm:^4.2.3" @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A.": - version: 0.0.0-use.local - resolution: "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A." +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c": + version: 37.13.0 + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=a9dc1c3a38668c7561ce0eac4a7935498dd0199c" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,8 +10296,9 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" + checksum: 10c0/1f97dd28046ff0ef0700a1c354611dd9eded5d3df5e987af1f12f60f9aebeb0bb932d1cd8685942bd1dd1d673fbf28df5ce1799a35a95d1a06b9f500a3787013 languageName: node - linkType: soft + linkType: hard "matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0": version: 1.13.1 From fb8de061f320d78806c7e78fc240089a4b9a8c34 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 14:31:14 +0200 Subject: [PATCH 005/306] rename to callPickupState Signed-off-by: Timo K --- src/UrlParams.ts | 9 ++++++--- src/state/CallViewModel.test.ts | 31 ++++++++++++++++--------------- src/state/CallViewModel.ts | 8 ++++---- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f2ee5d4d..a26045b5 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -218,7 +218,7 @@ export interface UrlConfiguration { autoLeaveWhenOthersLeft: boolean; /** - * If the client should show behave like it is awaiting an answer if a notification was sent. + * If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up). * This is a no-op if not combined with sendNotificationType. * * This entails: @@ -226,7 +226,7 @@ export interface UrlConfiguration { * - play a sound that indicates that it is awaiting an answer * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. */ - awaitingAnswer: boolean; + shouldWaitForCallPickup: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -358,6 +358,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: "notification" as RTCNotificationType, autoLeaveWhenOthersLeft: false, + shouldWaitForCallPickup: false, }; switch (intent) { case UserIntent.StartNewCall: @@ -377,6 +378,7 @@ export const getUrlParams = ( ...inAppDefault, skipLobby: true, autoLeaveWhenOthersLeft: true, + shouldWaitForCallPickup: true, }; break; case UserIntent.JoinExistingCallDM: @@ -402,6 +404,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: undefined, autoLeaveWhenOthersLeft: false, + shouldWaitForCallPickup: false, }; } @@ -453,7 +456,7 @@ export const getUrlParams = ( "ring", "notification", ]), - awaitingAnswer: parser.getFlag("showAwaitingAnswerFeedback"), + shouldWaitForCallPickup: parser.getFlag("shouldWaitForCallPickup"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index a4f5d2a3..02297dd4 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1231,7 +1231,7 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option }); }); -describe("waitForNotificationAnswer$", () => { +describe("shouldWaitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { // No one ever joins (only local user) @@ -1255,13 +1255,14 @@ describe("waitForNotificationAnswer$", () => { }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe( - "a 9ms b 29ms c", - { a: "unknown", b: "ringing", c: "timeout" }, - ); + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1303,13 +1304,13 @@ describe("waitForNotificationAnswer$", () => { }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 2ms c", { + expectObservable(vm.callPickupState$).toBe("a 2ms c", { a: "unknown", c: "success", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1349,13 +1350,13 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 1ms b", { + expectObservable(vm.callPickupState$).toBe("a 1ms b", { a: "unknown", b: "success", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1382,20 +1383,20 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("a 9ms b", { + expectObservable(vm.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "timeout", }); }, { - waitForNotificationAnswer: true, + shouldWaitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); - test("stays null when waitForNotificationAnswer=false", () => { + test("stays null when shouldWaitForCallPickup=false", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { const remote$ = scope.behavior( hot("a--b", { a: [], b: [aliceParticipant] }), @@ -1427,12 +1428,12 @@ describe("waitForNotificationAnswer$", () => { ); }, }); - expectObservable(vm.waitForNotificationAnswer$).toBe("(n)", { + expectObservable(vm.callPickupState$).toBe("(n)", { n: null, }); }, { - waitForNotificationAnswer: false, + shouldWaitForCallPickup: false, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index f22269c0..709d27fa 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -116,7 +116,7 @@ export interface CallViewModelOptions { * If the call is started in a way where we want it to behave like a telephone usecase * If we sent a notification event, we want the ui to show a ringing state */ - waitForNotificationAnswer?: boolean; + shouldWaitForCallPickup?: boolean; } // How long we wait after a focus switch before showing the real participant @@ -970,7 +970,7 @@ export class CallViewModel extends ViewModel { ); /** - * The current waiting for answer state of the call. + * The current call pickup state of the call. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. @@ -979,7 +979,7 @@ export class CallViewModel extends ViewModel { * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly waitForNotificationAnswer$: Behavior< + public readonly callPickupState$: Behavior< "unknown" | "ringing" | "timeout" | "success" | null > = this.scope.behavior( combineLatest([ @@ -988,7 +988,7 @@ export class CallViewModel extends ViewModel { ]).pipe( map(([isRingingOthers, someoneJoined]) => { // Never enter waiting for answer state if the app is not configured with waitingForAnswer. - if (!this.options.waitForNotificationAnswer) return null; + if (!this.options.shouldWaitForCallPickup) return null; // As soon as someone joins, we can consider the call "wait for answer" successful if (someoneJoined) return "success"; From 04c03b1e72742fafbfde5fe4020f1082dcd55c1a Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 17:41:58 +0200 Subject: [PATCH 006/306] bump js-sdk to develop branch Signed-off-by: Timo K --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 68d364a8..1df79c26 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop", "matrix-widget-api": "^1.13.0", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", diff --git a/yarn.lock b/yarn.lock index 7e6352c4..723e3486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7537,7 +7537,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c" + matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop" matrix-widget-api: "npm:^1.13.0" normalize.css: "npm:^8.0.1" observable-hooks: "npm:^4.2.3" @@ -10278,9 +10278,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#a9dc1c3a38668c7561ce0eac4a7935498dd0199c": +"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=a9dc1c3a38668c7561ce0eac4a7935498dd0199c" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,7 +10296,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/1f97dd28046ff0ef0700a1c354611dd9eded5d3df5e987af1f12f60f9aebeb0bb932d1cd8685942bd1dd1d673fbf28df5ce1799a35a95d1a06b9f500a3787013 + checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a languageName: node linkType: hard From 36be15436ac4d42d6502b74445494a02e55ffc25 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 17:58:46 +0200 Subject: [PATCH 007/306] fix linter Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 02297dd4..2bb810bb 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1249,8 +1249,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 30 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { event_id: string } & ICallNotifyContent, ); }, }); From 03527e9d7a71a9be8ae8754bd6b1b4722b33f335 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:09:50 +0200 Subject: [PATCH 008/306] fix participant count test Signed-off-by: Timo K --- src/room/InCallView.test.tsx | 1 - .../__snapshots__/InCallView.test.tsx.snap | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index ec057e94..eb192f66 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -177,7 +177,6 @@ function createInCallView(): RenderResult & { }} matrixRoom={room} livekitRoom={livekitRoom} - participantCount={0} onLeave={function (): void { throw new Error("Function not implemented."); }} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 7d6ab966..bfbef499 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -49,6 +49,34 @@ exports[`InCallView > rendering > renders 1`] = ` +
+ + + + + + + 2 + +
Date: Tue, 26 Aug 2025 18:10:08 +0200 Subject: [PATCH 009/306] dont use deprecated `defer` Signed-off-by: Timo K --- src/livekit/useECConnectionState.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 72324884..e71ceeae 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -93,8 +93,8 @@ describe("Leaking connection prevention", () => { test("Should cancel pending connections when the component is unmounted", async () => { const connectCall = vi.fn(); - const pendingConnection = defer(); - // let pendingDisconnection = defer() + const pendingConnection = Promise.withResolvers(); + // let pendingDisconnection = Promise.withResolvers() const disconnectMock = vi.fn(); const mockRoom = { @@ -141,8 +141,8 @@ describe("Leaking connection prevention", () => { test("Should cancel about to open but not yet opened connection", async () => { const createTracksCall = vi.fn(); - const pendingCreateTrack = defer(); - // let pendingDisconnection = defer() + const pendingCreateTrack = Promise.withResolvers(); + // let pendingDisconnection = Promise.withResolvers() const disconnectMock = vi.fn(); const connectMock = vi.fn(); From f410a119344e66409b8d258233c602734bc823c1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:13:04 +0200 Subject: [PATCH 010/306] unused import Signed-off-by: Timo K --- src/livekit/useECConnectionState.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index e71ceeae..883cb68c 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -15,7 +15,7 @@ import { import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { defer, sleep } from "matrix-js-sdk/lib/utils"; +import { sleep } from "matrix-js-sdk/lib/utils"; import { useECConnectionState } from "./useECConnectionState"; import { type SFUConfig } from "./openIDSFU"; From c15551c9f565f39b22240d54e769f80375294d38 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:29:51 +0200 Subject: [PATCH 011/306] types Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 2bb810bb..b774700c 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1300,8 +1300,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 100 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 100 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1347,8 +1351,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 50 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1380,8 +1388,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as IRTCNotificationContent, // no lifetime - {} as unknown as ICallNotifyContent, + { lifetime: 0 } as unknown as { + event_id: string; + } & IRTCNotificationContent, // no lifetime + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1425,8 +1437,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as IRTCNotificationContent, - {} as unknown as ICallNotifyContent, + { lifetime: 30 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + {} as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); From e30142a43b0af48a3cc1f9c641048aa77e9b23b9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 17:49:23 +0200 Subject: [PATCH 012/306] Add decline logic and tests Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 174 ++++++++++++++++++++++++++++++-- src/state/CallViewModel.ts | 64 +++++++++--- src/utils/test.ts | 33 +++--- 3 files changed, 229 insertions(+), 42 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b774700c..475ccd47 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -18,7 +18,14 @@ import { of, switchMap, } from "rxjs"; -import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk"; +import { + ClientEvent, + SyncState, + type MatrixClient, + RoomEvent as MatrixRoomEvent, + MatrixEvent, + type IRoomTimelineData, +} from "matrix-js-sdk"; import { ConnectionState, type LocalParticipant, @@ -1249,10 +1256,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { + { event_id: "$notif1", lifetime: 30 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { event_id: string } & ICallNotifyContent, + { event_id: "$notif1" } as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1300,10 +1309,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 100 } as unknown as { + { event_id: "$notif2", lifetime: 100 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif2" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1351,10 +1360,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 50 } as unknown as { + { event_id: "$notif3", lifetime: 50 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif3" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1388,10 +1397,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as { + { event_id: "$notif4", lifetime: 0 } as unknown as { event_id: string; } & IRTCNotificationContent, // no lifetime - {} as unknown as { + { event_id: "$notif4" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1437,10 +1446,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { + { event_id: "$notif5", lifetime: 30 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif5" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1457,6 +1466,149 @@ describe("shouldWaitForCallPickup$", () => { ); }); }); + + test("decline before timeout window ends -> decline", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (vm, rtcSession) => { + // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id + schedule(" 10ms r 29ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { event_id: "$decl1", lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$decl1" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + d: () => { + // Emit decline timeline event with id matching the notification + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ event_id: "$decl1", type: "m.rtc.decline" }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", { + a: "unknown", + b: "ringing", + e: "decline", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline after timeout window ends -> stays timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (vm, rtcSession) => { + // Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms + schedule(" 10ms r 20ms t 10ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { event_id: "$decl2", lifetime: 20 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$decl2" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + t: () => {}, + d: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline with wrong id is ignored (stays ringing)", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (vm, rtcSession) => { + // Notify at 10ms with id A, decline arrives at 20ms with id B + schedule(" 10ms r 10ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { event_id: "$right", lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$right" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + d: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ event_id: "$wrong", type: "m.rtc.decline" }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + // We assert up to 21ms to see the ringing at 10ms and no change at 20ms + expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", { + a: "unknown", + b: "ringing", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); }); test("audio output changes when toggling earpiece mode", () => { diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 709d27fa..d6fa80f5 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -19,6 +19,8 @@ import { } from "livekit-client"; import { ClientEvent, + EventTimelineSetHandlerMap, + RoomEvent, RoomStateEvent, SyncState, type Room as MatrixRoom, @@ -57,6 +59,7 @@ import { type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, + MatrixRTCSessionEventHandlerMap, MembershipManagerEvent, Status, } from "matrix-js-sdk/lib/matrixrtc"; @@ -935,26 +938,35 @@ export class CallViewModel extends ViewModel { * "ringing": The notification event was sent. * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. */ - private readonly notificationEventIsRingingOthers$: Observable< - "unknown" | "ringing" | "ringEnded" | null - > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( - this.matrixRTCSession, - MatrixRTCSessionEvent.DidSendCallNotification, - ).pipe( + private readonly rtcNotificationEventState$: Observable< + { state: "unknown" | "ringEnded" } | { state: "ringing"; event_id: string } + > = fromEvent< + Parameters< + MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification] + > + >(this.matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification).pipe( switchMap(([notificationEvent]) => { // event.lifetime is expected to be in ms const lifetime = notificationEvent?.lifetime ?? 0; if (lifetime > 0) { // Emit true immediately, then false after lifetime ms return concat( - of<"ringing" | null>("ringing"), - timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), + of({ + state: "ringing", + event_id: notificationEvent.event_id, + } as { + state: "ringing"; + event_id: string; + }), + timer(lifetime).pipe( + map(() => ({ state: "ringEnded" }) as { state: "ringEnded" }), + ), ); } - // If no lifetime, just emit true once - return of(null); + // If no lifetime, the notify event is basically invalid and we just stay in unknown state. + return of({ state: "unknown" } as { state: "unknown" }); }), - startWith("unknown" as "unknown" | null), + startWith({ state: "unknown" } as { state: "unknown" }), ); /** @@ -980,22 +992,37 @@ export class CallViewModel extends ViewModel { * - null: EC is configured to never show any waiting for answer state. */ public readonly callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "success" | null + "unknown" | "ringing" | "timeout" | "success" | "decline" | null > = this.scope.behavior( combineLatest([ - this.notificationEventIsRingingOthers$, + this.rtcNotificationEventState$, this.someoneElseJoined$, + fromEvent>( + this.matrixRoom, + RoomEvent.Timeline, + ).pipe( + map(([event]) => { + // TODO use correct decline event type enum. + if (event.getType() === "m.rtc.decline") return event; + else return null; + }), + startWith(null), + ), ]).pipe( - map(([isRingingOthers, someoneJoined]) => { + map(([notificationEventState, someoneJoined, declineEvent]) => { // Never enter waiting for answer state if the app is not configured with waitingForAnswer. if (!this.options.shouldWaitForCallPickup) return null; // As soon as someone joins, we can consider the call "wait for answer" successful if (someoneJoined) return "success"; - switch (isRingingOthers) { + switch (notificationEventState?.state) { case "unknown": return "unknown"; case "ringing": + // Check if the decline event corresponds to the current notification event + if (declineEvent?.getId() === notificationEventState.event_id) { + return "decline"; + } return "ringing"; case "ringEnded": return "timeout"; @@ -1003,6 +1030,13 @@ export class CallViewModel extends ViewModel { return "timeout"; } }), + // Once we reach a terminal state, keep it + scan((prev, next) => { + if (prev === "decline" || prev === "timeout" || prev === "success") { + return prev; + } + return next; + }), distinctUntilChanged(), ), ); diff --git a/src/utils/test.ts b/src/utils/test.ts index 3e47f4f6..cd8b2d1c 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -38,6 +38,7 @@ import { type RoomAndToDeviceEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { type TrackReference } from "@livekit/components-core"; +import EventEmitter from "events"; import { LocalUserMediaViewModel, @@ -143,27 +144,27 @@ export function withTestScheduler( scope.end(); } + interface EmitterMock { - on: () => T; - off: () => T; - addListener: () => T; - removeListener: () => T; + on: (...args: unknown[]) => T; + off: (...args: unknown[]) => T; + addListener: (...args: unknown[]) => T; + removeListener: (...args: unknown[]) => T; + emit: (event: string | symbol, ...args: unknown[]) => boolean; } export function mockEmitter(): EmitterMock { + const ee = new EventEmitter(); return { - on(): T { - return this as T; - }, - off(): T { - return this as T; - }, - addListener(): T { - return this as T; - }, - removeListener(): T { - return this as T; - }, + on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T, + off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T, + addListener: ee.addListener.bind(ee) as unknown as ( + ...args: unknown[] + ) => T, + removeListener: ee.removeListener.bind(ee) as unknown as ( + ...args: unknown[] + ) => T, + emit: ee.emit.bind(ee), }; } From a91c71a8e4c7c0b6a5660c81212299296b6d61a8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:28:53 +0200 Subject: [PATCH 013/306] types Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 13 +++++++------ src/state/CallViewModel.ts | 2 -- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 475ccd47..559a5226 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1300,8 +1300,6 @@ describe("shouldWaitForCallPickup$", () => { remoteParticipants$: remote$, rtcMembers$: rtc$, connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), }, (vm, rtcSession) => { // Notify at 5ms so we enter ringing, then success at 20ms @@ -1309,12 +1307,15 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif2", lifetime: 100 } as unknown as { + { + event_id: "$notif2", + lifetime: 100, + } as unknown as IRTCNotificationContent & { event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif2" } as unknown as { + }, + { event_id: "$notif2" } as unknown as ICallNotifyContent & { event_id: string; - } & ICallNotifyContent, + }, ); }, }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d6fa80f5..1771a589 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -55,8 +55,6 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, - type ICallNotifyContent, - type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap, From fe65c1f4dae422010510835ca36f3a937e5f648e Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 19:07:52 +0200 Subject: [PATCH 014/306] fix decline event type Signed-off-by: Timo K --- src/state/CallViewModel.ts | 4 ++-- src/utils/test.ts | 1 - yarn.lock | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 1771a589..d292ef5a 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -20,6 +20,7 @@ import { import { ClientEvent, EventTimelineSetHandlerMap, + EventType, RoomEvent, RoomStateEvent, SyncState, @@ -1000,8 +1001,7 @@ export class CallViewModel extends ViewModel { RoomEvent.Timeline, ).pipe( map(([event]) => { - // TODO use correct decline event type enum. - if (event.getType() === "m.rtc.decline") return event; + if (event.getType() === EventType.RTCDecline) return event; else return null; }), startWith(null), diff --git a/src/utils/test.ts b/src/utils/test.ts index cd8b2d1c..31c6068a 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -144,7 +144,6 @@ export function withTestScheduler( scope.end(); } - interface EmitterMock { on: (...args: unknown[]) => T; off: (...args: unknown[]) => T; diff --git a/yarn.lock b/yarn.lock index 723e3486..fa71cce0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10280,7 +10280,7 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2f1d654f14be8dd03896e9e76f12017b6f9eec1c" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,7 +10296,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a + checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805 languageName: node linkType: hard From 7724cbf9be4f2a5de93a15f76f63d062c3895f9c Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 19:21:27 +0200 Subject: [PATCH 015/306] fix tests Signed-off-by: Timo K --- src/state/CallViewModel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d292ef5a..96d8740b 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -19,7 +19,7 @@ import { } from "livekit-client"; import { ClientEvent, - EventTimelineSetHandlerMap, + type EventTimelineSetHandlerMap, EventType, RoomEvent, RoomStateEvent, @@ -58,7 +58,7 @@ import { type CallMembership, type MatrixRTCSession, MatrixRTCSessionEvent, - MatrixRTCSessionEventHandlerMap, + type MatrixRTCSessionEventHandlerMap, MembershipManagerEvent, Status, } from "matrix-js-sdk/lib/matrixrtc"; @@ -962,8 +962,8 @@ export class CallViewModel extends ViewModel { ), ); } - // If no lifetime, the notify event is basically invalid and we just stay in unknown state. - return of({ state: "unknown" } as { state: "unknown" }); + // If no lifetime, the notify event is basically invalid and we enter ringEnded immediately. + return of({ state: "ringEnded" } as { state: "ringEnded" }); }), startWith({ state: "unknown" } as { state: "unknown" }), ); From a775bcd62a434cad4d20cdcb9f8a1339090d0806 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 28 Aug 2025 10:09:49 -0400 Subject: [PATCH 016/306] Migrate from js-sdk's `defer` in test See: matrix-org/matrix-js-sdk#4959 --- src/livekit/useECConnectionState.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 72324884..8abbf438 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -15,7 +15,7 @@ import { import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; -import { defer, sleep } from "matrix-js-sdk/lib/utils"; +import { sleep } from "matrix-js-sdk/lib/utils"; import { useECConnectionState } from "./useECConnectionState"; import { type SFUConfig } from "./openIDSFU"; @@ -93,7 +93,7 @@ describe("Leaking connection prevention", () => { test("Should cancel pending connections when the component is unmounted", async () => { const connectCall = vi.fn(); - const pendingConnection = defer(); + const pendingConnection = Promise.withResolvers(); // let pendingDisconnection = defer() const disconnectMock = vi.fn(); @@ -141,7 +141,7 @@ describe("Leaking connection prevention", () => { test("Should cancel about to open but not yet opened connection", async () => { const createTracksCall = vi.fn(); - const pendingCreateTrack = defer(); + const pendingCreateTrack = Promise.withResolvers(); // let pendingDisconnection = defer() const disconnectMock = vi.fn(); const connectMock = vi.fn(); From 07522d67044d7aad81755a5dffef862a588f2c79 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 13:03:48 +0200 Subject: [PATCH 017/306] Remove unnecessary casts --- src/state/CallViewModel.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 709d27fa..cc600154 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -435,14 +435,7 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.StatusChanged, ).pipe( startWith(null), - map( - () => - ( - this.matrixRTCSession as unknown as { - membershipStatus?: Status; - } - ).membershipStatus === Status.Connected, - ), + map(() => this.matrixRTCSession.membershipStatus === Status.Connected), ), // Also watch out for warnings that we've likely hit a timeout and our // delayed leave event is being sent (this condition is here because it @@ -453,11 +446,7 @@ export class CallViewModel extends ViewModel { MembershipManagerEvent.ProbablyLeft, ).pipe( startWith(null), - map( - () => - (this.matrixRTCSession as unknown as { probablyLeft?: boolean }) - .probablyLeft !== true, - ), + map(() => this.matrixRTCSession.probablyLeft !== true), ), ), ); From 880e07c07f9edbec225a0629529983d85a76e32b Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:50:43 +0200 Subject: [PATCH 018/306] Tidy some things up, refactor dialing/ringing behaviors --- src/room/CallEventAudioRenderer.test.tsx | 10 +- src/room/CallEventAudioRenderer.tsx | 49 +--- src/room/InCallView.tsx | 2 +- src/state/CallViewModel.test.ts | 122 ++++------ src/state/CallViewModel.ts | 286 ++++++++++------------- 5 files changed, 179 insertions(+), 290 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 1c515175..f51e602d 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -19,10 +19,7 @@ import { act } from "react"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { mockRtcMembership } from "../utils/test"; -import { - CallEventAudioRenderer, - MAX_PARTICIPANT_COUNT_FOR_SOUND, -} from "./CallEventAudioRenderer"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; @@ -33,6 +30,7 @@ import { local, localRtcMember, } from "../utils/test-fixtures"; +import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel"; vitest.mock("../useAudioContext"); vitest.mock("../soundUtils"); @@ -172,7 +170,7 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(2); + expect(playSound).toHaveBeenCalledTimes(1); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -182,5 +180,5 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(2); + expect(playSound).toHaveBeenCalledTimes(1); }); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index a39da82a..797501b6 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useEffect } from "react"; -import { filter, interval, throttle } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel"; import joinCallSoundMp3 from "../sound/join_call.mp3"; @@ -21,11 +20,6 @@ import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { useLatest } from "../useLatest"; -// Do not play any sounds if the participant count has exceeded this -// number. -export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; -export const THROTTLE_SOUND_EFFECT_MS = 500; - export const callEventAudioSounds = prefetchSounds({ join: { mp3: joinCallSoundMp3, @@ -60,37 +54,18 @@ export function CallEventAudioRenderer({ const audioEngineRef = useLatest(audioEngineCtx); useEffect(() => { - const joinSub = vm.participantChanges$ - .pipe( - filter( - ({ joined, ids }) => - ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0, - ), - throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), - ) - .subscribe(() => { - void audioEngineRef.current?.playSound("join"); - }); - - const leftSub = vm.participantChanges$ - .pipe( - filter( - ({ ids, left }) => - ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0, - ), - throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)), - ) - .subscribe(() => { - void audioEngineRef.current?.playSound("left"); - }); - - const handRaisedSub = vm.newHandRaised$.subscribe(() => { - void audioEngineRef.current?.playSound("raiseHand"); - }); - - const screenshareSub = vm.newScreenShare$.subscribe(() => { - void audioEngineRef.current?.playSound("screenshareStarted"); - }); + const joinSub = vm.joinSoundEffect$.subscribe( + () => void audioEngineRef.current?.playSound("join"), + ); + const leftSub = vm.leaveSoundEffect$.subscribe( + () => void audioEngineRef.current?.playSound("left"), + ); + const handRaisedSub = vm.newHandRaised$.subscribe( + () => void audioEngineRef.current?.playSound("raiseHand"), + ); + const screenshareSub = vm.newScreenShare$.subscribe( + () => void audioEngineRef.current?.playSound("screenshareStarted"), + ); return (): void => { joinSub.unsubscribe(); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 16e03987..034be16d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -321,7 +321,7 @@ export const InCallView: FC = ({ const showFooter = useBehavior(vm.showFooter$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave); + useSubscription(vm.autoLeave$, onLeave); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b774700c..a26fdc59 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -311,7 +311,7 @@ function withCallViewModel( const roomEventSelectorSpy = vi .spyOn(ComponentsCore, "roomEventSelector") - .mockImplementation((room, eventType) => of()); + .mockImplementation((_room, _eventType) => of()); const livekitRoom = mockLivekitRoom( { localParticipant }, @@ -1071,9 +1071,9 @@ it("should rank raised hands above video feeds and below speakers and presenters }); function nooneEverThere$( - hot: (marbles: string, values: Record) => Observable, -): Observable { - return hot("a-b-c-d", { + behavior: (marbles: string, values: Record) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [], // Start empty b: [], // Alice joins c: [], // Alice still there @@ -1082,12 +1082,12 @@ function nooneEverThere$( } function participantJoinLeave$( - hot: ( + behavior: ( marbles: string, values: Record, - ) => Observable, -): Observable { - return hot("a-b-c-d", { + ) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [], // Start empty b: [aliceParticipant], // Alice joins c: [aliceParticipant], // Alice still there @@ -1096,12 +1096,12 @@ function participantJoinLeave$( } function rtcMemberJoinLeave$( - hot: ( + behavior: ( marbles: string, values: Record, - ) => Observable, -): Observable { - return hot("a-b-c-d", { + ) => Behavior, +): Behavior { + return behavior("a-b-c-d", { a: [localRtcMember], // Start empty b: [localRtcMember, aliceRtcMember], // Alice joins c: [localRtcMember, aliceRtcMember], // Alice still there @@ -1109,47 +1109,15 @@ function rtcMemberJoinLeave$( }); } -test("allOthersLeft$ emits only when someone joined and then all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { - // Test scenario 1: No one ever joins - should only emit initial false and never emit again - withCallViewModel( - { remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) }, - (vm) => { - expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); - }, - ); - }); -}); - -test("allOthersLeft$ emits true when someone joined and then all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), }, (vm) => { - expectObservable(vm.allOthersLeft$).toBe( - "n-----u", // false initially, then at frame 6: true then false emissions in same frame - { n: false, u: true }, // map(() => {}) - ); - }, - ); - }); -}); - -test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { - withCallViewModel( - { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), - }, - (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( - "------e", // false initially, then at frame 6: true then false emissions in same frame - { e: undefined }, - ); + expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined }); }, { autoLeaveWhenOthersLeft: true, @@ -1159,15 +1127,15 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is }); }); -test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(nooneEverThere$(hot), []), - rtcMembers$: scope.behavior(nooneEverThere$(hot), []), + remoteParticipants$: nooneEverThere$(behavior), + rtcMembers$: nooneEverThere$(behavior), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + expectObservable(vm.autoLeave$).toBe("-"); }, { autoLeaveWhenOthersLeft: true, @@ -1177,15 +1145,15 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena }); }); -test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []), - rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []), + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + expectObservable(vm.autoLeave$).toBe("-"); }, { autoLeaveWhenOthersLeft: false, @@ -1195,31 +1163,25 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option }); }); -test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { - withTestScheduler(({ hot, expectObservable, scope }) => { +test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { withCallViewModel( { - remoteParticipants$: scope.behavior( - hot("a-b-c-d", { - a: [], // Alone - b: [aliceParticipant], // Alice joins - c: [aliceParticipant], - d: [], // Local joins with a second device - }), - [], //Alice leaves - ), - rtcMembers$: scope.behavior( - hot("a-b-c-d", { - a: [localRtcMember], // Start empty - b: [localRtcMember, aliceRtcMember], // Alice joins - c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there - d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves - }), - [], - ), + remoteParticipants$: behavior("a-b-c-d", { + a: [], // Alone + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], + d: [], // Local joins with a second device + }), + rtcMembers$: behavior("a-b-c-d", { + a: [localRtcMember], // Start empty + b: [localRtcMember, aliceRtcMember], // Alice joins + c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there + d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves + }), }, (vm) => { - expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { + expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined, }); }, diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index cc600154..d4b9537e 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -27,6 +27,7 @@ import { import { BehaviorSubject, EMPTY, + NEVER, type Observable, Subject, combineLatest, @@ -35,10 +36,12 @@ import { filter, forkJoin, fromEvent, + ignoreElements, map, merge, mergeMap, of, + pairwise, race, scan, skip, @@ -47,13 +50,14 @@ import { switchMap, switchScan, take, + takeUntil, + throttleTime, timer, withLatestFrom, } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, - type ICallNotifyContent, type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, @@ -107,7 +111,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"; +import { constant, type Behavior } from "./Behavior"; export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; @@ -123,6 +127,11 @@ export interface CallViewModelOptions { // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; +// Do not play any sounds if the participant count has exceeded this +// number. +export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; +export const THROTTLE_SOUND_EFFECT_MS = 500; + // This is the number of participants that we think constitutes a "small" call // on mobile. No spotlight tile should be shown below this threshold. const smallMobileCallThreshold = 3; @@ -563,6 +572,17 @@ export class CallViewModel extends ViewModel { ) .pipe(pauseWhen(this.pretendToBeDisconnected$)); + private readonly memberships$ = this.scope.behavior( + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.MembershipsChanged, + ).pipe( + startWith(null), + pauseWhen(this.pretendToBeDisconnected$), + map(() => this.matrixRTCSession.memberships), + ), + ); + /** * Displaynames for each member of the call. This will disambiguate * any displaynames that clashes with another member. Only members @@ -572,18 +592,17 @@ export class CallViewModel extends ViewModel { // 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: public readonly memberDisplaynames$ = this.scope.behavior( - merge( - // Handle call membership changes. - fromEvent( - this.matrixRTCSession, - MatrixRTCSessionEvent.MembershipsChanged, - ), - // Handle room membership changes (and displayname updates) - fromEvent(this.matrixRoom, RoomStateEvent.Members), - ).pipe( - startWith(null), - map(() => { - const memberships = this.matrixRTCSession.memberships; + // React to call memberships and also display name updates + // (calculateDisplayName implicitly depends on the room member data) + combineLatest( + [ + this.memberships$, + fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe( + startWith(null), + pauseWhen(this.pretendToBeDisconnected$), + ), + ], + (memberships, _members) => { const displaynameMap = new Map(); const room = this.matrixRoom; @@ -605,8 +624,7 @@ export class CallViewModel extends ViewModel { ); } return displaynameMap; - }), - pauseWhen(this.pretendToBeDisconnected$), + }, ), ); @@ -636,13 +654,7 @@ export class CallViewModel extends ViewModel { this.remoteParticipants$, observeParticipantMedia(this.livekitRoom.localParticipant), duplicateTiles.value$, - // Also react to changes in the MatrixRTC session list. - // The session list will also be update if a room membership changes. - // No additional RoomState event listener needs to be set up. - fromEvent( - this.matrixRTCSession, - MatrixRTCSessionEvent.MembershipsChanged, - ).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)), + this.memberships$, showNonMemberTiles.value$, ]).pipe( scan( @@ -652,7 +664,7 @@ export class CallViewModel extends ViewModel { remoteParticipants, { participant: localParticipant }, duplicateTiles, - _membershipsChanged, + memberships, showNonMemberTiles, ], ) => { @@ -660,7 +672,7 @@ export class CallViewModel extends ViewModel { function* (this: CallViewModel): Iterable<[string, MediaItem]> { const room = this.matrixRoom; // m.rtc.members are the basis for calculating what is visible in the call - for (const rtcMember of this.matrixRTCSession.memberships) { + for (const rtcMember of memberships) { const { member, id: livekitParticipantId } = getRoomMemberFromRtcMember(rtcMember, room); const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; @@ -826,175 +838,117 @@ export class CallViewModel extends ViewModel { ), ); - /** - * This observable tracks the currently connected participants. - * - * - Each participant has one livekit connection - * - Each participant has a corresponding MatrixRTC membership state event - * - There can be multiple participants for one matrix user. - */ - public readonly participantChanges$ = this.scope.behavior( - this.userMedia$.pipe( - map((mediaItems) => mediaItems.map((m) => m.id)), - scan( - (prev, ids) => { - const left = prev.ids.filter((id) => !ids.includes(id)); - const joined = ids.filter((id) => !prev.ids.includes(id)); - return { ids, joined, left }; - }, - { ids: [], joined: [], left: [] }, - ), + public readonly joinSoundEffect$ = this.userMedia$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && + current.length > prev.length, ), + map(() => {}), + throttleTime(THROTTLE_SOUND_EFFECT_MS), + ); + + public readonly leaveSoundEffect$ = this.userMedia$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && + current.length < prev.length, + ), + map(() => {}), + throttleTime(THROTTLE_SOUND_EFFECT_MS), ); /** * The number of participants currently in the call. * - * - Each participant has one livekit connection * - Each participant has a corresponding MatrixRTC membership state event - * - There can be multiple participants for one matrix user. + * - There can be multiple participants for one Matrix user if they join from + * multiple devices. */ public readonly participantCount$ = this.scope.behavior( - this.participantChanges$.pipe(map(({ ids }) => ids.length)), + this.memberships$.pipe(map((ms) => ms.length)), ); - /** - * This observable tracks the matrix users that are currently in the call. - * There can be just one matrix user with multiple participants (see also participantChanges$) - */ - public readonly matrixUserChanges$ = this.scope.behavior( - this.userMedia$.pipe( - map( - (mediaItems) => - new Set( - mediaItems - .map((m) => m.vm.member?.userId) - .filter((id) => id !== undefined), - ), - ), - scan< - Set, - { - userIds: Set; - joinedUserIds: Set; - leftUserIds: Set; - } - >( - (prevState, userIds) => { - const left = new Set( - [...prevState.userIds].filter((id) => !userIds.has(id)), - ); - const joined = new Set( - [...userIds].filter((id) => !prevState.userIds.has(id)), - ); - return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; - }, - { - userIds: new Set(), - joinedUserIds: new Set(), - leftUserIds: new Set(), - }, - ), + private readonly allOthersLeft$ = this.memberships$.pipe( + pairwise(), + filter( + ([prev, current]) => + current.every((m) => m.sender === this.userId) && + prev.some((m) => m.sender !== this.userId), ), - ); - - public readonly allOthersLeft$ = this.matrixUserChanges$.pipe( - map(({ userIds, leftUserIds }) => { - if (!this.userId) { - logger.warn("Could not access user ID to compute allOthersLeft"); - return false; - } - return ( - userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0 - ); - }), - startWith(false), - distinctUntilChanged(), - ); - - public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe( - distinctUntilChanged(), - filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false), map(() => {}), + take(1), ); - /** - * "unknown": We don't know if the RTC session decides to send a notify event yet. - * It will only be known once we sent our own membership and know we were the first one to join. - * "ringing": The notification event was sent. - * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. - */ - private readonly notificationEventIsRingingOthers$: Observable< - "unknown" | "ringing" | "ringEnded" | null - > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( - this.matrixRTCSession, - MatrixRTCSessionEvent.DidSendCallNotification, - ).pipe( - switchMap(([notificationEvent]) => { - // event.lifetime is expected to be in ms - const lifetime = notificationEvent?.lifetime ?? 0; - if (lifetime > 0) { - // Emit true immediately, then false after lifetime ms - return concat( - of<"ringing" | null>("ringing"), - timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), - ); - } - // If no lifetime, just emit true once - return of(null); - }), - startWith("unknown" as "unknown" | null), - ); + public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft + ? this.allOthersLeft$ + : NEVER; /** - * If some other matrix user has joined the call. It can start with true if there are already multiple matrix users. + * Emits whenever the RTC session tells us that it intends to ring for a given + * duration. */ - private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe( - scan( - (someoneJoined, { joinedUserIds }) => - someoneJoined || [...joinedUserIds].some((id) => id !== this.userId), - false, - ), - startWith(this.matrixUserChanges$.value.userIds.size > 1), + private readonly beginRingingForMs$ = ( + fromEvent( + this.matrixRTCSession, + MatrixRTCSessionEvent.DidSendCallNotification, + ) as Observable<[IRTCNotificationContent]> + ) + // event.lifetime is expected to be in ms + .pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0)); + + /** + * Whether some Matrix user other than ourself is joined to the call. + */ + private readonly someoneElseJoined$ = this.memberships$.pipe( + map((ms) => ms.some((m) => m.sender !== this.userId)), ); /** * The current call pickup state of the call. - * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. + * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. - * - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback. + * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "success" | null - > = this.scope.behavior( - combineLatest([ - this.notificationEventIsRingingOthers$, - this.someoneElseJoined$, - ]).pipe( - map(([isRingingOthers, someoneJoined]) => { - // Never enter waiting for answer state if the app is not configured with waitingForAnswer. - if (!this.options.shouldWaitForCallPickup) return null; - // As soon as someone joins, we can consider the call "wait for answer" successful - if (someoneJoined) return "success"; - - switch (isRingingOthers) { - case "unknown": - return "unknown"; - case "ringing": - return "ringing"; - case "ringEnded": - return "timeout"; - default: - return "timeout"; - } - }), - distinctUntilChanged(), - ), - ); + public readonly callPickupState$ = this.options.shouldWaitForCallPickup + ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">( + concat( + concat( + // We don't know if the RTC session decides to send a notify event + // yet. It will only be known once we sent our own membership and + // know we were the first one to join. + of("unknown" as const), + // Once we get the signal to begin ringing: + this.beginRingingForMs$.pipe( + take(1), + switchMap((lifetime) => + lifetime === 0 + ? // If no lifetime, skip the ring state + EMPTY + : // Ring until lifetime ms have passed + timer(lifetime).pipe( + ignoreElements(), + startWith("ringing" as const), + ), + ), + ), + // The notification lifetime has timed out, meaning ringing has + // likely stopped on all receiving clients. + of("timeout" as const), + NEVER, + ).pipe( + takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))), + ), + of("success" as const), + ), + ) + : constant(null); /** * List of MediaItems that we want to display, that are of type ScreenShare From b8acdc3cec7aa06799bbea037ec8c9fef8c42a65 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:51:35 +0200 Subject: [PATCH 019/306] Tidy/correct the dialing/ringing tests --- src/state/CallViewModel.test.ts | 108 +++++++++++--------------------- 1 file changed, 35 insertions(+), 73 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index a26fdc59..75b5e5c4 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1195,16 +1195,10 @@ test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all ot describe("shouldWaitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { // No one ever joins (only local user) withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), - }, + { remoteParticipants$: constant([]) }, (vm, rtcSession) => { // Fire a call notification at 10ms with lifetime 30ms schedule(" 10ms r", { @@ -1234,27 +1228,18 @@ describe("shouldWaitForCallPickup$", () => { }); test("ringing -> success if someone joins before timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) - const remote$ = scope.behavior( - hot("a--b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a--b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { // Notify at 5ms so we enter ringing, then success at 20ms @@ -1272,8 +1257,9 @@ describe("shouldWaitForCallPickup$", () => { }, }); - expectObservable(vm.callPickupState$).toBe("a 2ms c", { + expectObservable(vm.callPickupState$).toBe("a 4ms b 14ms c", { a: "unknown", + b: "ringing", c: "success", }); }, @@ -1286,27 +1272,18 @@ describe("shouldWaitForCallPickup$", () => { }); test("success when someone joins before we notify", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { // Join at 10ms, notify later at 20ms (state should stay success) - const remote$ = scope.behavior( - hot("a-b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a-b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a 9ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 9ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { schedule(" 20ms r", { @@ -1322,7 +1299,7 @@ describe("shouldWaitForCallPickup$", () => { ); }, }); - expectObservable(vm.callPickupState$).toBe("a 1ms b", { + expectObservable(vm.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "success", }); @@ -1336,21 +1313,15 @@ describe("shouldWaitForCallPickup$", () => { }); test("notify without lifetime -> immediate timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), - }, + {}, (vm, rtcSession) => { schedule(" 10ms r", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as { + {} as unknown as { event_id: string; } & IRTCNotificationContent, // no lifetime {} as unknown as { @@ -1373,26 +1344,17 @@ describe("shouldWaitForCallPickup$", () => { }); test("stays null when shouldWaitForCallPickup=false", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { - const remote$ = scope.behavior( - hot("a--b", { a: [], b: [aliceParticipant] }), - [], - ); - const rtc$ = scope.behavior( - hot("a--b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - [], - ); - + withTestScheduler(({ behavior, schedule, expectObservable }) => { withCallViewModel( { - remoteParticipants$: remote$, - rtcMembers$: rtc$, - connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), + remoteParticipants$: behavior("a--b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), }, (vm, rtcSession) => { schedule(" 5ms r", { From 1428df8567a0a3ce354c8371aeb7b342e3960b86 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:52:40 +0200 Subject: [PATCH 020/306] Tentatively rename URL parameter to waitForCallPickup --- src/UrlParams.ts | 10 +++++----- src/state/CallViewModel.test.ts | 14 +++++++------- src/state/CallViewModel.ts | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index a26045b5..5f43b17f 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -226,7 +226,7 @@ export interface UrlConfiguration { * - play a sound that indicates that it is awaiting an answer * - auto-dismiss the call widget once the notification lifetime expires on the receivers side. */ - shouldWaitForCallPickup: boolean; + waitForCallPickup: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -358,7 +358,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: "notification" as RTCNotificationType, autoLeaveWhenOthersLeft: false, - shouldWaitForCallPickup: false, + waitForCallPickup: false, }; switch (intent) { case UserIntent.StartNewCall: @@ -378,7 +378,7 @@ export const getUrlParams = ( ...inAppDefault, skipLobby: true, autoLeaveWhenOthersLeft: true, - shouldWaitForCallPickup: true, + waitForCallPickup: true, }; break; case UserIntent.JoinExistingCallDM: @@ -404,7 +404,7 @@ export const getUrlParams = ( returnToLobby: false, sendNotificationType: undefined, autoLeaveWhenOthersLeft: false, - shouldWaitForCallPickup: false, + waitForCallPickup: false, }; } @@ -456,7 +456,7 @@ export const getUrlParams = ( "ring", "notification", ]), - shouldWaitForCallPickup: parser.getFlag("shouldWaitForCallPickup"), + waitForCallPickup: parser.getFlag("waitForCallPickup"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 75b5e5c4..c30624ae 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1193,7 +1193,7 @@ test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all ot }); }); -describe("shouldWaitForCallPickup$", () => { +describe("waitForCallPickup$", () => { test("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ schedule, expectObservable }) => { // No one ever joins (only local user) @@ -1220,7 +1220,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1264,7 +1264,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1305,7 +1305,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); @@ -1336,14 +1336,14 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: true, + waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); - test("stays null when shouldWaitForCallPickup=false", () => { + test("stays null when waitForCallPickup=false", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { withCallViewModel( { @@ -1375,7 +1375,7 @@ describe("shouldWaitForCallPickup$", () => { }); }, { - shouldWaitForCallPickup: false, + waitForCallPickup: false, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d4b9537e..9112954f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -120,7 +120,7 @@ export interface CallViewModelOptions { * If the call is started in a way where we want it to behave like a telephone usecase * If we sent a notification event, we want the ui to show a ringing state */ - shouldWaitForCallPickup?: boolean; + waitForCallPickup?: boolean; } // How long we wait after a focus switch before showing the real participant @@ -916,7 +916,7 @@ export class CallViewModel extends ViewModel { * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly callPickupState$ = this.options.shouldWaitForCallPickup + public readonly callPickupState$ = this.options.waitForCallPickup ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">( concat( concat( From 1e8b87408cc6060d3fd4450dab74a5efc2e9f91c Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 3 Sep 2025 16:59:37 +0200 Subject: [PATCH 021/306] Document recently added URL parameters --- docs/url-params.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/url-params.md b/docs/url-params.md index 3fac185a..b2af8416 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `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. | +| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | +| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | ### Widget-only parameters From 779f792cbff3e5ffc12a5a2e6b71b3ccd8cce9cf Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 3 Sep 2025 15:39:27 -0400 Subject: [PATCH 022/306] docker compose: always pull latest image Otherwise, there is a risk of using outdated images for some services. --- dev-backend-docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index e6180710..eb11b630 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -26,6 +26,7 @@ services: livekit: image: livekit/livekit-server:latest + pull_policy: always hostname: livekit-sfu command: --dev --config /etc/livekit.yaml restart: unless-stopped @@ -45,6 +46,7 @@ services: redis: image: redis:6-alpine + pull_policy: always command: redis-server /etc/redis.conf ports: # HOST_PORT:CONTAINER_PORT @@ -57,6 +59,7 @@ services: synapse: hostname: homeserver image: docker.io/matrixdotorg/synapse:latest + pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml # Needed for rootless podman-compose such that the uid/gid mapping does @@ -87,6 +90,7 @@ services: # see backend/dev_tls_setup for how to generate the tls certs hostname: synapse.m.localhost image: nginx:latest + pull_policy: always volumes: - ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z - ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z From cd845b69d22496b8200532bedabfb45b3b5ff230 Mon Sep 17 00:00:00 2001 From: Mohammed Tayeh Date: Thu, 4 Sep 2025 11:19:37 +0300 Subject: [PATCH 023/306] Add Caddy configuration example and fix ConfigOptions.ts link (#3476) Signed-off-by: Mohammed Tayeh --- docs/self-hosting.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 4a663c3f..051bb31c 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -126,6 +126,32 @@ server { } ``` +Or Using Caddy, you can achieve this by: + +```caddy configuration file +# Route for lk-jwt-service with livekit/jwt prefix +@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz +handle @jwt_service { + uri strip_prefix /livekit/jwt + reverse_proxy http://[::1]:8080 { + header_up Host {host} + header_up X-Forwarded-Server {host} + header_up X-Real-IP {remote_addr} + header_up X-Forwarded-For {remote_addr} + } +} + +# Default route for livekit +handle { + reverse_proxy http://localhost:7880 { + header_up Host {host} + header_up X-Forwarded-Server {host} + header_up X-Real-IP {remote_addr} + header_up X-Forwarded-For {remote_addr} + } +} +``` + #### MatrixRTC backend announcement > [!IMPORTANT] @@ -214,7 +240,7 @@ server { There are currently two different config files. `.env` holds variables that are used at build time, while `public/config.json` holds variables that are used at runtime. Documentation and default values for `public/config.json` can be found -in [ConfigOptions.ts](src/config/ConfigOptions.ts). +in [ConfigOptions.ts](../src/config/ConfigOptions.ts). > [!CAUTION] > Please note configuring MatrixRTC backend via `config.json` of From c599d6eae997f61c1cf73924651b9039d0f16338 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:06:28 +0200 Subject: [PATCH 024/306] Make the CallEventAudioRenderer test stricter --- src/room/CallEventAudioRenderer.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index f51e602d..894d1d24 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -156,6 +156,7 @@ test("should not play a sound when a hand raise is retracted", () => { ]); render(); + playSound.mockClear(); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -170,7 +171,7 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(1); + expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand"); act(() => { handRaisedSubject$.next({ ["foo"]: { @@ -180,5 +181,5 @@ test("should not play a sound when a hand raise is retracted", () => { }, }); }); - expect(playSound).toHaveBeenCalledTimes(1); + expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand"); }); From 2541f810fa8a66c5e68393e380cd8398b1d72677 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:36:27 +0200 Subject: [PATCH 025/306] Ensure that non-ringing notifications lead to a null pickup state --- src/room/InCallView.tsx | 7 ++++++- src/state/CallViewModel.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 034be16d..e87072d3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -158,7 +158,8 @@ export const ActiveCall: FC = (props) => { }; }, [livekitRoom]); - const { autoLeaveWhenOthersLeft } = useUrlParams(); + const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } = + useUrlParams(); useEffect(() => { if (livekitRoom !== undefined) { @@ -171,6 +172,8 @@ export const ActiveCall: FC = (props) => { { encryptionSystem: props.e2eeSystem, autoLeaveWhenOthersLeft, + waitForCallPickup: + waitForCallPickup && sendNotificationType === "ring", }, connStateObservable$, reactionsReader.raisedHands$, @@ -190,6 +193,8 @@ export const ActiveCall: FC = (props) => { props.e2eeSystem, connStateObservable$, autoLeaveWhenOthersLeft, + sendNotificationType, + waitForCallPickup, ]); if (livekitRoom === undefined || vm === null) return null; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 5b946f09..5dbb0b9c 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -907,15 +907,17 @@ export class CallViewModel extends ViewModel { > > ).pipe( + filter( + ([notificationEvent]) => notificationEvent.notification_type === "ring", + ), map(([notificationEvent]) => { - // event.lifetime is expected to be in ms - const lifetime = notificationEvent?.lifetime ?? 0; + const lifetimeMs = notificationEvent?.lifetime ?? 0; return concat( - lifetime === 0 + lifetimeMs === 0 ? // If no lifetime, skip the ring state EMPTY : // Ring until lifetime ms have passed - timer(lifetime).pipe( + timer(lifetimeMs).pipe( ignoreElements(), startWith("ringing" as const), ), From 1193a22658ad6370833179c2eb4b2304449f9b71 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:48:36 +0200 Subject: [PATCH 026/306] Fix tests --- src/state/CallViewModel.test.ts | 102 +++++++++++--------------------- 1 file changed, 35 insertions(+), 67 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b7c2bd32..938109cb 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -245,6 +245,21 @@ function summarizeLayout$(l$: Observable): Observable { ); } +function mockRingEvent( + eventId: string, + lifetimeMs: number | undefined, +): { event_id: string } & IRTCNotificationContent { + return { + event_id: eventId, + ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), + notification_type: "ring", + } as { event_id: string } & IRTCNotificationContent; +} + +// The app doesn't really care about the content of these legacy events, we just +// need a value to fill in for them when emitting notifications +const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; + interface CallViewModelInputs { remoteParticipants$: Behavior; rtcMembers$: Behavior[]>; @@ -1213,12 +1228,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif1", lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif1" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif1", 30), + mockLegacyRingEvent, ); }, }); @@ -1257,15 +1268,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { - event_id: "$notif2", - lifetime: 100, - } as unknown as IRTCNotificationContent & { - event_id: string; - }, - { event_id: "$notif2" } as unknown as ICallNotifyContent & { - event_id: string; - }, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, ); }, }); @@ -1303,12 +1307,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif3", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif3" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif3", 50), + mockLegacyRingEvent, ); }, }); @@ -1334,12 +1334,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif4" } as unknown as { - event_id: string; - } & IRTCNotificationContent, // no lifetime - { event_id: "$notif4" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif4", undefined), + mockLegacyRingEvent, ); }, }); @@ -1374,12 +1370,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif5", lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif5" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif5", 30), + mockLegacyRingEvent, ); }, }); @@ -1396,25 +1388,17 @@ describe("waitForCallPickup$", () => { }); test("decline before timeout window ends -> decline", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (vm, rtcSession) => { // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id schedule(" 10ms r 29ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$decl1", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$decl1" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$decl1", 50), + mockLegacyRingEvent, ); }, d: () => { @@ -1454,23 +1438,15 @@ describe("waitForCallPickup$", () => { test("decline after timeout window ends -> stays timeout", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (vm, rtcSession) => { // Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms schedule(" 10ms r 20ms t 10ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$decl2", lifetime: 20 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$decl2" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$decl2", 20), + mockLegacyRingEvent, ); }, t: () => {}, @@ -1502,23 +1478,15 @@ describe("waitForCallPickup$", () => { test("decline with wrong id is ignored (stays ringing)", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (vm, rtcSession) => { // Notify at 10ms with id A, decline arrives at 20ms with id B schedule(" 10ms r 10ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$right", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$right" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$right", 50), + mockLegacyRingEvent, ); }, d: () => { From 1e32b355ce2640990abae4a87b5f3c100295d011 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 21:22:32 +0200 Subject: [PATCH 027/306] Ignore decline events from the local user --- src/state/CallViewModel.test.ts | 29 ++++++++++++++++++++++++----- src/state/CallViewModel.ts | 3 ++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 938109cb..4b5e603f 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -26,6 +26,7 @@ import { MatrixEvent, type IRoomTimelineData, EventType, + type IEvent, } from "matrix-js-sdk"; import { ConnectionState, @@ -248,12 +249,14 @@ function summarizeLayout$(l$: Observable): Observable { function mockRingEvent( eventId: string, lifetimeMs: number | undefined, + sender = local.userId, ): { event_id: string } & IRTCNotificationContent { return { event_id: eventId, ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), notification_type: "ring", - } as { event_id: string } & IRTCNotificationContent; + sender, + } as unknown as { event_id: string } & IRTCNotificationContent; } // The app doesn't really care about the content of these legacy events, we just @@ -1436,7 +1439,7 @@ describe("waitForCallPickup$", () => { }); test("decline after timeout window ends -> stays timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { @@ -1475,8 +1478,8 @@ describe("waitForCallPickup$", () => { }); }); - test("decline with wrong id is ignored (stays ringing)", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + function testStaysRinging(declineEvent: Partial): void { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { @@ -1492,7 +1495,7 @@ describe("waitForCallPickup$", () => { d: () => { rtcSession.room.emit( MatrixRoomEvent.Timeline, - new MatrixEvent({ event_id: "$wrong", type: "m.rtc.decline" }), + new MatrixEvent(declineEvent), rtcSession.room, undefined, false, @@ -1512,6 +1515,22 @@ describe("waitForCallPickup$", () => { }, ); }); + } + + test("decline with wrong id is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$wrong", + type: "m.rtc.decline", + sender: local.userId, + }); + }); + + test("decline with sender being the local user is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$right", + type: "m.rtc.decline", + sender: alice.userId, + }); }); }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 5dbb0b9c..3802d6dd 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -936,7 +936,8 @@ export class CallViewModel extends ViewModel { ([event]) => event.getType() === EventType.RTCDecline && event.getRelation()?.rel_type === "m.reference" && - event.getRelation()?.event_id === notificationEvent.event_id, + event.getRelation()?.event_id === notificationEvent.event_id && + event.getSender() !== this.userId, ), ), ), From d85cf5f929411922b6a2aeefb70ce93a78bc98bc Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 14:21:38 +0200 Subject: [PATCH 028/306] Fix the reconnect button After a membership manager error, clicking the 'reconnect' button did nothing. This is because we were forgetting to clear the external error state, causing it to transition directly back to the same error state. --- src/room/GroupCallErrorBoundary.tsx | 10 ++++++---- src/room/GroupCallView.test.tsx | 17 +++++++++++++++-- src/room/GroupCallView.tsx | 5 +++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 3d55d005..72958683 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -36,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; -export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void; +export type RecoveryActionHandler = ( + action: CallErrorRecoveryAction, +) => Promise; interface ErrorPageProps { error: ElementCallError; @@ -71,7 +73,7 @@ const ErrorPage: FC = ({ if (error instanceof ConnectionLostError) { actions.push({ label: t("call_ended_view.reconnect_button"), - onClick: () => recoveryActionHandler("reconnect"), + onClick: () => void recoveryActionHandler("reconnect"), }); } @@ -131,9 +133,9 @@ export const GroupCallErrorBoundary = ({ widget={widget ?? null} error={callError} resetError={resetError} - recoveryActionHandler={(action: CallErrorRecoveryAction) => { + recoveryActionHandler={async (action: CallErrorRecoveryAction) => { + await recoveryActionHandler(action); resetError(); - recoveryActionHandler(action); }} /> ); diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 12dfdf61..a1aa9452 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -15,11 +15,14 @@ import { } from "vitest"; import { render, waitFor, screen } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { + MatrixRTCSessionEvent, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { useState } from "react"; +import { act, useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MuteStates } from "./MuteStates"; @@ -258,3 +261,13 @@ test("GroupCallView shows errors that occur during joining", async () => { await user.click(screen.getByRole("button", { name: "Join call" })); screen.getByText("Call is not supported"); }); + +test("user can reconnect after a membership manager error", async () => { + const user = userEvent.setup(); + const { rtcSession } = createGroupCallView(null, true); + await act(() => + rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), + ); + await user.click(screen.getByRole("button", { name: "Reconnect" })); + await waitFor(() => screen.getByRole("button", { name: "Leave" })); +}); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 63fc942f..dd6db229 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -502,10 +502,11 @@ export const GroupCallView: FC = ({ return ( { + recoveryActionHandler={async (action) => { + setExternalError(null); if (action == "reconnect") { setLeft(false); - enterRTCSessionOrError(rtcSession).catch((e) => { + await enterRTCSessionOrError(rtcSession).catch((e) => { logger.error("Error re-entering RTC session", e); }); } From db04cbfbfcaef5dfbf1035adc2c954a1901f6c82 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 14:58:46 +0200 Subject: [PATCH 029/306] Fix type error --- src/room/GroupCallErrorBoundary.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index f2a10bc2..51912956 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () = const WrapComponent = (): ReactNode => { const [failState, setFailState] = useState(true); const reconnectCallback = useCallback( - (action: CallErrorRecoveryAction) => { + async (action: CallErrorRecoveryAction) => { reconnectCallbackSpy(action); setFailState(false); + return Promise.resolve(); }, [setFailState], ); From 34a8977dd15689fc4919901f334a8622f2e4cce3 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 8 Sep 2025 15:24:46 +0200 Subject: [PATCH 030/306] Increase timeout to hopefully avoid test flakes --- src/room/GroupCallView.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a1aa9452..247f4461 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -269,5 +269,8 @@ test("user can reconnect after a membership manager error", async () => { rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); await user.click(screen.getByRole("button", { name: "Reconnect" })); - await waitFor(() => screen.getByRole("button", { name: "Leave" })); + // In-call controls should be visible again + await waitFor(() => screen.getByRole("button", { name: "Leave" }), { + timeout: 3000, + }); }); From 8311b1619c2cbcec29b756bf30a7b6f5d03cd347 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 02:28:00 +0000 Subject: [PATCH 031/306] Update dependency vite to v6.3.6 [SECURITY] --- yarn.lock | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 274 insertions(+), 13 deletions(-) diff --git a/yarn.lock b/yarn.lock index 13e9770b..30f194ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4510,6 +4510,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-android-arm64@npm:4.41.1" @@ -4517,6 +4524,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-android-arm64@npm:4.50.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-darwin-arm64@npm:4.41.1" @@ -4524,6 +4538,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-darwin-x64@npm:4.41.1" @@ -4531,6 +4552,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-darwin-x64@npm:4.50.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-arm64@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-freebsd-arm64@npm:4.41.1" @@ -4538,6 +4566,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-freebsd-x64@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-freebsd-x64@npm:4.41.1" @@ -4545,6 +4580,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-freebsd-x64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1" @@ -4552,6 +4594,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1" @@ -4559,6 +4608,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.41.1" @@ -4566,6 +4622,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.41.1" @@ -4573,6 +4636,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1" @@ -4580,6 +4650,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1" @@ -4587,6 +4664,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1" @@ -4594,6 +4678,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.41.1" @@ -4601,6 +4692,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.41.1" @@ -4608,6 +4706,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.41.1" @@ -4615,6 +4720,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-linux-x64-musl@npm:4.41.1" @@ -4622,6 +4734,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.41.1" @@ -4629,6 +4755,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.41.1" @@ -4636,6 +4769,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.41.1": version: 4.41.1 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.41.1" @@ -4643,6 +4783,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.50.1": + version: 4.50.1 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -5155,6 +5302,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + "@types/estree@npm:^1.0.0": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" @@ -8483,6 +8637,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + "fflate@npm:^0.4.8": version: 0.4.8 resolution: "fflate@npm:0.4.8" @@ -11097,6 +11263,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.3 + resolution: "picomatch@npm:4.0.3" + checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 + languageName: node + linkType: hard + "playwright-core@npm:1.54.1": version: 1.54.1 resolution: "playwright-core@npm:1.54.1" @@ -11523,7 +11696,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.41, postcss@npm:^8.5.3": +"postcss@npm:^8.4.41, postcss@npm:^8.5.3, postcss@npm:^8.5.6": version: 8.5.6 resolution: "postcss@npm:8.5.6" dependencies: @@ -12251,7 +12424,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.34.9, rollup@npm:^4.40.0": +"rollup@npm:^4.34.9": version: 4.41.1 resolution: "rollup@npm:4.41.1" dependencies: @@ -12326,6 +12499,84 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.43.0": + version: 4.50.1 + resolution: "rollup@npm:4.50.1" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.50.1" + "@rollup/rollup-android-arm64": "npm:4.50.1" + "@rollup/rollup-darwin-arm64": "npm:4.50.1" + "@rollup/rollup-darwin-x64": "npm:4.50.1" + "@rollup/rollup-freebsd-arm64": "npm:4.50.1" + "@rollup/rollup-freebsd-x64": "npm:4.50.1" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.50.1" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.50.1" + "@rollup/rollup-linux-arm64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-arm64-musl": "npm:4.50.1" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-riscv64-musl": "npm:4.50.1" + "@rollup/rollup-linux-s390x-gnu": "npm:4.50.1" + "@rollup/rollup-linux-x64-gnu": "npm:4.50.1" + "@rollup/rollup-linux-x64-musl": "npm:4.50.1" + "@rollup/rollup-openharmony-arm64": "npm:4.50.1" + "@rollup/rollup-win32-arm64-msvc": "npm:4.50.1" + "@rollup/rollup-win32-ia32-msvc": "npm:4.50.1" + "@rollup/rollup-win32-x64-msvc": "npm:4.50.1" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loongarch64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205 + languageName: node + linkType: hard + "rrweb-cssom@npm:^0.8.0": version: 0.8.0 resolution: "rrweb-cssom@npm:0.8.0" @@ -13168,6 +13419,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + languageName: node + linkType: hard + "tinypool@npm:^1.1.1": version: 1.1.1 resolution: "tinypool@npm:1.1.1" @@ -13887,16 +14148,16 @@ __metadata: linkType: hard "vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": - version: 7.0.0-beta.0 - resolution: "vite@npm:7.0.0-beta.0" + version: 7.1.5 + resolution: "vite@npm:7.1.5" dependencies: esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.4" + fdir: "npm:^6.5.0" fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.2" - postcss: "npm:^8.5.3" - rollup: "npm:^4.40.0" - tinyglobby: "npm:^0.2.14" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 jiti: ">=1.21.0" @@ -13937,13 +14198,13 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/d13907d67b4991a2862dafe6a31d10ffe28f26ba04e511049e9d86d06293b3a8d6733c896c8fb38e3f2d5805d240e3cad27700f3c42536602035e4c324b48d58 + checksum: 10c0/782d2f20c25541b26d1fb39bef5f194149caff39dc25b7836e25f049ca919f2e2ce186bddb21f3f20f6195354b3579ec637a8ca08d65b117f8b6f81e3e730a9c languageName: node linkType: hard "vite@npm:^6.0.0": - version: 6.3.5 - resolution: "vite@npm:6.3.5" + version: 6.3.6 + resolution: "vite@npm:6.3.6" dependencies: esbuild: "npm:^0.25.0" fdir: "npm:^6.4.4" @@ -13992,7 +14253,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c + checksum: 10c0/add701f1e72596c002275782e38d0389ab400c1be330c93a3009804d62db68097a936ca1c53c3301df3aaacfe5e328eab547060f31ef9c49a277ae50df6ad4fb languageName: node linkType: hard From 8e32deaba571b601f764cbfe15916da47e02b158 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 10 Sep 2025 12:31:03 +0200 Subject: [PATCH 032/306] Upgrade to Vite v7 To remove all the duplicate Rollup dependencies. --- package.json | 2 +- yarn.lock | 285 +-------------------------------------------------- 2 files changed, 5 insertions(+), 282 deletions(-) diff --git a/package.json b/package.json index 1df79c26..18877823 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "typescript-eslint-language-service": "^5.0.5", "unique-names-generator": "^4.6.0", "vaul": "^1.0.0", - "vite": "^6.0.0", + "vite": "^7.0.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-svgr": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index 30f194ad..b2189691 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4503,13 +4503,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.41.1" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@rollup/rollup-android-arm-eabi@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1" @@ -4517,13 +4510,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-android-arm64@npm:4.41.1" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-android-arm64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-android-arm64@npm:4.50.1" @@ -4531,13 +4517,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.41.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-arm64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1" @@ -4545,13 +4524,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.41.1" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-darwin-x64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-darwin-x64@npm:4.50.1" @@ -4559,13 +4531,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.41.1" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-freebsd-arm64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1" @@ -4573,13 +4538,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-freebsd-x64@npm:4.41.1" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-freebsd-x64@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1" @@ -4587,13 +4545,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1" @@ -4601,13 +4552,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1" - conditions: os=linux & cpu=arm & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1" @@ -4615,13 +4559,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.41.1" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1" @@ -4629,13 +4566,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.41.1" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-arm64-musl@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1" @@ -4643,13 +4573,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1" @@ -4657,13 +4580,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1" @@ -4671,13 +4587,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1" @@ -4685,13 +4594,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.41.1" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-riscv64-musl@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1" @@ -4699,13 +4601,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.41.1" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-s390x-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1" @@ -4713,13 +4608,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.41.1" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-gnu@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1" @@ -4727,13 +4615,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.41.1" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@rollup/rollup-linux-x64-musl@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1" @@ -4748,13 +4629,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.41.1" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@rollup/rollup-win32-arm64-msvc@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1" @@ -4762,13 +4636,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.41.1" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@rollup/rollup-win32-ia32-msvc@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1" @@ -4776,13 +4643,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.41.1": - version: 4.41.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.41.1" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@rollup/rollup-win32-x64-msvc@npm:4.50.1": version: 4.50.1 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1" @@ -5295,13 +5155,6 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.7": - version: 1.0.7 - resolution: "@types/estree@npm:1.0.7" - checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c - languageName: node - linkType: hard - "@types/estree@npm:1.0.8": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -7712,7 +7565,7 @@ __metadata: typescript-eslint-language-service: "npm:^5.0.5" unique-names-generator: "npm:^4.6.0" vaul: "npm:^1.0.0" - vite: "npm:^6.0.0" + vite: "npm:^7.0.0" vite-plugin-generate-file: "npm:^0.3.0" vite-plugin-html: "npm:^3.2.2" vite-plugin-svgr: "npm:^4.0.0" @@ -11696,7 +11549,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.41, postcss@npm:^8.5.3, postcss@npm:^8.5.6": +"postcss@npm:^8.4.41, postcss@npm:^8.5.6": version: 8.5.6 resolution: "postcss@npm:8.5.6" dependencies: @@ -12424,81 +12277,6 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.34.9": - version: 4.41.1 - resolution: "rollup@npm:4.41.1" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.41.1" - "@rollup/rollup-android-arm64": "npm:4.41.1" - "@rollup/rollup-darwin-arm64": "npm:4.41.1" - "@rollup/rollup-darwin-x64": "npm:4.41.1" - "@rollup/rollup-freebsd-arm64": "npm:4.41.1" - "@rollup/rollup-freebsd-x64": "npm:4.41.1" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.41.1" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.41.1" - "@rollup/rollup-linux-arm64-gnu": "npm:4.41.1" - "@rollup/rollup-linux-arm64-musl": "npm:4.41.1" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.41.1" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.41.1" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.41.1" - "@rollup/rollup-linux-riscv64-musl": "npm:4.41.1" - "@rollup/rollup-linux-s390x-gnu": "npm:4.41.1" - "@rollup/rollup-linux-x64-gnu": "npm:4.41.1" - "@rollup/rollup-linux-x64-musl": "npm:4.41.1" - "@rollup/rollup-win32-arm64-msvc": "npm:4.41.1" - "@rollup/rollup-win32-ia32-msvc": "npm:4.41.1" - "@rollup/rollup-win32-x64-msvc": "npm:4.41.1" - "@types/estree": "npm:1.0.7" - fsevents: "npm:~2.3.2" - dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-freebsd-arm64": - optional: true - "@rollup/rollup-freebsd-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": - optional: true - "@rollup/rollup-linux-arm-musleabihf": - optional: true - "@rollup/rollup-linux-arm64-gnu": - optional: true - "@rollup/rollup-linux-arm64-musl": - optional: true - "@rollup/rollup-linux-loongarch64-gnu": - optional: true - "@rollup/rollup-linux-powerpc64le-gnu": - optional: true - "@rollup/rollup-linux-riscv64-gnu": - optional: true - "@rollup/rollup-linux-riscv64-musl": - optional: true - "@rollup/rollup-linux-s390x-gnu": - optional: true - "@rollup/rollup-linux-x64-gnu": - optional: true - "@rollup/rollup-linux-x64-musl": - optional: true - "@rollup/rollup-win32-arm64-msvc": - optional: true - "@rollup/rollup-win32-ia32-msvc": - optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: 10c0/c4d5f2257320b50dc0e035e31d8d2f78d36b7015aef2f87cc984c0a1c97ffebf14337dddeb488b4b11ae798fea6486189b77e7cf677617dcf611d97db41ebfda - languageName: node - linkType: hard - "rollup@npm:^4.43.0": version: 4.50.1 resolution: "rollup@npm:4.50.1" @@ -13409,7 +13187,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14": +"tinyglobby@npm:^0.2.14": version: 0.2.14 resolution: "tinyglobby@npm:0.2.14" dependencies: @@ -14147,7 +13925,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0": version: 7.1.5 resolution: "vite@npm:7.1.5" dependencies: @@ -14202,61 +13980,6 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0": - version: 6.3.6 - resolution: "vite@npm:6.3.6" - dependencies: - esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.4" - fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.2" - postcss: "npm:^8.5.3" - rollup: "npm:^4.34.9" - tinyglobby: "npm:^0.2.13" - peerDependencies: - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: ">=1.21.0" - less: "*" - lightningcss: ^1.21.0 - sass: "*" - sass-embedded: "*" - stylus: "*" - sugarss: "*" - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10c0/add701f1e72596c002275782e38d0389ab400c1be330c93a3009804d62db68097a936ca1c53c3301df3aaacfe5e328eab547060f31ef9c49a277ae50df6ad4fb - languageName: node - linkType: hard - "vitest-axe@npm:^1.0.0-pre.3": version: 1.0.0-pre.5 resolution: "vitest-axe@npm:1.0.0-pre.5" From 32cb8541f4c688303b567c154c5123e1fbf8c86e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 10 Sep 2025 16:42:09 +0200 Subject: [PATCH 033/306] Actually fix the test flake --- src/room/GroupCallView.test.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 247f4461..ff2b89d5 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -13,7 +13,7 @@ import { test, vi, } from "vitest"; -import { render, waitFor, screen } from "@testing-library/react"; +import { render, waitFor, screen, act } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { MatrixRTCSessionEvent, @@ -22,7 +22,7 @@ import { import { BrowserRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { act, useState } from "react"; +import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MuteStates } from "./MuteStates"; @@ -268,9 +268,12 @@ test("user can reconnect after a membership manager error", async () => { await act(() => rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); - await user.click(screen.getByRole("button", { name: "Reconnect" })); + // XXX: Wrapping the following click in act() shouldn't be necessary (the + // async state update should be processed automatically by the waitFor call), + // and yet here we are. + await act(async () => + user.click(screen.getByRole("button", { name: "Reconnect" })), + ); // In-call controls should be visible again - await waitFor(() => screen.getByRole("button", { name: "Leave" }), { - timeout: 3000, - }); + await waitFor(() => screen.getByRole("button", { name: "Leave" })); }); From 65d358df58b7c1791e6992e0e6bbe7d5a80e5730 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 11 Sep 2025 14:57:26 +0100 Subject: [PATCH 034/306] Do not use preload mode by default in embedded mode (#3488) * Set preload=false by default for inApp * Pull through types for params so it's easy to document * Cleanup boolean logic * change test --- src/UrlParams.test.ts | 4 ++-- src/UrlParams.ts | 2 +- src/room/GroupCallView.tsx | 47 +++++++++++++++++--------------------- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index fbf0c095..56a3797d 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -228,7 +228,7 @@ describe("UrlParams", () => { const startNewCallDefaults = (platform: string): object => ({ confineToRoom: true, appPrompt: false, - preload: true, + preload: false, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, showControls: true, hideScreensharing: false, @@ -242,7 +242,7 @@ describe("UrlParams", () => { const joinExistingCallDefaults = (platform: string): object => ({ confineToRoom: true, appPrompt: false, - preload: true, + preload: false, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, showControls: true, hideScreensharing: false, diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 5f43b17f..f5369765 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -347,7 +347,7 @@ export const getUrlParams = ( const inAppDefault = { confineToRoom: true, appPrompt: false, - preload: true, + preload: false, header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, showControls: true, hideScreensharing: false, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 63fc942f..fe21ba62 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -50,7 +50,7 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; -import { HeaderStyle, useUrlParams } from "../UrlParams"; +import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds } from "./CallEventAudioRenderer"; @@ -83,8 +83,8 @@ interface Props { client: MatrixClient; isPasswordlessUser: boolean; confineToRoom: boolean; - preload: boolean; - skipLobby: boolean; + preload: UrlParams["preload"]; + skipLobby: UrlParams["skipLobby"]; header: HeaderStyle; rtcSession: MatrixRTCSession; isJoined: boolean; @@ -276,34 +276,28 @@ export const GroupCallView: FC = ({ }; if (skipLobby) { - if (widget) { - if (preload) { - // In preload mode without lobby we wait for a join action before entering - const onJoin = (ev: CustomEvent): void => { - (async (): Promise => { - await defaultDeviceSetup( - ev.detail.data as unknown as JoinCallData, - ); - await enterRTCSessionOrError(rtcSession); - widget.api.transport.reply(ev.detail, {}); - })().catch((e) => { - logger.error("Error joining RTC session", e); - }); - }; - widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); - return (): void => { - widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); - }; - } else { - // No lobby and no preload: we enter the rtc session right away + if (widget && preload) { + // In preload mode without lobby we wait for a join action before entering + const onJoin = (ev: CustomEvent): void => { (async (): Promise => { + await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); await enterRTCSessionOrError(rtcSession); + widget.api.transport.reply(ev.detail, {}); })().catch((e) => { - logger.error("Error joining RTC session", e); + logger.error("Error joining RTC session on preload", e); }); - } + }; + widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); + return (): void => { + widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); + }; } else { - void enterRTCSessionOrError(rtcSession); + // No lobby and no preload: we enter the rtc session right away + (async (): Promise => { + await enterRTCSessionOrError(rtcSession); + })().catch((e) => { + logger.error("Error joining RTC session immediately", e); + }); } } }, [ @@ -494,6 +488,7 @@ export const GroupCallView: FC = ({ // Left in widget mode: body = returnToLobby ? lobbyView : null; } else if (preload || skipLobby) { + // The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called. body = null; } else { body = lobbyView; From 76465d0e631c35518d2411dc4be67abf71c45354 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:58:16 +0200 Subject: [PATCH 035/306] Add "wait for pickup" overlay with sound. Leave on decline/timeout (#3489) * add wait for pickup overlay Signed-off-by: Timo K * refactor and leave logic Signed-off-by: Timo K * recursive play sound logic Signed-off-by: Timo K * review Signed-off-by: Timo K * text color Signed-off-by: Timo K * overlay styling and interval fixes Signed-off-by: Timo K * fix permissions and styling Signed-off-by: Timo K * fix always getting pickup sound Signed-off-by: Timo K --------- Signed-off-by: Timo K --- src/room/InCallView.tsx | 101 +++++++++++++++++++++++++++++ src/room/WaitingForJoin.module.css | 48 ++++++++++++++ src/widget.ts | 1 + 3 files changed, 150 insertions(+) create mode 100644 src/room/WaitingForJoin.module.css diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e87072d3..c1244a2a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -50,6 +50,7 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; +import overlayStyles from "../Overlay.module.css"; import { GridTile } from "../tile/GridTile"; import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -112,6 +113,15 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { Toast } from "../Toast.tsx"; +import { Avatar, Size as AvatarSize } from "../Avatar"; +import waitingStyles from "./WaitingForJoin.module.css"; +import { prefetchSounds } from "../soundUtils"; +import { useAudioContext } from "../useAudioContext"; +// TODO: Dont use this!!! use the correct sound +import genericSoundOgg from "../sound/reactions/generic.ogg?url"; +import genericSoundMp3 from "../sound/reactions/generic.mp3?url"; +import leftCallSoundMp3 from "../sound/left_call.mp3"; +import leftCallSoundOgg from "../sound/left_call.ogg"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -265,6 +275,26 @@ export const InCallView: FC = ({ }); const muteAllAudio = useBehavior(muteAllAudio$); + // Call pickup state and display names are needed for waiting overlay/sounds + const callPickupState = useBehavior(vm.callPickupState$); + + // Preload a waiting and decline sounds + const pickupPhaseSoundCache = useInitial(async () => { + return prefetchSounds({ + waiting: { mp3: genericSoundMp3, ogg: genericSoundOgg }, + decline: { mp3: leftCallSoundMp3, ogg: leftCallSoundOgg }, + // Do we want a timeout sound? + }); + }); + // configure this to sth that fits to the pickup waiting sound. + // 1600 is in sync with the animation. + const PICKUP_SOUND_INTERVAL = 1600; + + const pickupPhaseAudio = useAudioContext({ + sounds: pickupPhaseSoundCache, + latencyHint: "interactive", + muted: muteAllAudio, + }); // This seems like it might be enough logic to use move it into the call view model? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -328,6 +358,76 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); useSubscription(vm.autoLeave$, onLeave); + // When we enter timeout or decline we will leave the call. + useEffect((): void | (() => void) => { + if (callPickupState === "timeout") { + onLeave(); + } + if (callPickupState === "decline") { + // Wait for the sound to finish before leaving + void pickupPhaseAudio + ?.playSound("decline") + .catch((e) => { + logger.error("Failed to play decline sound", e); + }) + .finally(() => { + onLeave(); + }); + } + }, [callPickupState, onLeave, pickupPhaseAudio]); + + // When waiting for pickup, loop a waiting sound + useEffect((): void | (() => void) => { + if (callPickupState !== "ringing") return; + const interval = window.setInterval(() => { + void pickupPhaseAudio?.playSound("waiting"); + }, PICKUP_SOUND_INTERVAL); + return (): void => window.clearInterval(interval); + }, [callPickupState, pickupPhaseAudio]); + + // Waiting UI overlay + const waitingOverlay: JSX.Element | null = useMemo(() => { + // No overlay if not in ringing state + if (callPickupState !== "ringing") return null; + + // Use room state for other participants data (the one that we likely want to reach) + const roomOthers = [ + ...matrixRoom.getMembersWithMembership("join"), + ...matrixRoom.getMembersWithMembership("invite"), + ].filter((m) => m.userId !== client.getUserId()); + // Yield if there are not other members in the room. + if (roomOthers.length === 0) return null; + + const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined; + const isOneOnOne = roomOthers.length === 1 && otherMember; + const text = isOneOnOne + ? `Waiting for ${otherMember.name ?? otherMember.userId} to join…` + : "Waiting for other participants…"; + const avatarMxc = isOneOnOne + ? (otherMember.getMxcAvatarUrl?.() ?? undefined) + : (matrixRoom.getMxcAvatarUrl() ?? undefined); + + return ( +
+
+
+ +
+ + {text} + +
+
+ ); + }, [callPickupState, client, matrixRoom]); + // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported // in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility @@ -806,6 +906,7 @@ export const InCallView: FC = ({ onBackToVideoPressed={audioOutputSwitcher?.switch} /> + {waitingOverlay} {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css new file mode 100644 index 00000000..8f7ebeec --- /dev/null +++ b/src/room/WaitingForJoin.module.css @@ -0,0 +1,48 @@ +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.pulse { + position: relative; + height: 90px; +} + +.pulse::before { + content: ""; + position: absolute; + inset: -12px; + border-radius: 9999px; + border: 12px solid rgba(255, 255, 255, 0.6); + animation: pulse 1.6s ease-out infinite; +} + +.text { + color: var(--cpd-color-text-on-solid-primary); +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + opacity: 0.7; + } + 70% { + transform: scale(1.15); + opacity: 0.15; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} diff --git a/src/widget.ts b/src/widget.ts index 61bf3850..60163c7c 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -116,6 +116,7 @@ export const widget = ((): WidgetHelpers | null => { EventType.Reaction, EventType.RoomRedaction, ElementCallReactionEventType, + EventType.RTCDecline, ]; const sendState = [ From e201258af33107ac6accb8dd4baecd794f692bab Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 15 Sep 2025 15:41:15 +0100 Subject: [PATCH 036/306] Add sounds for ringing (#3490) * add wait for pickup overlay Signed-off-by: Timo K * refactor and leave logic Signed-off-by: Timo K * recursive play sound logic Signed-off-by: Timo K * review Signed-off-by: Timo K * text color Signed-off-by: Timo K * overlay styling and interval fixes Signed-off-by: Timo K * fix permissions and styling Signed-off-by: Timo K * fix always getting pickup sound Signed-off-by: Timo K * Add sound effects for declined,timeout and ringtone * better ringtone * Integrate sounds * Ensure leave sound does not play * Remove unused blocked sound * fix test * Improve tests * Loop ring sound inside Audio context for better perf. * lint * better ringtone * Update to delay ringtone logic. * lint + fix test * Tidy up ring sync and add comments. * lint * Refactor onLeave to take a sound so we don't need to repeat the sound * fix import --------- Signed-off-by: Timo K Co-authored-by: Timo K --- src/room/CallEventAudioRenderer.test.tsx | 16 +++++ src/room/CallEventAudioRenderer.tsx | 14 ++++ src/room/GroupCallView.test.tsx | 7 +- src/room/GroupCallView.tsx | 12 +++- src/room/InCallView.tsx | 70 +++++++++++--------- src/room/ReactionAudioRenderer.test.tsx | 2 + src/room/WaitingForJoin.module.css | 19 +++++- src/sound/blocked.mp3 | Bin 8403 -> 0 bytes src/sound/blocked.ogg | Bin 6863 -> 0 bytes src/sound/call_declined.mp3 | Bin 0 -> 8601 bytes src/sound/call_declined.ogg | Bin 0 -> 11597 bytes src/sound/call_timeout.mp3 | Bin 0 -> 8025 bytes src/sound/call_timeout.ogg | Bin 0 -> 11231 bytes src/sound/ringtone.mp3 | Bin 0 -> 7593 bytes src/sound/ringtone.ogg | Bin 0 -> 11425 bytes src/state/CallViewModel.ts | 34 ++++++---- src/useAudioContext.tsx | 80 ++++++++++++++++++++++- src/utils/test-viewmodel.ts | 7 +- 18 files changed, 207 insertions(+), 54 deletions(-) delete mode 100644 src/sound/blocked.mp3 delete mode 100644 src/sound/blocked.ogg create mode 100644 src/sound/call_declined.mp3 create mode 100644 src/sound/call_declined.ogg create mode 100644 src/sound/call_timeout.mp3 create mode 100644 src/sound/call_timeout.ogg create mode 100644 src/sound/ringtone.mp3 create mode 100644 src/sound/ringtone.ogg diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 894d1d24..40b79da4 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -54,6 +54,8 @@ beforeEach(() => { playSound = vitest.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), + soundDuration: {}, }); }); @@ -105,6 +107,20 @@ test("plays a sound when a user leaves", () => { expect(playSound).toBeCalledWith("left"); }); +test("does not play a sound before the call is successful", () => { + const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment( + [local, alice], + [localRtcMember], + { waitForCallPickup: true }, + ); + render(); + + act(() => { + rtcMemberships$.next([localRtcMember]); + }); + expect(playSound).not.toBeCalledWith("left"); +}); + test("plays no sound when the participant list is more than the maximum size", () => { const mockRtcMemberships: CallMembership[] = [localRtcMember]; for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) { diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index 797501b6..23997c37 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -16,6 +16,10 @@ import handSoundOgg from "../sound/raise_hand.ogg"; import handSoundMp3 from "../sound/raise_hand.mp3"; import screenShareStartedOgg from "../sound/screen_share_started.ogg"; import screenShareStartedMp3 from "../sound/screen_share_started.mp3"; +import declineMp3 from "../sound/call_declined.mp3?url"; +import declineOgg from "../sound/call_declined.ogg?url"; +import timeoutMp3 from "../sound/call_timeout.mp3?url"; +import timeoutOgg from "../sound/call_timeout.ogg?url"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { useLatest } from "../useLatest"; @@ -37,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({ mp3: screenShareStartedMp3, ogg: screenShareStartedOgg, }, + decline: { + mp3: declineMp3, + ogg: declineOgg, + }, + timeout: { + mp3: timeoutMp3, + ogg: timeoutOgg, + }, }); +export type CallEventSounds = keyof Awaited; + export function CallEventAudioRenderer({ vm, muted, diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index ff2b89d5..084c06ec 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -12,6 +12,7 @@ import { onTestFinished, test, vi, + vitest, } from "vitest"; import { render, waitFor, screen, act } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; @@ -97,13 +98,15 @@ beforeEach(() => { playSound = vi.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vi.fn(), + soundDuration: {}, }); // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. (ActiveCall as MockedFunction).mockImplementation( ({ onLeave }) => { return (
- +
); }, @@ -209,6 +212,8 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async () ); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), + soundDuration: {}, }); const { getByText, rtcSession } = createGroupCallView( diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 18ec1a6a..dbc3ea18 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -53,7 +53,10 @@ import { InviteModal } from "./InviteModal"; import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; -import { callEventAudioSounds } from "./CallEventAudioRenderer"; +import { + callEventAudioSounds, + type CallEventSounds, +} from "./CallEventAudioRenderer"; import { useLatest } from "../useLatest"; import { usePageTitle } from "../usePageTitle"; import { @@ -317,8 +320,11 @@ export const GroupCallView: FC = ({ const navigate = useNavigate(); const onLeave = useCallback( - (cause: "user" | "error" = "user"): void => { - const audioPromise = leaveSoundContext.current?.playSound("left"); + ( + cause: "user" | "error" = "user", + playSound: CallEventSounds = "left", + ): void => { + const audioPromise = leaveSoundContext.current?.playSound(playSound); // In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // therefore we want the event to be sent instantly without getting queued/batched. const sendInstantly = !!widget; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index c1244a2a..3d7044be 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -95,7 +95,10 @@ import { } from "../reactions/useReactionsSender"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsOverlay } from "./ReactionsOverlay"; -import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; +import { + CallEventAudioRenderer, + type CallEventSounds, +} from "./CallEventAudioRenderer"; import { debugTileLayout as debugTileLayoutSetting, useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, @@ -117,11 +120,8 @@ import { Avatar, Size as AvatarSize } from "../Avatar"; import waitingStyles from "./WaitingForJoin.module.css"; import { prefetchSounds } from "../soundUtils"; import { useAudioContext } from "../useAudioContext"; -// TODO: Dont use this!!! use the correct sound -import genericSoundOgg from "../sound/reactions/generic.ogg?url"; -import genericSoundMp3 from "../sound/reactions/generic.mp3?url"; -import leftCallSoundMp3 from "../sound/left_call.mp3"; -import leftCallSoundOgg from "../sound/left_call.ogg"; +import ringtoneMp3 from "../sound/ringtone.mp3?url"; +import ringtoneOgg from "../sound/ringtone.ogg?url"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -232,7 +232,7 @@ export interface InCallViewProps { livekitRoom: LivekitRoom; muteStates: MuteStates; /** Function to call when the user explicitly ends the call */ - onLeave: () => void; + onLeave: (cause: "user", soundFile?: CallEventSounds) => void; header: HeaderStyle; otelGroupCallMembership?: OTelGroupCallMembership; connState: ECConnectionState; @@ -281,14 +281,9 @@ export const InCallView: FC = ({ // Preload a waiting and decline sounds const pickupPhaseSoundCache = useInitial(async () => { return prefetchSounds({ - waiting: { mp3: genericSoundMp3, ogg: genericSoundOgg }, - decline: { mp3: leftCallSoundMp3, ogg: leftCallSoundOgg }, - // Do we want a timeout sound? + waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg }, }); }); - // configure this to sth that fits to the pickup waiting sound. - // 1600 is in sync with the animation. - const PICKUP_SOUND_INTERVAL = 1600; const pickupPhaseAudio = useAudioContext({ sounds: pickupPhaseSoundCache, @@ -356,34 +351,47 @@ export const InCallView: FC = ({ const showFooter = useBehavior(vm.showFooter$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - useSubscription(vm.autoLeave$, onLeave); + useSubscription(vm.autoLeave$, () => onLeave("user")); + + // We need to set the proper timings on the animation based upon the sound length. + const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; + useEffect((): (() => void) => { + // The CSS animation includes the delay, so we must double the length of the sound. + window.document.body.style.setProperty( + "--call-ring-duration-s", + `${ringDuration * 2}s`, + ); + window.document.body.style.setProperty( + "--call-ring-delay-s", + `${ringDuration}s`, + ); + // Remove properties when we unload. + return () => { + window.document.body.style.removeProperty("--call-ring-duration-s"); + window.document.body.style.removeProperty("--call-ring-delay-s"); + }; + }, [pickupPhaseAudio?.soundDuration, ringDuration]); // When we enter timeout or decline we will leave the call. useEffect((): void | (() => void) => { if (callPickupState === "timeout") { - onLeave(); + onLeave("user", "timeout"); } if (callPickupState === "decline") { - // Wait for the sound to finish before leaving - void pickupPhaseAudio - ?.playSound("decline") - .catch((e) => { - logger.error("Failed to play decline sound", e); - }) - .finally(() => { - onLeave(); - }); + onLeave("user", "decline"); } }, [callPickupState, onLeave, pickupPhaseAudio]); // When waiting for pickup, loop a waiting sound useEffect((): void | (() => void) => { - if (callPickupState !== "ringing") return; - const interval = window.setInterval(() => { - void pickupPhaseAudio?.playSound("waiting"); - }, PICKUP_SOUND_INTERVAL); - return (): void => window.clearInterval(interval); - }, [callPickupState, pickupPhaseAudio]); + if (callPickupState !== "ringing" || !pickupPhaseAudio) return; + const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration); + return () => { + void endSound().catch((e) => { + logger.error("Failed to stop ringing sound", e); + }); + }; + }, [callPickupState, pickupPhaseAudio, ringDuration]); // Waiting UI overlay const waitingOverlay: JSX.Element | null = useMemo(() => { @@ -823,7 +831,7 @@ export const InCallView: FC = ({ { playSound = vitest.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), + soundDuration: {}, }); }); diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css index 8f7ebeec..a598e482 100644 --- a/src/room/WaitingForJoin.module.css +++ b/src/room/WaitingForJoin.module.css @@ -25,7 +25,9 @@ inset: -12px; border-radius: 9999px; border: 12px solid rgba(255, 255, 255, 0.6); - animation: pulse 1.6s ease-out infinite; + animation: pulse var(--call-ring-duration-s) ease-out infinite; + animation-delay: 1s; + opacity: 0; } .text { @@ -36,13 +38,24 @@ 0% { transform: scale(0.95); opacity: 0.7; + transform: scale(0); + opacity: 1; } - 70% { + 35% { transform: scale(1.15); opacity: 0.15; } - 100% { + 50% { transform: scale(1.2); opacity: 0; } + 50.01% { + transform: scale(0); + } + 85% { + transform: scale(0); + } + 100% { + transform: scale(0); + } } diff --git a/src/sound/blocked.mp3 b/src/sound/blocked.mp3 deleted file mode 100644 index d37e5fc60e6ffd682b68e0d3f88f859d7025eb0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8403 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 Kp}h%ND*pxJyaK%d diff --git a/src/sound/call_declined.mp3 b/src/sound/call_declined.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0d0c23359961c17878cf8571c6ec0ce8ec09200d GIT binary patch literal 8601 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 literal 0 HcmV?d00001 diff --git a/src/sound/call_declined.ogg b/src/sound/call_declined.ogg new file mode 100644 index 0000000000000000000000000000000000000000..458fcd2641094230f64371d0d35eef3e1ef902f4 GIT binary patch literal 11597 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 aBYMPfHd&v~JDo}E;p<=7qDAQ7LjMI2{gN>N literal 0 HcmV?d00001 diff --git a/src/sound/ringtone.mp3 b/src/sound/ringtone.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d4c73adb2031bce41f824dc7870332f73a40ce78 GIT binary patch literal 7593 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 d&oR=vln&-nlV literal 0 HcmV?d00001 diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 3802d6dd..ca797aa8 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -49,6 +49,7 @@ import { race, scan, skip, + skipWhile, startWith, switchAll, switchMap, @@ -853,17 +854,6 @@ export class CallViewModel extends ViewModel { throttleTime(THROTTLE_SOUND_EFFECT_MS), ); - public readonly leaveSoundEffect$ = this.userMedia$.pipe( - pairwise(), - filter( - ([prev, current]) => - current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && - current.length < prev.length, - ), - map(() => {}), - throttleTime(THROTTLE_SOUND_EFFECT_MS), - ); - /** * The number of participants currently in the call. * @@ -963,7 +953,9 @@ export class CallViewModel extends ViewModel { * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - null: EC is configured to never show any waiting for answer state. */ - public readonly callPickupState$ = this.options.waitForCallPickup + public readonly callPickupState$: Behavior< + "unknown" | "ringing" | "timeout" | "decline" | "success" | null + > = this.options.waitForCallPickup ? this.scope.behavior< "unknown" | "ringing" | "timeout" | "decline" | "success" >( @@ -983,6 +975,24 @@ export class CallViewModel extends ViewModel { ) : constant(null); + public readonly leaveSoundEffect$ = combineLatest([ + this.callPickupState$, + this.userMedia$, + ]).pipe( + // Until the call is successful, do not play a leave sound. + // If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound. + skipWhile(([c]) => c !== null && c !== "success"), + map(([, userMedia]) => userMedia), + pairwise(), + filter( + ([prev, current]) => + current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && + current.length < prev.length, + ), + map(() => {}), + throttleTime(THROTTLE_SOUND_EFFECT_MS), + ); + /** * List of MediaItems that we want to display, that are of type ScreenShare */ diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index 1b00e82b..59334dda 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -32,6 +32,8 @@ async function playSound( buffer: AudioBuffer, volume: number, stereoPan: number, + delayS = 0, + abort?: AbortController, ): Promise { const gain = ctx.createGain(); gain.gain.setValueAtTime(volume, 0); @@ -39,13 +41,62 @@ async function playSound( pan.pan.setValueAtTime(stereoPan, 0); const src = ctx.createBufferSource(); src.buffer = buffer; - src.connect(gain).connect(pan).connect(ctx.destination); + abort?.signal.addEventListener("abort", () => { + src.disconnect(); + }); const p = new Promise((r) => src.addEventListener("ended", () => r())); + src.connect(gain).connect(pan).connect(ctx.destination); controls.setPlaybackStarted(); - src.start(); + src.start(ctx.currentTime + delayS); return p; } +/** + * Play a sound though a given AudioContext, looping until stopped. Will take + * care of connecting the correct buffer and gating + * through gain. + * @param volume The volume to play at. + * @param ctx The context to play through. + * @param buffer The buffer to play. + * @returns A function used to end the sound. This function will return a promise when the sound has stopped. + */ +function playSoundLooping( + ctx: AudioContext, + buffer: AudioBuffer, + volume: number, + stereoPan: number, + delayS?: number, +): () => Promise { + if (delayS === 0) { + throw Error("Looping sounds must have a delay"); + } + + // Our audio loop + let lastSoundPromise: Promise; + let nextSoundPromise: Promise; + let ac: AbortController | undefined; + void (async (): Promise => { + ac = new AbortController(); + // Play a sound immediately + lastSoundPromise = Promise.resolve(); + do { + // Queue up the next sound. + nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac); + // Await the previous sound. + await lastSoundPromise; + // Swap the promises over, and loop round to play the next sound. + lastSoundPromise = nextSoundPromise; + } while (!ac.signal.aborted); + })(); + + return async () => { + ac?.abort(); + // Wait for sounds to finish. + await lastSoundPromise; + await nextSoundPromise; + }; +} + interface Props { /** * The sounds to play. If no sounds should be played then @@ -57,8 +108,13 @@ interface Props { muted?: boolean; } -interface UseAudioContext { +interface UseAudioContext { playSound(soundName: S): Promise; + playSoundLooping(soundName: S, delayS?: number): () => Promise; + /** + * Map of sound name to duration in seconds. + */ + soundDuration: Record; } /** @@ -146,5 +202,23 @@ export function useAudioContext( earpiecePan, ); }, + playSoundLooping: (name, delayS: number): (() => Promise) => { + if (!audioBuffers[name]) { + throw Error(`Tried to play a sound that wasn't buffered (${name})`); + } + return playSoundLooping( + audioContext, + audioBuffers[name], + soundEffectVolume * earpieceVolume, + earpiecePan, + delayS, + ); + }, + soundDuration: Object.fromEntries( + Object.entries(audioBuffers).map(([k, v]) => [ + k, + (v as AudioBuffer).duration, + ]), + ), }; } diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index e5558ae2..09044e3f 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -22,7 +22,10 @@ import { } from "matrix-js-sdk"; import { E2eeType } from "../e2ee/e2eeType"; -import { CallViewModel } from "../state/CallViewModel"; +import { + CallViewModel, + type CallViewModelOptions, +} from "../state/CallViewModel"; import { mockLivekitRoom, mockMatrixRoom, @@ -122,6 +125,7 @@ export function getBasicRTCSession( export function getBasicCallViewModelEnvironment( members: RoomMember[], initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember], + callViewModelOptions: Partial = {}, ): { vm: CallViewModel; rtcMemberships$: BehaviorSubject; @@ -148,6 +152,7 @@ export function getBasicCallViewModelEnvironment( mockMediaDevices({}), { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + ...callViewModelOptions, }, of(ConnectionState.Connected), handRaisedSubject$, From 5811794f3135dc23e25f72b3bc7a9192760b6181 Mon Sep 17 00:00:00 2001 From: davidegirardi <16451191+davidegirardi@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:46:25 +0200 Subject: [PATCH 037/306] Add drum/joke reaction (#3217) * Add drum/joke reaction * Add license information * Update test snapshot * update snapshot test --------- Co-authored-by: Robin Co-authored-by: Davide Girardi Co-authored-by: fkwp --- .../ReactionToggleButton.test.tsx.snap | 2 +- src/reactions/index.ts | 11 +++++++++++ src/sound/LICENCE.md | 1 + src/sound/reactions/baduntss.mp3 | Bin 0 -> 83165 bytes src/sound/reactions/baduntss.ogg | Bin 0 -> 77921 bytes 5 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 src/sound/reactions/baduntss.mp3 create mode 100644 src/sound/reactions/baduntss.ogg diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap index 44cf92bb..139ecfab 100644 --- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap +++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap @@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = ` aria-disabled="false" aria-expanded="true" aria-haspopup="true" - aria-labelledby="«rb5»" + aria-labelledby="«rbb»" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" data-kind="primary" data-size="lg" diff --git a/src/reactions/index.ts b/src/reactions/index.ts index d3c2d9c9..acf7e181 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -27,6 +27,8 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url"; import rockSoundMp3 from "../sound/reactions/rock.mp3?url"; import waveSoundOgg from "../sound/reactions/wave.ogg?url"; import waveSoundMp3 from "../sound/reactions/wave.mp3?url"; +import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url"; +import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url"; export const ElementCallReactionEventType = "io.element.call.reaction"; @@ -191,6 +193,15 @@ export const ReactionSet: ReactionOption[] = [ mp3: waveSoundMp3, }, }, + { + emoji: "🥁", + name: "drum", + alias: ["joke"], + sound: { + ogg: baduntssSoundOgg, + mp3: baduntssSoundMp3, + }, + }, ]; export interface RaisedHandInfo { diff --git a/src/sound/LICENCE.md b/src/sound/LICENCE.md index 94e29b0e..c4f81363 100644 --- a/src/sound/LICENCE.md +++ b/src/sound/LICENCE.md @@ -23,3 +23,4 @@ The following sound effects have been originally created by Element. - `end_call` - `reactions/rock` - `reactions/wave` +- `reactions/baduntss` diff --git a/src/sound/reactions/baduntss.mp3 b/src/sound/reactions/baduntss.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4dd5abb9e2b64f3e4d8457a5d47250ebcfcebbc8 GIT binary patch literal 83165 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%0DZf>Q_$o#O#tWocWdi@8sFaUrrGHKU~ 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

GpI+V9#qIRsmAnh|NJ$;z(EJP~D^0>G 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 fCAe_ir Date: Tue, 16 Sep 2025 14:16:11 +0100 Subject: [PATCH 038/306] Prevent showing calling view when disconnected from Livekit. (#3491) * Refactor disconnection handling * Use "unknown" * Update signature * Add tests * Expose livekitConnectionState directly * fix whoopsie --- src/room/InCallView.test.tsx | 3 +-- src/room/InCallView.tsx | 19 ++++++-------- src/state/CallViewModel.test.ts | 45 +++++++++++++++++++++++++++++++++ src/state/CallViewModel.ts | 24 ++++++++++++------ 4 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index eb192f66..f20ffada 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -17,7 +17,7 @@ import { act, render, type RenderResult } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; -import { ConnectionState, type LocalParticipant } from "livekit-client"; +import { type LocalParticipant } from "livekit-client"; import { of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -180,7 +180,6 @@ function createInCallView(): RenderResult & { onLeave={function (): void { throw new Error("Function not implemented."); }} - connState={ConnectionState.Connected} onShareClick={null} /> diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3d7044be..350af973 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,11 @@ 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, useSubscription } from "observable-hooks"; +import { + useObservable, + useObservableEagerState, + useSubscription, +} from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -63,7 +67,6 @@ import { type MuteStates } from "./MuteStates"; import { type MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; -import { type ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; import { CallViewModel, @@ -212,12 +215,7 @@ export const ActiveCall: FC = (props) => { return ( - + ); @@ -235,7 +233,6 @@ export interface InCallViewProps { onLeave: (cause: "user", soundFile?: CallEventSounds) => void; header: HeaderStyle; otelGroupCallMembership?: OTelGroupCallMembership; - connState: ECConnectionState; onShareClick: (() => void) | null; } @@ -249,7 +246,6 @@ export const InCallView: FC = ({ muteStates, onLeave, header: headerStyle, - connState, onShareClick, }) => { const { t } = useTranslation(); @@ -257,10 +253,11 @@ export const InCallView: FC = ({ useReactionsSender(); useWakeLock(); + const connectionState = useObservableEagerState(vm.livekitConnectionState$); // annoyingly we don't get the disconnection reason this way, // only by listening for the emitted event - if (connState === ConnectionState.Disconnected) + if (connectionState === ConnectionState.Disconnected) throw new ConnectionLostError(); const containerRef1 = useRef(null); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 4b5e603f..b6935a9b 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1291,6 +1291,51 @@ describe("waitForCallPickup$", () => { }); }); + test("ringing -> unknown if we get disconnected", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + const connectionState$ = new BehaviorSubject(ConnectionState.Connected); + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + withCallViewModel( + { + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + connectionState$, + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then get disconnected 5ms later + schedule(" 5ms r 5ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, + ); + }, + d: () => { + connectionState$.next(ConnectionState.Disconnected); + }, + }); + + expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { + a: "unknown", + b: "ringing", + c: "unknown", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + test("success when someone joins before we notify", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Join at 10ms, notify later at 20ms (state should stay success) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index ca797aa8..8289369f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -947,6 +947,7 @@ export class CallViewModel extends ViewModel { * The current call pickup state of the call. * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. + * This may also be set if we are disconnected. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. @@ -959,13 +960,20 @@ export class CallViewModel extends ViewModel { ? this.scope.behavior< "unknown" | "ringing" | "timeout" | "decline" | "success" >( - this.someoneElseJoined$.pipe( - switchMap((someoneElseJoined) => - someoneElseJoined - ? of("success" as const) - : // Show the ringing state of the most recent ringing attempt. - this.ring$.pipe(switchAll()), - ), + combineLatest([ + this.livekitConnectionState$, + this.someoneElseJoined$, + ]).pipe( + switchMap(([livekitConnectionState, someoneElseJoined]) => { + if (livekitConnectionState === ConnectionState.Disconnected) { + // Do not ring until we're connected. + return of("unknown" as const); + } else if (someoneElseJoined) { + return of("success" as const); + } + // Show the ringing state of the most recent ringing attempt. + return this.ring$.pipe(switchAll()); + }), // The state starts as 'unknown' because we don't know if the RTC // session will actually send a notify event yet. It will only be // known once we send our own membership and see that we were the @@ -1682,7 +1690,7 @@ export class CallViewModel extends ViewModel { private readonly livekitRoom: LivekitRoom, private readonly mediaDevices: MediaDevices, private readonly options: CallViewModelOptions, - private readonly livekitConnectionState$: Observable, + public readonly livekitConnectionState$: Observable, private readonly handsRaisedSubject$: Observable< Record >, From df7bd8ff2bc6465a566491d14fd979526d77056f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:46:55 +0200 Subject: [PATCH 039/306] Update dependency livekit-client to v2.15.7 (#3496) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b2189691..5f224576 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10065,8 +10065,8 @@ __metadata: linkType: hard "livekit-client@npm:^2.13.0": - version: 2.15.6 - resolution: "livekit-client@npm:2.15.6" + version: 2.15.7 + resolution: "livekit-client@npm:2.15.7" dependencies: "@livekit/mutex": "npm:1.1.1" "@livekit/protocol": "npm:1.39.3" @@ -10079,7 +10079,7 @@ __metadata: webrtc-adapter: "npm:^9.0.1" peerDependencies: "@types/dom-mediacapture-record": ^1 - checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192 + checksum: 10c0/17ea084565a4fef31038af4ce1aa6951125dc80b78d6d320789d67bfc544d95b6b661032848221758c9ece6dab6ab0e867da683759f11b0bc65a1d94ab27d185 languageName: node linkType: hard From 4be395500fdf896ffc74b1b389b26e4912e18da1 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Sep 2025 12:58:47 +0200 Subject: [PATCH 040/306] Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486) * Fix the interactivity of buttons while reconnecting or in earpiece mode When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles. * Write a Playwright test for reconnecting * fix lints Signed-off-by: Timo K * fix test Signed-off-by: Timo K * enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU * remove rate limit for delayed events * more time to connect to livekit SFU * Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button * adapt to most recent Element Web version * Use the "End call" button as proofe for a started call * Currrenty disabled due to recent Element Web - not indicating the number of participants - bypassing Lobby * linting * disable 'can only interact with header and footer while reconnecting' for firefox --------- Signed-off-by: Timo K Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> Co-authored-by: Timo K Co-authored-by: fkwp --- backend/dev_nginx.conf | 2 + backend/playwright_homeserver.yaml | 4 ++ playwright/fixtures/widget-user.ts | 8 +-- playwright/reconnect.spec.ts | 60 +++++++++++++++++++ playwright/widget/simple-create.spec.ts | 43 +++++++------ src/Overlay.module.css | 2 + src/Toast.tsx | 12 ++-- src/reactions/RaisedHandIndicator.tsx | 3 + src/room/EarpieceOverlay.module.css | 1 - src/room/EarpieceOverlay.tsx | 2 +- src/room/InCallView.tsx | 58 +++++++++++++----- .../__snapshots__/InCallView.test.tsx.snap | 5 ++ src/tile/GridTile.test.tsx | 1 + src/tile/GridTile.tsx | 12 +++- src/tile/MediaView.test.tsx | 1 + src/tile/MediaView.tsx | 4 ++ src/tile/SpotlightTile.test.tsx | 1 + src/tile/SpotlightTile.tsx | 11 ++++ 18 files changed, 182 insertions(+), 48 deletions(-) create mode 100644 playwright/reconnect.spec.ts diff --git a/backend/dev_nginx.conf b/backend/dev_nginx.conf index a29b06d7..aadb612c 100644 --- a/backend/dev_nginx.conf +++ b/backend/dev_nginx.conf @@ -59,6 +59,8 @@ server { ssl_certificate /root/ssl/cert.pem; ssl_certificate_key /root/ssl/key.pem; + http2 on; + location ^~ /livekit/jwt/ { diff --git a/backend/playwright_homeserver.yaml b/backend/playwright_homeserver.yaml index ca45cf3f..38350a3c 100644 --- a/backend/playwright_homeserver.yaml +++ b/backend/playwright_homeserver.yaml @@ -44,6 +44,10 @@ rc_message: per_second: 10000 burst_count: 10000 +rc_delayed_event_mgmt: + per_second: 10000 + burst_count: 10000 + rc_login: address: per_second: 10000 diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index 3ccb2ab2..8089c9de 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -159,8 +159,8 @@ export const widgetTest = test.extend({ } = await registerUser(browser, userB); // Invite the second user - await ewPage1.getByRole("button", { name: "Add room" }).click(); - await ewPage1.getByText("New room").click(); + await ewPage1.getByRole("button", { name: "Add", exact: true }).click(); + await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); await ewPage1.getByRole("button", { name: "Create room" }).click(); await expect(ewPage1.getByText("You created this room.")).toBeVisible(); @@ -184,9 +184,9 @@ export const widgetTest = test.extend({ // Accept the invite await expect( - ewPage2.getByRole("treeitem", { name: "Welcome Room" }), + ewPage2.getByRole("option", { name: "Welcome Room" }), ).toBeVisible(); - await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click(); + await ewPage2.getByRole("option", { name: "Welcome Room" }).click(); await ewPage2.getByRole("button", { name: "Accept" }).click(); await expect( ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts new file mode 100644 index 00000000..3b419af4 --- /dev/null +++ b/playwright/reconnect.spec.ts @@ -0,0 +1,60 @@ +/* +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 { expect, test } from "@playwright/test"; + +// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode +test.skip( + ({ browserName }) => browserName === "firefox", + 'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode', +); + +test("can only interact with header and footer while reconnecting", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("Test call"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("Test user"); + // If we do not call fastForward here, we end up with Date.now() returning an actual timestamp + // but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0 + // and we will never get into probablyDisconnected state? + await page.clock.fastForward(10); + await page.getByTestId("home_go").click(); + + await expect(page.locator("video")).toBeVisible(); + await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // The media tile for the local user should become visible + await new Promise((resolve) => setTimeout(resolve, 1500)); + await expect(page.getByTestId("name_tag")).toContainText("Test user"); + + // Now disconnect from the internet + await page.route("https://synapse.m.localhost/**/*", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 10000)); + await route.continue(); + }); + await page.clock.fastForward(20000); + + await expect( + page.getByRole("dialog", { name: "Reconnecting…" }), + ).toBeVisible(); + + // Tab order should jump directly from header to footer, skipping media tiles + await page.getByRole("button", { name: "Mute microphone" }).focus(); + await expect( + page.getByRole("button", { name: "Mute microphone" }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused(); + // Most critically, we should be able to press the hangup button + await page.getByRole("button", { name: "End call" }).click(); +}); diff --git a/playwright/widget/simple-create.spec.ts b/playwright/widget/simple-create.spec.ts index 00d5c658..8c889892 100644 --- a/playwright/widget/simple-create.spec.ts +++ b/playwright/widget/simple-create.spec.ts @@ -49,7 +49,10 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { // Check the join indicator on the room list await expect( - brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }), + brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByRole("button", { name: "End call" }), ).toBeVisible(); // Join from the other side @@ -59,26 +62,28 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { ).toBeVisible(); await whistler.page.getByRole("button", { name: "Join" }).click(); - await expect( - whistler.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall"), - ).toBeVisible(); + // Currently disabled due to recent Element Web is bypassing Lobby + // await expect( + // whistler.page + // .locator('iframe[title="Element Call"]') + // .contentFrame() + // .getByTestId("lobby_joinCall"), + // ).toBeVisible(); + // + // await whistler.page + // .locator('iframe[title="Element Call"]') + // .contentFrame() + // .getByTestId("lobby_joinCall") + // .click(); - await whistler.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall") - .click(); + // Currrenty disabled due to recent Element Web not indicating the number of participants + // await expect( + // whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + // ).toBeVisible(); - await expect( - whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), - ).toBeVisible(); - - await expect( - brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }), - ).toBeVisible(); + // await expect( + // brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + // ).toBeVisible(); // Whistler leaves await whistler.page.waitForTimeout(1000); diff --git a/src/Overlay.module.css b/src/Overlay.module.css index fa972e6f..d711d05b 100644 --- a/src/Overlay.module.css +++ b/src/Overlay.module.css @@ -35,6 +35,8 @@ Please see LICENSE in the repository root for full details. .bg.animate[data-state="closed"] { animation: fade-out 130ms; + opacity: 0; + pointer-events: none; } .overlay { diff --git a/src/Toast.tsx b/src/Toast.tsx index 105572c8..83e220bc 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -46,11 +46,11 @@ interface Props { */ Icon?: ComponentType>; /** - * Whether the toast should be portaled into the root of the document (rather - * than rendered in-place within the component tree). + * Whether the toast should be modal, making it fill the screen (by portalling + * it into the root of the document) and trap focus until dismissed. * @default true */ - portal?: boolean; + modal?: boolean; } /** @@ -62,7 +62,7 @@ export const Toast: FC = ({ autoDismiss, children, Icon, - portal = true, + modal = true, }) => { const onOpenChange = useCallback( (open: boolean) => { @@ -103,8 +103,8 @@ export const Toast: FC = ({ ); return ( - - {portal ? {content} : content} + + {modal ? {content} : content} ); }; diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index 12974849..67d9cf16 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -22,11 +22,13 @@ export function RaisedHandIndicator({ miniature, showTimer, onClick, + tabIndex, }: { raisedHandTime?: Date; miniature?: boolean; showTimer?: boolean; onClick?: () => void; + tabIndex?: number; }): ReactNode { const { t } = useTranslation(); const [raisedHandDuration, setRaisedHandDuration] = useState(""); @@ -94,6 +96,7 @@ export function RaisedHandIndicator({ background: "none", }} onClick={clickCallback} + tabIndex={tabIndex} > {content} diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css index fd46d0e2..d0757cdb 100644 --- a/src/room/EarpieceOverlay.module.css +++ b/src/room/EarpieceOverlay.module.css @@ -34,7 +34,6 @@ .overlay[data-show="false"] { animation: fade-out 130ms forwards; - content-visibility: hidden; pointer-events: none; } diff --git a/src/room/EarpieceOverlay.tsx b/src/room/EarpieceOverlay.tsx index 14ce33cc..6835bdd7 100644 --- a/src/room/EarpieceOverlay.tsx +++ b/src/room/EarpieceOverlay.tsx @@ -20,7 +20,7 @@ interface Props { export const EarpieceOverlay: FC = ({ show, onBackToVideoPressed }) => { const { t } = useTranslation(); return ( -

+
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 350af973..348a2c44 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -54,7 +54,6 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; -import overlayStyles from "../Overlay.module.css"; import { GridTile } from "../tile/GridTile"; import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -119,6 +118,7 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { Toast } from "../Toast.tsx"; +import overlayStyles from "../Overlay.module.css"; import { Avatar, Size as AvatarSize } from "../Avatar"; import waitingStyles from "./WaitingForJoin.module.css"; import { prefetchSounds } from "../soundUtils"; @@ -641,6 +641,38 @@ export const InCallView: FC = ({ } } + // The reconnecting toast cannot be dismissed + const onDismissReconnectingToast = useCallback(() => {}, []); + // We need to use a non-modal toast to avoid trapping focus within the toast. + // However, a non-modal toast will not render any background overlay on its + // own, so we must render one manually. + const reconnectingToast = ( + <> +
+ + {t("common.reconnecting")} + + + ); + + const earpieceOverlay = ( + + ); + + // If the reconnecting toast or earpiece overlay obscures the media tiles, we + // need to remove them from the accessibility tree and block focus. + const contentObscured = reconnecting || earpieceMode; + const Tile = useMemo( () => function Tile({ @@ -670,6 +702,7 @@ export const InCallView: FC = ({ className={classNames(className, styles.tile)} style={style} showSpeakingIndicators={showSpeakingIndicatorsValue} + focusable={!contentObscured} /> ) : ( = ({ targetWidth={targetWidth} targetHeight={targetHeight} showIndicators={showSpotlightIndicatorsValue} + focusable={!contentObscured} className={classNames(className, styles.tile)} style={style} /> ); }, - [vm, openProfile], + [vm, openProfile, contentObscured], ); const layouts = useMemo(() => { @@ -714,6 +748,8 @@ export const InCallView: FC = ({ targetWidth={gridBounds.height} targetHeight={gridBounds.width} showIndicators={false} + focusable={!contentObscured} + aria-hidden={contentObscured} /> ); } @@ -731,6 +767,7 @@ export const InCallView: FC = ({ model={layout} Layout={layers.fixed} Tile={Tile} + aria-hidden={contentObscured} /> ); const scrollingGrid = ( @@ -740,6 +777,7 @@ export const InCallView: FC = ({ model={layout} Layout={layers.scrolling} Tile={Tile} + aria-hidden={contentObscured} /> ); // The grid tiles go *under* the spotlight in the portrait layout, but @@ -869,9 +907,6 @@ export const InCallView: FC = ({
); - // The reconnecting toast cannot be dismissed - const onDismissReconnectingToast = useCallback(() => {}, []); - return (
= ({ {renderContent()} - - {t("common.reconnecting")} - - + {reconnectingToast} + {earpieceOverlay} {waitingOverlay} {footer} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index bfbef499..8a2ef37e 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -111,6 +111,11 @@ exports[`InCallView > rendering > renders 1`] = `
+