diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b734c520..98e6e4c8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,8 +28,6 @@ module.exports = { rules: { "matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER], "jsx-a11y/media-has-caption": "off", - // We should use the js-sdk logger, never console directly. - "no-console": ["error"], "react/display-name": "error", // Encourage proper usage of Promises: "@typescript-eslint/no-floating-promises": "error", @@ -46,6 +44,15 @@ module.exports = { "rxjs/no-exposed-subjects": "error", "rxjs/finnish": ["error", { names: { "^this$": false } }], }, + overrides: [ + { + files: ["src/*/**"], + rules: { + // In application code we should use the js-sdk logger, never console directly. + "no-console": ["error"], + }, + }, + ], settings: { react: { version: "detect", diff --git a/docs/url-params.md b/docs/url-params.md index e76c976e..3fac185a 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -48,6 +48,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | Name | Values | Required for widget | Required for SPA | Description | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | | `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | | `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | | `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | @@ -59,7 +60,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st | `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | | `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | | `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | -| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. | | `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | | `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | | `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | diff --git a/i18next-parser.config.ts b/i18next-parser.config.ts index 3acf2b5e..10d748a8 100644 --- a/i18next-parser.config.ts +++ b/i18next-parser.config.ts @@ -1,4 +1,6 @@ -export default { +import type { UserConfig } from "i18next-parser"; + +const config: UserConfig = { keySeparator: ".", namespaceSeparator: false, contextSeparator: "|", @@ -26,3 +28,5 @@ export default { input: ["src/**/*.{ts,tsx}"], sort: true, }; + +export default config; diff --git a/knip.ts b/knip.ts index 2381356c..6b378e29 100644 --- a/knip.ts +++ b/knip.ts @@ -1,8 +1,15 @@ -import { KnipConfig } from "knip"; +/* +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 { type KnipConfig } from "knip"; export default { vite: { - config: ["vite.config.js", "vite-embedded.config.js"], + config: ["vite.config.ts", "vite-embedded.config.ts"], }, entry: ["src/main.tsx", "i18next-parser.config.ts"], ignoreBinaries: [ diff --git a/playwright/restricted-sfu.spec.ts b/playwright/restricted-sfu.spec.ts new file mode 100644 index 00000000..a9e07d38 --- /dev/null +++ b/playwright/restricted-sfu.spec.ts @@ -0,0 +1,75 @@ +/* +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"; +import { sleep } from "matrix-js-sdk/lib/utils.js"; + +test("Should request JWT token before starting the call", async ({ page }) => { + await page.goto("/"); + + let sfGetTimestamp = 0; + let sendStateEventTimestamp = 0; + await page.route( + "**/matrix-rtc.m.localhost/livekit/jwt/sfu/get", + async (route) => { + await sleep(2000); // Simulate very slow request + await route.continue(); + sfGetTimestamp = Date.now(); + }, + ); + + await page.route( + "**/state/org.matrix.msc3401.call.member/**", + async (route) => { + await route.continue(); + sendStateEventTimestamp = Date.now(); + }, + ); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + await page.waitForTimeout(4000); + // Ensure that the call is connected + await page + .locator("div") + .filter({ hasText: /^HelloCall$/ }) + .click(); + + expect(sfGetTimestamp).toBeGreaterThan(0); + expect(sendStateEventTimestamp).toBeGreaterThan(0); + expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp); +}); + +test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({ + page, +}) => { + await page.goto("/"); + + await page.route("**/openid/request_token", async (route) => { + await route.fulfill({ + status: 418, // Simulate an error not retryable + }); + }); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // Should fail + await expect(page.getByText("Something went wrong")).toBeVisible(); +}); diff --git a/playwright/sfu-reconnect-bug.spec.ts b/playwright/sfu-reconnect-bug.spec.ts index c756570a..6138eb78 100644 --- a/playwright/sfu-reconnect-bug.spec.ts +++ b/playwright/sfu-reconnect-bug.spec.ts @@ -100,5 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({ // https://github.com/element-hq/element-call/issues/3344 // The app used to request a new jwt token then to reconnect to the SFU expect(wsConnectionCount).toBe(1); - expect(sfuGetCallCount).toBe(1); + expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 65e3d901..30019d36 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -23,9 +23,10 @@ interface RoomIdentifier { } export enum UserIntent { - // TODO: add DM vs room call StartNewCall = "start_call", JoinExistingCall = "join_existing", + StartNewCallDM = "start_call_dm", + JoinExistingCallDM = "join_existing_dm", Unknown = "unknown", } @@ -209,6 +210,12 @@ export interface UrlConfiguration { * Whether and what type of notification EC should send, when the user joins the call. */ sendNotificationType?: RTCNotificationType; + /** + * Whether the app should automatically leave the call when there + * is no one left in the call. + * This is one part to make the call matrixRTC session behave like a telephone call. + */ + autoLeaveWhenOthersLeft: boolean; } // If you need to add a new flag to this interface, prefer a name that describes @@ -276,10 +283,16 @@ class ParamParser { ]; } + /** + * Returns true if the flag exists and is not "false". + */ public getFlagParam(name: string, defaultValue = false): boolean { const param = this.getParam(name); return param === null ? defaultValue : param !== "false"; } + /** + * Returns the value of the flag if it exists, or undefined if it does not. + */ public getFlag(name: string): boolean | undefined { const param = this.getParam(name); return param !== null ? param !== "false" : undefined; @@ -333,6 +346,7 @@ export const getUrlParams = ( skipLobby: true, returnToLobby: false, sendNotificationType: "notification" as RTCNotificationType, + autoLeaveWhenOthersLeft: false, }; switch (intent) { case UserIntent.StartNewCall: @@ -347,6 +361,20 @@ export const getUrlParams = ( skipLobby: false, }; break; + case UserIntent.StartNewCallDM: + intentPreset = { + ...inAppDefault, + skipLobby: true, + autoLeaveWhenOthersLeft: true, + }; + break; + case UserIntent.JoinExistingCallDM: + intentPreset = { + ...inAppDefault, + skipLobby: true, + autoLeaveWhenOthersLeft: true, + }; + break; // Non widget usecase defaults default: intentPreset = { @@ -362,6 +390,7 @@ export const getUrlParams = ( skipLobby: false, returnToLobby: false, sendNotificationType: undefined, + autoLeaveWhenOthersLeft: false, }; } @@ -413,12 +442,13 @@ export const getUrlParams = ( "ring", "notification", ]), + autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), }; return { ...properties, ...intentPreset, - ...pickBy(configuration, (v) => v !== undefined), + ...pickBy(configuration, (v?: unknown) => v !== undefined), }; }; diff --git a/src/icons/FullScreenMaximise.svg b/src/icons/FullScreenMaximise.svg new file mode 100644 index 00000000..1814f16e --- /dev/null +++ b/src/icons/FullScreenMaximise.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/icons/FullScreenMinimise.svg b/src/icons/FullScreenMinimise.svg new file mode 100644 index 00000000..204259e2 --- /dev/null +++ b/src/icons/FullScreenMinimise.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/livekit/BlurBackgroundTransformer.ts b/src/livekit/BlurBackgroundTransformer.ts index 72256fe1..c3d885ba 100644 --- a/src/livekit/BlurBackgroundTransformer.ts +++ b/src/livekit/BlurBackgroundTransformer.ts @@ -1,7 +1,7 @@ /* Copyright 2024-2025 New Vector Ltd. -SPDX-License-Identifier: AGPL-3.0-only +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index a68d7535..b37a6e3e 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -1,7 +1,7 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024-2025 New Vector Ltd. -SPDX-License-Identifier: AGPL-3.0-only +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index a0d685ff..a39da82a 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -60,7 +60,7 @@ export function CallEventAudioRenderer({ const audioEngineRef = useLatest(audioEngineCtx); useEffect(() => { - const joinSub = vm.memberChanges$ + const joinSub = vm.participantChanges$ .pipe( filter( ({ joined, ids }) => @@ -72,7 +72,7 @@ export function CallEventAudioRenderer({ void audioEngineRef.current?.playSound("join"); }); - const leftSub = vm.memberChanges$ + const leftSub = vm.participantChanges$ .pipe( filter( ({ ids, left }) => diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 4af599bb..76352523 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -166,7 +166,11 @@ export const GroupCallView: FC = ({ const { displayName, avatarUrl } = useProfile(client); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room); - const { perParticipantE2EE, returnToLobby } = useUrlParams(); + const { + perParticipantE2EE, + returnToLobby, + password: passwordFromUrl, + } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(room.roomId); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); const [useExperimentalToDeviceTransport] = useSetting( @@ -174,7 +178,6 @@ export const GroupCallView: FC = ({ ); // Save the password once we start the groupCallView - const { password: passwordFromUrl } = useUrlParams(); useEffect(() => { if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl); }, [passwordFromUrl, room.roomId]); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5f8e7c28..5aa270d2 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,7 @@ import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; import { BehaviorSubject, map } from "rxjs"; -import { useObservable } from "observable-hooks"; +import { useObservable, useSubscription } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -140,11 +140,11 @@ export const ActiveCall: FC = (props) => { useEffect(() => { logger.info( - `[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`, + `[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`, ); return (): void => { logger.info( - `[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`, + `[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`, ); livekitRoom ?.disconnect() @@ -159,6 +159,8 @@ export const ActiveCall: FC = (props) => { }; }, [livekitRoom]); + const { autoLeaveWhenOthersLeft } = useUrlParams(); + useEffect(() => { if (livekitRoom !== undefined) { const reactionsReader = new ReactionsReader(props.rtcSession); @@ -166,7 +168,10 @@ export const ActiveCall: FC = (props) => { props.rtcSession, livekitRoom, mediaDevices, - props.e2eeSystem, + { + encryptionSystem: props.e2eeSystem, + autoLeaveWhenOthersLeft, + }, connStateObservable$, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -183,6 +188,7 @@ export const ActiveCall: FC = (props) => { mediaDevices, props.e2eeSystem, connStateObservable$, + autoLeaveWhenOthersLeft, ]); if (livekitRoom === undefined || vm === null) return null; @@ -313,6 +319,7 @@ export const InCallView: FC = ({ const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); + useSubscription(vm.autoLeaveWhenOthersLeft$, 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/rtcSessionHelpers.test.ts b/src/rtcSessionHelpers.test.ts index ecfd44f7..2ef9e3f1 100644 --- a/src/rtcSessionHelpers.test.ts +++ b/src/rtcSessionHelpers.test.ts @@ -70,6 +70,12 @@ test("It joins the correct Session", async () => { roomId: "roomId", client: { getDomain: vi.fn().mockReturnValue("example.org"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, + }), }, }, memberships: [], @@ -195,6 +201,12 @@ test("It should not fail with configuration error if homeserver config has livek roomId: "roomId", client: { getDomain: vi.fn().mockReturnValue("example.org"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, + }), }, }, memberships: [], diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts index be5eedf1..73f58cea 100644 --- a/src/rtcSessionHelpers.ts +++ b/src/rtcSessionHelpers.ts @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; -import { logger } from "matrix-js-sdk/lib/logger"; import { isLivekitFocus, isLivekitFocusConfig, type LivekitFocus, type LivekitFocusActive, + type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; +import { logger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { PosthogAnalytics } from "./analytics/PosthogAnalytics"; @@ -20,6 +20,7 @@ import { Config } from "./config/Config"; import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget"; import { MatrixRTCFocusMissingError } from "./utils/errors"; import { getUrlParams } from "./UrlParams"; +import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; @@ -46,6 +47,9 @@ async function makePreferredLivekitFoci( preferredFoci.push(focusInUse); } + // Warm up the first focus we owned, to ensure livekit room is created before any state event sent. + let toWarmUp: LivekitFocus | undefined; + // Prioritize the .well-known/matrix/client, if available, over the configured SFU const domain = rtcSession.room.client.getDomain(); if (domain) { @@ -55,18 +59,17 @@ async function makePreferredLivekitFoci( FOCI_WK_KEY ]; if (Array.isArray(wellKnownFoci)) { - preferredFoci.push( - ...wellKnownFoci - .filter((f) => !!f) - .filter(isLivekitFocusConfig) - .map((wellKnownFocus) => { - logger.log( - "Adding livekit focus from well known: ", - wellKnownFocus, - ); - return { ...wellKnownFocus, livekit_alias: livekitAlias }; - }), - ); + const validWellKnownFoci = wellKnownFoci + .filter((f) => !!f) + .filter(isLivekitFocusConfig) + .map((wellKnownFocus) => { + logger.log("Adding livekit focus from well known: ", wellKnownFocus); + return { ...wellKnownFocus, livekit_alias: livekitAlias }; + }); + if (validWellKnownFoci.length > 0) { + toWarmUp = validWellKnownFoci[0]; + } + preferredFoci.push(...validWellKnownFoci); } } @@ -77,10 +80,15 @@ async function makePreferredLivekitFoci( livekit_service_url: urlFromConf, livekit_alias: livekitAlias, }; + toWarmUp = toWarmUp ?? focusFormConf; logger.log("Adding livekit focus from config: ", focusFormConf); preferredFoci.push(focusFormConf); } + if (toWarmUp) { + // this will call the jwt/sfu/get endpoint to pre create the livekit room. + await getSFUConfigWithOpenID(rtcSession.room.client, toWarmUp); + } if (preferredFoci.length === 0) throw new MatrixRTCFocusMissingError(domain ?? ""); return Promise.resolve(preferredFoci); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index a3068c32..511a9431 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -32,7 +32,11 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { deepCompare } from "matrix-js-sdk/lib/utils"; -import { CallViewModel, type Layout } from "./CallViewModel"; +import { + CallViewModel, + type CallViewModelOptions, + type Layout, +} from "./CallViewModel"; import { mockLivekitRoom, mockLocalParticipant, @@ -71,6 +75,7 @@ import { local, localId, localRtcMember, + localRtcMemberDevice2, } from "../utils/test-fixtures"; import { ObservableScope } from "./ObservableScope"; import { MediaDevices } from "./MediaDevices"; @@ -231,6 +236,10 @@ function withCallViewModel( vm: CallViewModel, subjects: { raisedHands$: BehaviorSubject> }, ) => void, + options: CallViewModelOptions = { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + autoLeaveWhenOthersLeft: false, + }, ): void { const room = mockMatrixRoom({ client: { @@ -281,9 +290,7 @@ function withCallViewModel( rtcSession as unknown as MatrixRTCSession, liveKitRoom, mediaDevices, - { - kind: E2eeType.PER_PARTICIPANT, - }, + options, connectionState$, raisedHands$, new BehaviorSubject({}), @@ -978,7 +985,7 @@ test("should strip RTL characters from displayname", () => { }); it("should rank raised hands above video feeds and below speakers and presenters", () => { - withTestScheduler(({ schedule, expectObservable }) => { + withTestScheduler(({ schedule, expectObservable, behavior }) => { // There should always be one tile for each MatrixRTCSession const expectedLayoutMarbles = "ab"; @@ -1037,6 +1044,176 @@ 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", { + a: [], // Start empty + b: [], // Alice joins + c: [], // Alice still there + d: [], // Alice leaves + }); +} + +function participantJoinLeave$( + hot: ( + marbles: string, + values: Record, + ) => Observable, +): Observable { + return hot("a-b-c-d", { + a: [], // Start empty + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], // Alice still there + d: [], // Alice leaves + }); +} + +function rtcMemberJoinLeave$( + hot: ( + marbles: string, + values: Record, + ) => Observable, +): Observable { + return hot("a-b-c-d", { + a: [], // Start empty + b: [aliceRtcMember], // Alice joins + c: [aliceRtcMember], // Alice still there + d: [], // Alice leaves + }); +} + +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( + scope.behavior(nooneEverThere$(hot), []), + scope.behavior(nooneEverThere$(hot), []), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); + }, + ); + }); +}); + +test("allOthersLeft$ emits true when someone joined and then all others left", () => { + withTestScheduler(({ hot, expectObservable, scope }) => { + withCallViewModel( + scope.behavior(participantJoinLeave$(hot), []), + scope.behavior(rtcMemberJoinLeave$(hot), []), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (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( + scope.behavior(participantJoinLeave$(hot), []), + scope.behavior(rtcMemberJoinLeave$(hot), []), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( + "------e", // false initially, then at frame 6: true then false emissions in same frame + { e: undefined }, + ); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { + withTestScheduler(({ hot, expectObservable, scope }) => { + withCallViewModel( + scope.behavior(nooneEverThere$(hot), []), + scope.behavior(nooneEverThere$(hot), []), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { + withTestScheduler(({ hot, expectObservable, scope }) => { + withCallViewModel( + scope.behavior(participantJoinLeave$(hot), []), + scope.behavior(rtcMemberJoinLeave$(hot), []), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); + }, + { + autoLeaveWhenOthersLeft: false, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); +}); + +test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ hot, expectObservable, scope }) => { + withCallViewModel( + scope.behavior( + hot("a-b-c-d", { + a: [], // Alone + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], + d: [], // Local joins with a second device + }), + [], //Alice leaves + ), + 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 + }), + [], + ), + of(ConnectionState.Connected), + new Map(), + mockMediaDevices({}), + (vm) => { + expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { + e: undefined, + }); + }, + { + autoLeaveWhenOthersLeft: true, + 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 bd2a7607..70183a37 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -96,6 +96,10 @@ import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; +export interface CallViewModelOptions { + encryptionSystem: EncryptionSystem; + autoLeaveWhenOthersLeft?: boolean; +} // How long we wait after a focus switch before showing the real participant // list again const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; @@ -473,49 +477,47 @@ export class CallViewModel extends ViewModel { ), ); + private readonly memberships$: Observable = merge( + // Handle call membership changes. + fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged), + // Handle room membership changes (and displayname updates) + fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members), + ).pipe( + startWith(this.matrixRTCSession.memberships), + map(() => { + return this.matrixRTCSession.memberships; + }), + ); + /** * Displaynames for each member of the call. This will disambiguate * any displaynames that clashes with another member. Only members * joined to the call are considered here. */ - 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.matrixRTCSession.room, RoomStateEvent.Members), - ).pipe( - startWith(null), - map(() => { - const displaynameMap = new Map(); - const { room, memberships } = this.matrixRTCSession; + public readonly memberDisplaynames$ = this.memberships$.pipe( + map((memberships) => { + const displaynameMap = new Map(); + const { room } = this.matrixRTCSession; - // We only consider RTC members for disambiguation as they are the only visible members. - for (const rtcMember of memberships) { - const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; - const { member } = getRoomMemberFromRtcMember(rtcMember, room); - if (!member) { - logger.error( - "Could not find member for media id:", - matrixIdentifier, - ); - continue; - } - const disambiguate = shouldDisambiguate(member, memberships, room); - displaynameMap.set( - matrixIdentifier, - calculateDisplayName(member, disambiguate), - ); + // We only consider RTC members for disambiguation as they are the only visible members. + for (const rtcMember of memberships) { + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + const { member } = getRoomMemberFromRtcMember(rtcMember, room); + if (!member) { + logger.error("Could not find member for media id:", matrixIdentifier); + continue; } - return displaynameMap; - }), - // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower - // than on Chrome/Firefox). This means it is important that we multicast the result so that we - // don't do this work more times than we need to. This is achieved by converting to a behavior: - ), + const disambiguate = shouldDisambiguate(member, memberships, room); + displaynameMap.set( + matrixIdentifier, + calculateDisplayName(member, disambiguate), + ); + } + return displaynameMap; + }), + // It turns out that doing the disambiguation above is rather expensive on Safari (10x slower + // than on Chrome/Firefox). This means it is important that we 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 handsRaised$ = this.scope.behavior(this.handsRaisedSubject$); @@ -612,7 +614,7 @@ export class CallViewModel extends ViewModel { indexedMediaId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -635,7 +637,7 @@ export class CallViewModel extends ViewModel { screenShareId, member, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map((m) => m.get(matrixIdentifier) ?? "[👻]"), @@ -676,7 +678,7 @@ export class CallViewModel extends ViewModel { nonMemberId, undefined, participant, - this.encryptionSystem, + this.options.encryptionSystem, this.livekitRoom, this.memberDisplaynames$.pipe( map( @@ -726,18 +728,77 @@ export class CallViewModel extends ViewModel { ), ); - public readonly memberChanges$ = this.userMedia$ - .pipe(map((mediaItems) => mediaItems.map((m) => m.id))) - .pipe( - 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: [] }, - ), - ); + /** + * 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.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: [] }, + ), + ); + + /** + * 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 allOthersLeft$ = this.matrixUserChanges$.pipe( + map(({ userIds, leftUserIds }) => { + const userId = this.matrixRTCSession.room.client.getUserId(); + if (!userId) { + logger.warn("Could access client.getUserId to compute allOthersLeft"); + return false; + } + return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0; + }), + startWith(false), + distinctUntilChanged(), + ); + + public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe( + distinctUntilChanged(), + filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false), + map(() => {}), + ); /** * List of MediaItems that we want to display, that are of type ScreenShare @@ -1426,7 +1487,7 @@ export class CallViewModel extends ViewModel { private readonly matrixRTCSession: MatrixRTCSession, private readonly livekitRoom: LivekitRoom, private readonly mediaDevices: MediaDevices, - private readonly encryptionSystem: EncryptionSystem, + private readonly options: CallViewModelOptions, private readonly connectionState$: Observable, private readonly handsRaisedSubject$: Observable< Record diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 78831571..622496d2 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -88,40 +88,48 @@ Please see LICENSE in the repository root for full details. padding: var(--cpd-space-2x); border: none; border-radius: var(--cpd-radius-pill-effect); - background: var(--cpd-color-alpha-gray-1400); + background: rgba(from var(--cpd-color-gray-100) r g b / 0.6); box-shadow: var(--small-drop-shadow); transition: opacity 0.15s, background-color 0.1s; - position: absolute; z-index: 1; --inset: 6px; inset-block-end: var(--inset); inset-inline-end: var(--inset); } +.bottomRightButtons { + display: flex; + gap: var(--cpd-space-2x); + position: absolute; + inset-block-end: var(--cpd-space-1x); + inset-inline-end: var(--cpd-space-1x); + z-index: 1; +} + .expand > svg { display: block; - color: var(--cpd-color-icon-on-solid-primary); + color: var(--cpd-color-icon-primary); } @media (hover) { .expand:hover { - background: var(--cpd-color-bg-action-primary-hovered); + background: var(--cpd-color-gray-400); } } .expand:active { - background: var(--cpd-color-bg-action-primary-pressed); + background: var(--cpd-color-gray-100); } @media (hover) { - .tile:hover > button { + .tile:hover > div > button { opacity: 1; } } -.tile:has(:focus-visible) > button { +.tile:has(:focus-visible) > div > button { opacity: 1; } diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 8495e88b..8bc45a81 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -29,6 +29,8 @@ import classNames from "classnames"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type RoomMember } from "matrix-js-sdk"; +import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; +import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { @@ -210,6 +212,26 @@ export const SpotlightTile: FC = ({ const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; + const isFullscreen = useCallback((): boolean => { + const rootElement = document.body; + if (rootElement && document.fullscreenElement) return true; + return false; + }, []); + + const FullScreenIcon = isFullscreen() + ? FullScreenMinimiseIcon + : FullScreenMaximiseIcon; + + const onToggleFullscreen = useCallback(() => { + const rootElement = document.body; + if (!rootElement) return; + if (isFullscreen()) { + void document?.exitFullscreen(); + } else { + void rootElement.requestFullscreen(); + } + }, [isFullscreen]); + // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run // their effects before their parent does, we need to do this dance with an @@ -292,17 +314,28 @@ export const SpotlightTile: FC = ({ /> ))} - {onToggleExpanded && ( + - + - )} + + {onToggleExpanded && ( + + + + )} + + {canGoToNext && ( ; + scope: ObservableScope; } interface TestRunnerGlobal { @@ -96,6 +97,7 @@ export function withTestScheduler( scheduler.run((helpers) => continuation({ ...helpers, + scope, schedule(marbles, actions) { const actionsObservable$ = helpers .cold(marbles) diff --git a/vite-embedded.config.js b/vite-embedded.config.ts similarity index 84% rename from vite-embedded.config.js rename to vite-embedded.config.ts index 8f5bcba8..27a42fbb 100644 --- a/vite-embedded.config.js +++ b/vite-embedded.config.ts @@ -1,7 +1,15 @@ +/* +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 { defineConfig, mergeConfig } from "vite"; -import fullConfig from "./vite.config"; import generateFile from "vite-plugin-generate-file"; +import fullConfig from "./vite.config"; + const base = "./"; // Config for embedded deployments (possibly hosted under a non-root path) diff --git a/vite.config.js b/vite.config.ts similarity index 92% rename from vite.config.js rename to vite.config.ts index 5fe3a99b..cfc80279 100644 --- a/vite.config.js +++ b/vite.config.ts @@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { defineConfig, loadEnv, searchForWorkspaceRoot } from "vite"; +import { + loadEnv, + searchForWorkspaceRoot, + type ConfigEnv, + type UserConfig, +} from "vite"; import svgrPlugin from "vite-plugin-svgr"; import { createHtmlPlugin } from "vite-plugin-html"; import { codecovVitePlugin } from "@codecov/vite-plugin"; @@ -15,10 +20,14 @@ import { realpathSync } from "fs"; import * as fs from "node:fs"; // https://vitejs.dev/config/ -export default defineConfig(({ mode, packageType }) => { +// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite) +export default ({ + mode, + packageType, +}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => { const env = loadEnv(mode, process.cwd()); // Environment variables with the VITE_ prefix are accessible at runtime. - // So, we set this to allow for build/package specific behaviour. + // So, we set this to allow for build/package specific behavior. // In future we might be able to do what is needed via code splitting at // build time. process.env.VITE_PACKAGE = packageType ?? "full"; @@ -93,7 +102,7 @@ export default defineConfig(({ mode, packageType }) => { sourcemap: true, rollupOptions: { output: { - assetFileNames: ({ originalFileNames }) => { + assetFileNames: ({ originalFileNames }): string => { if (originalFileNames) { for (const name of originalFileNames) { // Custom asset name for locales to include the locale code in the filename @@ -143,4 +152,4 @@ export default defineConfig(({ mode, packageType }) => { exclude: ["@matrix-org/matrix-sdk-crypto-wasm"], }, }; -}); +}; diff --git a/vitest.config.js b/vitest.config.ts similarity index 94% rename from vitest.config.js rename to vitest.config.ts index a6c3107f..1c6f746b 100644 --- a/vitest.config.js +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import viteConfig from "./vite.config.js"; + +import viteConfig from "./vite.config"; export default defineConfig((configEnv) => mergeConfig( diff --git a/yarn.lock b/yarn.lock index 5efe3c7a..128e39e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10046,8 +10046,8 @@ __metadata: linkType: hard "livekit-client@npm:^2.13.0": - version: 2.15.4 - resolution: "livekit-client@npm:2.15.4" + version: 2.15.5 + resolution: "livekit-client@npm:2.15.5" dependencies: "@livekit/mutex": "npm:1.1.1" "@livekit/protocol": "npm:1.39.3" @@ -10060,7 +10060,7 @@ __metadata: webrtc-adapter: "npm:^9.0.1" peerDependencies: "@types/dom-mediacapture-record": ^1 - checksum: 10c0/f12a3b604aed8e075791c60a75c9eea5467e5a0bab48c2eb23216ec357e89a50199274121e0330b61f946d0be9556058824ce1b832003e722786e54ba33017a6 + checksum: 10c0/52a70bdd39d802737ed7c25ae5d06daf9921156c4fc74f918009e86204430b2d200b66c55cefab949be4e5411cbc4d25eac92976f62f96b7226057a5b0706baa languageName: node linkType: hard