diff --git a/src/livekit/useLiveKit.ts b/src/livekit/useLivekit.ts similarity index 99% rename from src/livekit/useLiveKit.ts rename to src/livekit/useLivekit.ts index 972f7756..7cb32f5f 100644 --- a/src/livekit/useLiveKit.ts +++ b/src/livekit/useLivekit.ts @@ -48,7 +48,7 @@ interface UseLivekitResult { connState: ECConnectionState; } -export function useLiveKit( +export function useLivekit( rtcSession: MatrixRTCSession, muteStates: MuteStates, sfuConfig: SFUConfig | undefined, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 4ea356bf..513b51ea 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -62,7 +62,7 @@ import { } from "../utils/errors.ts"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { - useExperimentalToDeviceTransportSetting, + useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, useNewMembershipManagerSetting as useNewMembershipManagerSetting, useSetting, } from "../settings/settings"; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx new file mode 100644 index 00000000..66e070d6 --- /dev/null +++ b/src/room/InCallView.test.tsx @@ -0,0 +1,249 @@ +/* +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 { + beforeEach, + describe, + expect, + it, + type MockedFunction, + vi, +} from "vitest"; +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 { of } from "rxjs"; +import { BrowserRouter } from "react-router-dom"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { + RoomAudioRenderer, + RoomContext, + useLocalParticipant, +} from "@livekit/components-react"; +import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; + +import { type MuteStates } from "./MuteStates"; +import { InCallView } from "./InCallView"; +import { + mockLivekitRoom, + mockLocalParticipant, + mockMatrixRoom, + mockMatrixRoomMember, + mockRemoteParticipant, + mockRtcMembership, + type MockRTCSession, +} from "../utils/test"; +import { E2eeType } from "../e2ee/e2eeType"; +import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; +import { alice, local } from "../utils/test-fixtures"; +import { useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting } from "../settings/settings"; +import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; +import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; + +// vi.hoisted(() => { +// localStorage = {} as unknown as Storage; +// }); +vi.hoisted( + () => + (global.ImageData = class MockImageData { + public data: number[] = []; + } as unknown as typeof ImageData), +); + +vi.mock("../soundUtils"); +vi.mock("../useAudioContext"); +vi.mock("../tile/GridTile"); +vi.mock("../tile/SpotlightTile"); +vi.mock("@livekit/components-react"); +vi.mock("../e2ee/sharedKeyManagement"); +vi.mock("react-use-measure", () => ({ + default: (): [() => void, object] => [(): void => {}, {}], +})); + +const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); +const localParticipant = mockLocalParticipant({ + identity: "@local:example.org:AAAAAA", +}); +const remoteParticipant = mockRemoteParticipant({ + identity: "@alice:example.org:AAAAAA", +}); +const carol = mockMatrixRoomMember(localRtcMember); +const roomMembers = new Map([carol].map((p) => [p.userId, p])); + +const roomId = "!foo:bar"; +let useRoomEncryptionSystemMock: MockedFunction; +beforeEach(() => { + vi.clearAllMocks(); + // RoomAudioRenderer is tested separately. + ( + RoomAudioRenderer as MockedFunction + ).mockImplementation((_props) => { + return
mocked: RoomAudioRenderer
; + }); + ( + useLocalParticipant as MockedFunction + ).mockImplementation( + () => + ({ + isScreenShareEnabled: false, + localParticipant: localRtcMember as unknown as LocalParticipant, + }) as unknown as ReturnType, + ); + + useRoomEncryptionSystemMock = + useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock; + useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE }); +}); + +function createInCallView(): RenderResult & { + rtcSession: MockRTCSession; +} { + const client = { + getUser: () => null, + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + getRoom: (rId) => (rId === roomId ? room : null), + } as Partial as MatrixClient; + const room = mockMatrixRoom({ + relations: { + getChildEventsForEvent: () => + vi.mocked({ + getRelations: () => [], + }), + } as unknown as RelationsContainer, + client, + roomId, + getMember: (userId) => roomMembers.get(userId) ?? null, + getMxcAvatarUrl: () => null, + hasEncryptionStateEvent: vi.fn().mockReturnValue(true), + getCanonicalAlias: () => null, + currentState: { + getJoinRule: () => JoinRule.Invite, + } as Partial as RoomState, + }); + + const muteState = { + audio: { enabled: false }, + video: { enabled: false }, + } as MuteStates; + const livekitRoom = mockLivekitRoom( + { + localParticipant, + }, + { + remoteParticipants$: of([remoteParticipant]), + }, + ); + const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]); + + rtcSession.joined = true; + const renderResult = render( + + + + + + + + + , + ); + return { + ...renderResult, + rtcSession, + }; +} + +describe("InCallView", () => { + describe("rendering", () => { + it("renders", () => { + const { container } = createInCallView(); + expect(container).toMatchSnapshot(); + }); + }); + describe("toDevice label", () => { + it("is shown if setting activated and room encrypted", () => { + useRoomEncryptionSystemMock.mockReturnValue({ + kind: E2eeType.PER_PARTICIPANT, + }); + useExperimentalToDeviceTransportSetting.setValue(true); + const { getByText } = createInCallView(); + expect(getByText("using to Device key transport")).toBeInTheDocument(); + }); + + it("is not shown in unenecrypted room", () => { + useRoomEncryptionSystemMock.mockReturnValue({ + kind: E2eeType.NONE, + }); + useExperimentalToDeviceTransportSetting.setValue(true); + const { queryByText } = createInCallView(); + expect( + queryByText("using to Device key transport"), + ).not.toBeInTheDocument(); + }); + + it("is hidden once fallback was triggered", async () => { + useRoomEncryptionSystemMock.mockReturnValue({ + kind: E2eeType.PER_PARTICIPANT, + }); + useExperimentalToDeviceTransportSetting.setValue(true); + const { rtcSession, queryByText } = createInCallView(); + expect(queryByText("using to Device key transport")).toBeInTheDocument(); + expect(rtcSession).toBeDefined(); + await act(() => + rtcSession.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, { + toDevice: true, + room: true, + }), + ); + expect( + queryByText("using to Device key transport"), + ).not.toBeInTheDocument(); + }); + it("is not shown if setting is disabled", () => { + useExperimentalToDeviceTransportSetting.setValue(false); + + useRoomEncryptionSystemMock.mockReturnValue({ + kind: E2eeType.PER_PARTICIPANT, + }); + const { queryByText } = createInCallView(); + expect( + queryByText("using to Device key transport"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index c337bc3c..e90f78d8 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -56,7 +56,7 @@ import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { RageshakeRequestModal } from "./RageshakeRequestModal"; -import { useLiveKit } from "../livekit/useLiveKit"; +import { useLivekit } from "../livekit/useLivekit.ts"; import { useWakeLock } from "../useWakeLock"; import { useMergedRefs } from "../useMergedRefs"; import { type MuteStates } from "./MuteStates"; @@ -73,7 +73,10 @@ import { import { Grid, type TileProps } from "../grid/Grid"; import { useInitial } from "../useInitial"; import { SpotlightTile } from "../tile/SpotlightTile"; -import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; +import { + useRoomEncryptionSystem, + type EncryptionSystem, +} from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; import { @@ -96,7 +99,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { debugTileLayout as debugTileLayoutSetting, - useExperimentalToDeviceTransportSetting, + useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, useSetting, } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; @@ -114,7 +117,7 @@ export interface ActiveCallProps export const ActiveCall: FC = (props) => { const sfuConfig = useOpenIDSFU(props.client, props.rtcSession); - const { livekitRoom, connState } = useLiveKit( + const { livekitRoom, connState } = useLivekit( props.rtcSession, props.muteStates, sfuConfig, @@ -233,19 +236,29 @@ export const InCallView: FC = ({ room: livekitRoom, }); - const [toDeviceEncryptionSetting] = useSetting( - useExperimentalToDeviceTransportSetting, - ); - const [showToDeviceEncryption, setShowToDeviceEncryption] = useState( - () => toDeviceEncryptionSetting, - ); - useEffect(() => { - setShowToDeviceEncryption(toDeviceEncryptionSetting); - }, [toDeviceEncryptionSetting]); + // This seems like it might be enough logic to move it into the call view model? + const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); + useTypedEventEmitter( rtcSession, RoomAndToDeviceEvents.EnabledTransportsChanged, - (enabled) => setShowToDeviceEncryption(enabled.to_device), + (enabled) => setDidFallbackToRoomKey(enabled.room), + ); + const [useExperimentalToDeviceTransport] = useSetting( + useExperimentalToDeviceTransportSetting, + ); + const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId); + + const showToDeviceEncryption = useMemo( + () => + useExperimentalToDeviceTransport && + encryptionSystem.kind === E2eeType.PER_PARTICIPANT && + !didFallbackToRoomKey, + [ + encryptionSystem.kind, + didFallbackToRoomKey, + useExperimentalToDeviceTransport, + ], ); const toggleMicrophone = useCallback( diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index fa7df166..c61cbd82 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -21,8 +21,8 @@ import { act, type ReactNode } from "react"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { - playReactionsSound, - soundEffectVolumeSetting, + playReactionsSound as playReactionsSoundSetting, + soundEffectVolume as soundEffectVolumeSetting, } from "../settings/settings"; import { useAudioContext } from "../useAudioContext"; import { GenericReaction, ReactionSet } from "../reactions"; @@ -50,7 +50,7 @@ vitest.mock("../soundUtils"); afterEach(() => { vitest.resetAllMocks(); - playReactionsSound.setValue(playReactionsSound.defaultValue); + playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue); soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue); }); @@ -74,7 +74,7 @@ beforeEach(() => { test("preloads all audio elements", () => { const { vm } = getBasicCallViewModelEnvironment([local, alice]); - playReactionsSound.setValue(true); + playReactionsSoundSetting.setValue(true); render(); expect(prefetchSounds).toHaveBeenCalledOnce(); }); @@ -84,7 +84,7 @@ test("will play an audio sound when there is a reaction", () => { local, alice, ]); - playReactionsSound.setValue(true); + playReactionsSoundSetting.setValue(true); render(); // Find the first reaction with a sound effect @@ -110,7 +110,7 @@ test("will play the generic audio sound when there is soundless reaction", () => local, alice, ]); - playReactionsSound.setValue(true); + playReactionsSoundSetting.setValue(true); render(); // Find the first reaction with a sound effect @@ -136,7 +136,7 @@ test("will play multiple audio sounds when there are multiple different reaction local, alice, ]); - playReactionsSound.setValue(true); + playReactionsSoundSetting.setValue(true); render(); // Find the first reaction with a sound effect diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap new file mode 100644 index 00000000..427973b6 --- /dev/null +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -0,0 +1,181 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`InCallView > rendering > renders 1`] = ` +
+
+
+
+ mocked: RoomAudioRenderer +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+`; diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 67de0e0d..e3f50ebb 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -16,7 +16,7 @@ import { showNonMemberTiles as showNonMemberTilesSetting, showConnectionStats as showConnectionStatsSetting, useNewMembershipManagerSetting, - useExperimentalToDeviceTransportSetting, + useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, } from "./settings"; import type { MatrixClient } from "matrix-js-sdk"; import type { Room as LivekitRoom } from "livekit-client"; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b24674dc..b0a4b79e 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -23,7 +23,7 @@ import { import { widget } from "../widget"; import { useSetting, - soundEffectVolumeSetting, + soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, developerMode, } from "./settings"; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a3b52c7a..6525f50d 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -110,7 +110,7 @@ export const playReactionsSound = new Setting( true, ); -export const soundEffectVolumeSetting = new Setting( +export const soundEffectVolume = new Setting( "sound-effect-volume", 0.5, ); @@ -120,7 +120,7 @@ export const useNewMembershipManagerSetting = new Setting( true, ); -export const useExperimentalToDeviceTransportSetting = new Setting( +export const useExperimentalToDeviceTransport = new Setting( "experimental-to-device-transport", true, ); diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index 29949bf8..92d3a947 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -12,7 +12,7 @@ import userEvent from "@testing-library/user-event"; import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext"; import { useAudioContext } from "./useAudioContext"; -import { soundEffectVolumeSetting } from "./settings/settings"; +import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings"; const staticSounds = Promise.resolve({ aSound: new ArrayBuffer(0), diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index d96b9fdc..7cd4ff39 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -9,7 +9,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { useState, useEffect } from "react"; import { - soundEffectVolumeSetting as effectSoundVolumeSetting, + soundEffectVolume as soundEffectVolumeSetting, useSetting, } from "./settings/settings"; import { useMediaDevices } from "./livekit/MediaDevicesContext"; @@ -62,7 +62,7 @@ interface UseAudioContext { export function useAudioContext( props: Props, ): UseAudioContext | null { - const [effectSoundVolume] = useSetting(effectSoundVolumeSetting); + const [effectSoundVolume] = useSetting(soundEffectVolumeSetting); const devices = useMediaDevices(); const [audioContext, setAudioContext] = useState(); const [audioBuffers, setAudioBuffers] = useState>(); diff --git a/src/utils/test.ts b/src/utils/test.ts index 039b6983..6e1b5457 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -29,6 +29,10 @@ import { type Room as LivekitRoom, } from "livekit-client"; import { randomUUID } from "crypto"; +import { + type RoomAndToDeviceEvents, + type RoomAndToDeviceEventsHandlerMap, +} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { LocalUserMediaViewModel, @@ -269,8 +273,8 @@ export function mockConfig(config: Partial = {}): void { } export class MockRTCSession extends TypedEventEmitter< - MatrixRTCSessionEvent, - MatrixRTCSessionEventHandlerMap + MatrixRTCSessionEvent | RoomAndToDeviceEvents, + MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap > { public readonly statistics = { counters: {},