diff --git a/locales/en/app.json b/locales/en/app.json index b51c6ed9..a14663e9 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -3,6 +3,7 @@ "user_menu": "User menu" }, "action": { + "blur_background": "Blur background", "close": "Close", "copy_link": "Copy link", "edit": "Edit", diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts index 1a8f2c28..bd4dd199 100644 --- a/playwright/reconnect.spec.ts +++ b/playwright/reconnect.spec.ts @@ -54,6 +54,8 @@ test("can only interact with header and footer while reconnecting", async ({ page.getByRole("switch", { name: "Mute microphone" }), ).toBeFocused(); await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused(); + await page.keyboard.press("Tab"); await expect(page.getByRole("switch", { 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/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7090a338..e9a7537c 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -5,18 +5,41 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { fn } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { BehaviorSubject } from "rxjs"; -import { type ReactNode } from "react"; +import { type JSX, type ReactNode } from "react"; import { Link } from "@vector-im/compound-web"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CallFooter, type FooterProps } from "./CallFooter"; +import { CallFooter, type FooterSnapshot } from "./CallFooter"; import inCallViewStyles from "../room/InCallView.module.css"; +import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; +import { type GridMode } from "../state/CallViewModel/CallViewModel"; +// consts for tests +const reactionIdentifier = "@user:example.com:DEVICE"; +const reactionData = { + handsRaised$: new BehaviorSubject({}), + reactions$: new BehaviorSubject({}), +}; -function CallFooterWrapper(props: FooterProps): ReactNode { +/** + * A wrapper component that is used for: + * - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm) + * - Add additional react context + * The paraeters are all params from the FooterSnapshot, + * the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter. + * `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really. + * @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook. + */ +function CallFooterStoryWrapper({ + children, + ...vmSnapshot +}: FooterSnapshot & { + children?: false | JSX.Element | JSX.Element[] | undefined; +}): ReactNode { + const vm = useStaticViewModel(vmSnapshot); return (
Promise.resolve(), }} > - +
); } const meta = { - component: CallFooterWrapper, -} satisfies Meta; + component: CallFooterStoryWrapper, +} satisfies Meta; export default meta; type Story = StoryObj; -const reactionIdentifier = "@user:example.com:DEVICE"; -const reactionData = { - handsRaised$: new BehaviorSubject({}), - reactions$: new BehaviorSubject({}), -}; - const fnArgType = { control: { type: "select" as const }, options: ["MockedCallback", "undefined"], mapping: { MockedCallback: fn(), undefined: undefined }, }; + export const Default: Story = { args: { - hideLogo: true, + showLogo: false, layoutMode: "grid", audioEnabled: true, videoEnabled: true, @@ -61,13 +79,34 @@ export const Default: Story = { toggleAudio: fn(), toggleVideo: fn(), toggleScreenSharing: fn(), + toggleBlur: fn(), + videoBlurEnabled: true, hangup: fn(), + buttonSize: "lg", + showFooter: true, + hideControls: false, + asOverlay: false, + sharingScreen: false, + audioOutputSwitcher: undefined, + reactionIdentifier: undefined, + reactionData: undefined, + debugTileLayout: false, + tileStoreGeneration: undefined, + audioOptions: [], + videoOptions: [], + selectedAudio: undefined, + selectedVideo: undefined, + selectAudioButtonOption: undefined, + selectVideoButtonOption: undefined, }, parameters: { layout: "fullscreen", }, argTypes: { - layoutMode: { control: "radio", options: ["grid", "spotlight"] }, + layoutMode: { + control: "radio", + options: ["grid", "spotlight"] satisfies GridMode[], + }, audioOutputSwitcher: { control: "select", options: ["NoOutputCallback", "speaker", "earpiece"], @@ -95,12 +134,12 @@ export const WithAudioAndVideoOptions: Story = { audioEnabled: false, videoEnabled: true, audioOptions: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], videoOptions: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], selectedAudio: "2", selectedVideo: "1", @@ -110,7 +149,7 @@ export const WithLogo: Story = { ...Default, args: { ...Default.args, - hideLogo: false, + showLogo: true, }, }; @@ -121,6 +160,51 @@ export const AudioVideoEnabled: Story = { audioEnabled: true, videoEnabled: true, }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" }); + await userEvent.click(spotlightRadio); + await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight"); + + const micButtonMute = canvas.getByRole("switch", { + name: "Mute microphone", + }); + await userEvent.click(micButtonMute); + await expect(args.toggleAudio).toHaveBeenCalled(); + + const videoMuteButton = canvas.getByRole("switch", { + name: "Stop video", + }); + await userEvent.click(videoMuteButton); + await expect(args.toggleVideo).toHaveBeenCalled(); + const screenShare = canvas.getByRole("switch", { + name: "Share screen", + }); + await userEvent.click(screenShare); + await expect(args.toggleScreenSharing).toHaveBeenCalled(); + const endCall = canvas.getByRole("button", { + name: "End call", + }); + await userEvent.click(endCall); + await expect(args.hangup).toHaveBeenCalled(); + }, +}; + +/** used to test switching to grid mode */ +export const SpotlightMode: Story = { + ...Default, + args: { + ...Default.args, + layoutMode: "spotlight", + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + const spotlightRadio = canvas.getByRole("radio", { name: "Grid" }); + await userEvent.click(spotlightRadio); + await expect(args.setLayoutMode).toHaveBeenCalledWith("grid"); + }, }; export const WithAudioOutputSpeaker: Story = { @@ -150,7 +234,37 @@ export const Pip: Story = { ...Default, args: { ...Default.args, - asPip: true, + buttonSize: "md", + layoutMode: undefined, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.queryByRole("radio", { name: "Spotlight" }), + ).not.toBeInTheDocument(); + + const micButtonMute = canvas.getByRole("switch", { + name: "Mute microphone", + }); + await userEvent.click(micButtonMute); + await expect(args.toggleAudio).toHaveBeenCalled(); + + const videoMuteButton = canvas.getByRole("switch", { + name: "Stop video", + }); + await userEvent.click(videoMuteButton); + await expect(args.toggleVideo).toHaveBeenCalled(); + const screenShare = canvas.getByRole("switch", { + name: "Share screen", + }); + await userEvent.click(screenShare); + await expect(args.toggleScreenSharing).toHaveBeenCalled(); + const endCall = canvas.getByRole("button", { + name: "End call", + }); + await userEvent.click(endCall); + await expect(args.hangup).toHaveBeenCalled(); }, }; export const NoControlsWithLogo: Story = { @@ -158,7 +272,7 @@ export const NoControlsWithLogo: Story = { args: { ...Default.args, hideControls: true, - hideLogo: false, + showLogo: true, }, }; @@ -187,7 +301,7 @@ export const MobileLayout: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, }, @@ -203,7 +317,7 @@ export const Lobby: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, openSettings: undefined, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -217,7 +331,7 @@ export const LobbyMobile: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -235,7 +349,7 @@ export const LobbyRecentButton: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, @@ -249,7 +363,7 @@ export const LobbyRecentButtonMobile: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index afc5bdc9..7dd68d88 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,13 +7,12 @@ Please see LICENSE in the repository root for full details. import { type FC, type JSX, type Ref, useMemo } from "react"; import classNames from "classnames"; -import { BehaviorSubject } from "rxjs"; -import { Switch } from "@vector-im/compound-web"; -import { t } from "i18next"; import { SpotlightIcon, GridIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Switch } from "@vector-im/compound-web"; +import { t } from "i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -34,105 +33,123 @@ import { MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; +import { type ViewModel } from "../state/ViewModel"; +import { useBehavior } from "../useBehavior"; export interface AudioOutputSwitcher { targetOutput: string; switch: () => void; } -export interface FooterProps { - ref?: Ref; - /** Children will only be visible if the component is wider than 5*/ - children?: JSX.Element | JSX.Element[] | false; - - audioEnabled: boolean; +/** + * The Snapshot combines all fields required to populate the view. + * + * It is a combination of Actions and State. + * All Actions and State will be wrappen in behaviors. + * This has the advantage, that actions can mutate. + * (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined) + * With it being reactive we can use the existance of the action to update the rendering without + * requiring additional state. + * + * Comment: It might not make sense to seperate the two interfaces. Hence the seperation + * just happens on the syntax level with the `type = ... & ...` notation. + */ +export type FooterSnapshot = FooterActions & FooterState; +export interface FooterActions { /** Also controls if the audioMute button is disabled */ toggleAudio: (() => void) | undefined; - videoEnabled: boolean; /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; + toggleBlur: (() => void) | undefined; + /** Also controls if the layout button is visible */ + setLayoutMode: ((mode: GridMode) => void) | undefined; + toggleScreenSharing: (() => void) | undefined; + /** Also controls if the settings button is visible */ + openSettings: (() => void) | undefined; + /** Also controls if the hangup button is visible */ + hangup: (() => void) | undefined; +} +// we do not use any ? optional properties so that the vm type is including all fields. +export interface FooterState { + audioEnabled: boolean; + videoEnabled: boolean; + videoBlurEnabled: boolean; + showFooter: boolean; /* This is needed for WindowMode = "flat" */ - hideControls?: boolean; - /** hide the entire footer*/ - hidden?: boolean; - /** Pip controls buttonSize and hides: settings button, layout switcher and logo */ - asPip?: boolean; + hideControls: boolean; /** The footer should be used as an overlay. - * (Over the Call Grid) This saves spaces on small screens.*/ - asOverlay?: boolean; + * (Over the Call Grid) This saves spaces on small screens. */ + asOverlay: boolean; - layoutMode?: GridMode; - /** Also controls if the layout button is visible */ - setLayoutMode?: (mode: GridMode) => void; + buttonSize: "md" | "lg"; + showLogo: boolean; - sharingScreen?: boolean; - toggleScreenSharing?: () => void; + layoutMode: GridMode | undefined; - /** Also controls if the audio button is visible */ - audioOutputSwitcher?: AudioOutputSwitcher; - /** Also controls if the settings button is visible */ - openSettings?: () => void; - /** Also controls if the hangup button is visible */ - hangup?: () => void; + sharingScreen: boolean; - reactionIdentifier?: string; - reactionData?: ReactionData; + /** Also controls if the audio output button is visible */ + audioOutputSwitcher: AudioOutputSwitcher | undefined; + + reactionIdentifier: string | undefined; + reactionData: ReactionData | undefined; - hideLogo?: boolean; // debug stuff - debugTileLayout?: boolean; - tileStoreGeneration?: number; + debugTileLayout: boolean; + tileStoreGeneration: number | undefined; - audioOptions?: MenuOptions[]; - videoOptions?: MenuOptions[]; - selectedAudio?: string; - selectedVideo?: string; - selectAudioDevice?: (deviceId: string) => void; - selectVideoDevice?: (deviceId: string) => void; + /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ + audioOptions: MenuOptions[]; + /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ + videoOptions: MenuOptions[]; + selectedAudio: string | undefined; + selectedVideo: string | undefined; + selectAudioButtonOption: ((deviceId: string) => void) | undefined; + selectVideoButtonOption: ((option: string) => void) | undefined; } -export const CallFooter: FC = ({ - ref, - children, - asOverlay, - hidden, - hideControls, - hideLogo, - asPip, - layoutMode, - setLayoutMode, - openSettings, - audioEnabled, - videoEnabled, - toggleAudio, - toggleVideo, - sharingScreen, - toggleScreenSharing, - reactionIdentifier, - reactionData, - audioOutputSwitcher, - hangup, - debugTileLayout, - tileStoreGeneration, +export interface FooterProps { + ref?: Ref; + children?: JSX.Element | JSX.Element[] | false; + vm: ViewModel; +} +export const CallFooter: FC = ({ ref, children, vm }) => { + const asOverlay = useBehavior(vm.asOverlay$); + const showFooter = useBehavior(vm.showFooter$); + const hideControls = useBehavior(vm.hideControls$); + const layoutMode = useBehavior(vm.layoutMode$); + const setLayoutMode = useBehavior(vm.setLayoutMode$); + const openSettings = useBehavior(vm.openSettings$); + const audioEnabled = useBehavior(vm.audioEnabled$); + const videoEnabled = useBehavior(vm.videoEnabled$); + const toggleAudio = useBehavior(vm.toggleAudio$); + const toggleVideo = useBehavior(vm.toggleVideo$); + const sharingScreen = useBehavior(vm.sharingScreen$); + const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$); + const reactionIdentifier = useBehavior(vm.reactionIdentifier$); + const reactionData = useBehavior(vm.reactionData$); + const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); + const hangup = useBehavior(vm.hangup$); + const debugTileLayout = useBehavior(vm.debugTileLayout$); + const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); + const videoOptions = useBehavior(vm.videoOptions$); + const selectedVideo = useBehavior(vm.selectedVideo$); + const audioOptions = useBehavior(vm.audioOptions$); + const selectedAudio = useBehavior(vm.selectedAudio$); + const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$); + const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); + const toggleBlur = useBehavior(vm.toggleBlur$); + const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); + const buttonSize = useBehavior(vm.buttonSize$); + const showLogo = useBehavior(vm.showLogo$); - audioOptions, - videoOptions, - selectedAudio, - selectedVideo, - selectAudioDevice, - selectVideoDevice, -}) => { const buttons: JSX.Element[] = []; - const buttonSize = asPip ? "md" : "lg"; - const showSettingsButton = - openSettings !== undefined && !asPip && !hideControls; - const showLayoutSwitcher = !asPip && !hideControls; - const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout); - const showLogo = !hideLogo && !asPip; - if (showSettingsButton) { - // add the settings button to the center group of buttons, so it will be visible on small screens. - // On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible. + + if (openSettings !== undefined) { + // Add the settings button to the center group so it's visible on small + // screens. On larger screens the SettingsIconButton with + // showForScreenWidth="wide" in the settingsLogoContainer is used instead. buttons.push( = ({ data-testid="incall_mute" options={audioOptions} selectedOption={selectedAudio} - onSelect={selectAudioDevice} + onSelect={selectAudioButtonOption} />, ); } else { @@ -169,6 +186,7 @@ export const CallFooter: FC = ({ />, ); } + if ((videoOptions?.length ?? 0) > 0) { buttons.push( = ({ iconsAndLabels="video" enabled={videoEnabled ?? false} onMuteClick={toggleVideo} - data-testid="incall_videomute" options={videoOptions} selectedOption={selectedVideo} - onSelect={selectVideoDevice} + onSelect={selectVideoButtonOption} + videoBlurToggleClick={toggleBlur} + videoBlurEnabled={videoBlurEnabled} />, ); } else { @@ -213,12 +232,7 @@ export const CallFooter: FC = ({ buttons.push( = ({ return (
- {showSettingsButton && ( + {openSettings !== undefined && ( = ({ /> )} {children} - {showLogoDebugContainer && logoDebugContainer} + {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && showLayoutSwitcher && ( + {!hideControls && setLayoutMode && layoutMode && ( name="layoutMode" aria-label={t("layout_switch_label")} diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts new file mode 100644 index 00000000..ef3b756e --- /dev/null +++ b/src/components/CallFooterViewModel.test.ts @@ -0,0 +1,157 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { BehaviorSubject } from "rxjs"; + +import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; +import { constant } from "../state/Behavior"; +import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; +import type { Alignment, Layout } from "../state/layout-types"; +import type { SpotlightTileViewModel } from "../state/TileViewModel"; +import type { DeviceLabel } from "../state/MediaDevices"; +import { createCallFooterViewModel } from "./CallFooterViewModel"; + +const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../Platform", () => ({ + get platform(): string { + return platformMock(); + }, +})); + +// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not +// exercised by these tests (only used in `videoToggles`, not `videoOptions`). +vi.mock("@livekit/track-processors", () => ({ + supportsBackgroundProcessors: (): boolean => false, +})); + +/** + * Returns the minimum set of CallViewModel fields required by + * createCallFooterViewModel, with all other properties stubbed to + * simple constant values. + */ +function buildMinimalCallViewModel(layout: Layout): CallViewModel { + return { + layout$: constant(layout), + edgeToEdge$: constant(false), + showHeader$: constant(false), + hangup: (): void => {}, + gridMode$: constant("grid"), + setGridMode: (): void => {}, + sharingScreen$: constant(false), + toggleScreenSharing: null, + audioOutputSwitcher$: constant(null), + handsRaised$: constant({}), + reactions$: constant({}), + tileStoreGeneration$: constant(0), + showFooter$: constant(true), + settingsOpen$: constant(false), + setSettingsOpen$: constant(() => {}), + } as unknown as CallViewModel; +} + +/** A regular grid layout (not PiP). */ +const gridLayout: Layout = { + type: "grid", + grid: [], + spotlightAlignment$: new BehaviorSubject({ + inline: "end", + block: "end", + }), + setVisibleTiles: (_: number) => {}, +}; + +/** A PiP layout – only the `type` matters for the tests. */ +const pipLayout: Layout = { + type: "pip", + spotlight: {} as SpotlightTileViewModel, +}; + +const twoMicsAndOneCamMediaDevices = mockMediaDevices({ + audioInput: { + available$: constant( + new Map([ + ["mic1", { type: "number", number: 1 }], + ["mic2", { type: "name", name: "Microphone 2" }], + ]), + ), + selected$: constant(undefined), + select: vi.fn(), + }, + videoInput: { + available$: constant( + new Map([ + ["cam1", { type: "name", name: "Camera 1" }], + ]), + ), + selected$: constant(undefined), + select: vi.fn(), + }, +}); + +describe("createCallFooterViewModel", () => { + describe("audioOptions and videoOptions", () => { + function checkEmptyFor(platform: string, layout: Layout): void { + platformMock.mockReturnValue(platform); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(layout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$.value).toEqual([]); + expect(vm.videoOptions$.value).toEqual([]); + } + it("are both empty when the platform is iOS", () => { + checkEmptyFor("ios", gridLayout); + }); + it("are both empty when the layout is pip", () => { + checkEmptyFor("desktop", pipLayout); + }); + + it("are populated when the platform is desktop and the layout is not PiP", () => { + platformMock.mockReturnValue("desktop"); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$?.value).toEqual([ + { + id: "mic1", + label: { + number: 1, + type: "number", + }, + }, + { + id: "mic2", + label: { + name: "Microphone 2", + type: "name", + }, + }, + ]); + expect(vm.videoOptions$?.value).toEqual([ + { + id: "cam1", + label: { + name: "Camera 1", + type: "name", + }, + }, + ]); + }); + }); +}); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx new file mode 100644 index 00000000..ec4d4800 --- /dev/null +++ b/src/components/CallFooterViewModel.tsx @@ -0,0 +1,272 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest, map, switchMap } from "rxjs"; +import { supportsBackgroundProcessors } from "@livekit/track-processors"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { type MenuOptions } from "./MediaMuteAndSwitchButton"; +import { type MediaDevices } from "../state/MediaDevices"; +import { + backgroundBlur as backgroundBlurSettings, + debugTileLayout as debugTileLayoutSetting, +} from "../settings/settings"; +import { type Behavior, constant } from "../state/Behavior"; +import type { ObservableScope } from "../state/ObservableScope"; +import { type MuteStates } from "../state/MuteStates"; +import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; +import { getUrlParams, HeaderStyle } from "../UrlParams"; +import { platform } from "../Platform"; +import { type FooterSnapshot } from "./CallFooter"; + +/** + * Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors + * needed by FooterSnapshot. + */ +function buildMuteBehaviors( + scope: ObservableScope, + muteStates: MuteStates, +): Pick< + ViewModel, + "audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$" +> { + return { + audioEnabled$: muteStates.audio.enabled$, + toggleAudio$: scope.behavior( + muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)), + ), + videoEnabled$: muteStates.video.enabled$, + toggleVideo$: scope.behavior( + muteStates.video.toggle$.pipe(map((t) => t ?? undefined)), + ), + }; +} + +/** + * Shared helper: maps MediaDevices into the audio/video device-list behaviors + * needed by FooterSnapshot (options, selection, callbacks, blur toggle). + */ +function buildDeviceBehaviors( + scope: ObservableScope, + mediaDevices: MediaDevices, + /** return empty arrays for audioOptions and videoOptions*/ + disableSwitcher$: Behavior, +): Pick< + ViewModel, + | "audioOptions$" + | "selectedAudio$" + | "selectAudioButtonOption$" + | "videoOptions$" + | "selectedVideo$" + | "selectVideoButtonOption$" + | "toggleBlur$" + | "videoBlurEnabled$" +> { + return { + audioOptions$: scope.behavior( + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as MenuOptions[]) + : mediaDevices.audioInput.available$.pipe( + map((available) => + [...available.entries()].map(([id, label]) => ({ + id, + label, + })), + ), + ), + ), + ), + ), + selectedAudio$: scope.behavior( + mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), + ), + selectAudioButtonOption$: constant(mediaDevices.audioInput.select), + videoOptions$: scope.behavior( + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as MenuOptions[]) + : mediaDevices.videoInput.available$.pipe( + map((available) => + [...available.entries()].map(([id, label]) => ({ + id, + label, + })), + ), + ), + ), + ), + ), + selectedVideo$: scope.behavior( + mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), + ), + selectVideoButtonOption$: constant(mediaDevices.videoInput.select), + toggleBlur$: scope.behavior( + combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe( + map(([current, switcherDisabled]) => { + return !switcherDisabled && supportsBackgroundProcessors() + ? (): void => { + backgroundBlurSettings.setValue(!current); + } + : undefined; + }), + ), + ), + videoBlurEnabled$: backgroundBlurSettings.value$, + }; +} + +/** + * Creates the ViewModel for the CallFooter. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc. + * @param muteStates - Audio and video mute state + toggles. + * @param mediaDevices - Available and selected input devices. + * @param reactionIdentifier - The local user's reaction identifier string, or + * undefined when reactions are not supported (hides the reaction button). + */ +export function createCallFooterViewModel( + scope: ObservableScope, + callModel: CallViewModel, + muteStates: MuteStates, + mediaDevices: MediaDevices, + reactionIdentifier: string | undefined, +): ViewModel { + const { showControls, header: headerStyle } = getUrlParams(); + const showLogo = headerStyle === HeaderStyle.Standard; + + const isPip$ = scope.behavior( + callModel.layout$.pipe(map((l) => l.type === "pip")), + ); + const disableDeviceSwitcher$ = scope.behavior( + isPip$.pipe(map((isPip) => isPip || platform !== "desktop")), + ); + return { + ...buildMuteBehaviors(scope, muteStates), + ...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$), + // candidat to move into the FooterViewModel + showFooter$: callModel.showFooter$, + hideControls$: constant(!showControls), + asOverlay$: callModel.edgeToEdge$, + buttonSize$: scope.behavior( + isPip$.pipe(map((pip) => (pip ? "md" : "lg"))), + ), + + openSettings$: scope.behavior( + combineLatest([ + isPip$, + callModel.showHeader$, + callModel.setSettingsOpen$, + ]).pipe( + map(([isPip, showHeader, setSettingsOpen]) => + !isPip && + !(headerStyle === HeaderStyle.AppBar && showHeader) && + showControls + ? (): void => setSettingsOpen(true) + : undefined, + ), + ), + ), + + showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), + + layoutMode$: callModel.gridMode$, + setLayoutMode$: scope.behavior( + isPip$.pipe( + map((isPip) => + !isPip && showControls ? callModel.setGridMode : undefined, + ), + ), + ), + + sharingScreen$: callModel.sharingScreen$, + toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), + + audioOutputSwitcher$: scope.behavior( + callModel.audioOutputSwitcher$.pipe( + map((switcher) => switcher ?? undefined), + ), + ), + + hangup$: constant(callModel.hangup), + + reactionIdentifier$: constant(reactionIdentifier), + reactionData$: constant( + reactionIdentifier !== undefined + ? { + handsRaised$: callModel.handsRaised$, + reactions$: callModel.reactions$, + } + : undefined, + ), + + debugTileLayout$: debugTileLayoutSetting.value$, + tileStoreGeneration$: callModel.tileStoreGeneration$, + }; +} + +/** + * Creates a simplified ViewModel for the CallFooter used in the lobby + * (pre-call) screen. Unlike createCallFooterViewModel, this does not require + * a CallViewModel — it only needs mute states, device lists, and callbacks. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param muteStates - Audio and video mute state + toggles. + * @param mediaDevices - Available and selected input devices. + * @param openSettings - Callback to open the settings modal, or undefined. + * @param hangup - Callback to leave/cancel, or undefined (hides the button). + * @param showLogo - Whether to show the Element Call logo. + */ +export function createLobbyFooterViewModel( + scope: ObservableScope, + muteStates: MuteStates, + mediaDevices: MediaDevices, + openSettings: (() => void) | undefined, + hangup: (() => void) | undefined, + showLogo: boolean, +): ViewModel { + return { + ...createStaticViewModel({ + // we can safly skip any props that we do not need. + // The view model will then have less keys. + // But as soon as we call `useViewModel` and convert back to a snapshot the missing props will + // be correcty matching the snapshot type. + showLogo, + hideControls: false, + asOverlay: false, + buttonSize: "lg", + showLayoutSwitcher: false, + openSettings, + hangup, + debugTileLayout: false, + showFooter: true, + toggleAudio: undefined, + toggleVideo: undefined, + setLayoutMode: undefined, + toggleScreenSharing: undefined, + audioEnabled: undefined, + videoEnabled: undefined, + layoutMode: undefined, + sharingScreen: false, + audioOutputSwitcher: undefined, + reactionIdentifier: undefined, + reactionData: undefined, + tileStoreGeneration: undefined, + audioOptions: undefined, + videoOptions: undefined, + selectedAudio: undefined, + selectedVideo: undefined, + selectAudioButtonOption: undefined, + selectVideoButtonOption: undefined, + }), + ...buildMuteBehaviors(scope, muteStates), + ...buildDeviceBehaviors(scope, mediaDevices, constant(false)), + }; +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index bbf9f159..b014cf9b 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { fn, userEvent, within, expect } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -21,17 +20,11 @@ type Story = StoryObj; export const Default: Story = { args: { title: "SomeMenu", - iconsAndLabels: { - IconEnabled: AdvancedSettingsIcon, - IconDisabled: AdvancedSettingsIcon, - enabledLabel: "Enabled", - disabledLabel: "Disabled", - optionsButtonLabel: "Options", - }, + iconsAndLabels: "audio", enabled: true, options: [ - { label: "option 1", id: "1" }, - { label: "option 2", id: "2" }, + { label: { type: "name", name: "Option 1" }, id: "1" }, + { label: { type: "name", name: "Option 2" }, id: "2" }, ], selectedOption: "1", onMuteClick: fn(), @@ -46,23 +39,18 @@ export const AudioMute: Story = { iconsAndLabels: "audio", enabled: false, options: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, - ], - toggles: [ - { - label: "example toggle", - id: "t0", - enabled: true, - }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], + videoBlurEnabled: true, + videoBlurToggleClick: fn(), selectedOption: "2", }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); // Both the mute button and the chevron trigger currently share the aria-label "Edit" // (both are TODO placeholders in the component). The mute button is first in the DOM. - const muteButton = canvas.getByLabelText("Unmute microphone"); + const muteButton = canvas.getByTestId("incall_mute"); await userEvent.click(muteButton); await expect(args.onMuteClick).toHaveBeenCalled(); }, @@ -74,10 +62,10 @@ export const AudioUnmute: Story = { iconsAndLabels: "audio", enabled: true, options: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], - toggles: [], + selectedOption: "2", }, }; @@ -88,10 +76,10 @@ export const VideoMute: Story = { iconsAndLabels: "video", enabled: false, options: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], - toggles: [], + selectedOption: "1", }, }; @@ -102,16 +90,11 @@ export const VideoUnmute: Story = { iconsAndLabels: "video", enabled: true, options: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, - ], - toggles: [ - { - label: "Blur Background", - id: "background_blurring", - enabled: false, - }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], + videoBlurEnabled: true, + videoBlurToggleClick: fn(), selectedOption: "2", }, }; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 42a8d970..80ee0254 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -9,13 +9,16 @@ import { describe, expect, test, vi } from "vitest"; import { act, render, screen, type RenderResult } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { type JSX, useState } from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; describe("MediaMuteAndSwitchButton", () => { test("renders", () => { const { container } = render( - , + + + , ); expect(container).toMatchSnapshot(); }); @@ -26,11 +29,13 @@ describe("MediaMuteAndSwitchButton", () => { enabled: boolean, ): RenderResult => { return render( - , + + + , ); }; const renderAudioEndabled = renderLabels("audio", true); @@ -39,16 +44,16 @@ describe("MediaMuteAndSwitchButton", () => { const renderVideoDisabled = renderLabels("video", false); expect( - renderAudioEndabled.getByRole("button", { name: "Mute microphone" }), + renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }), ).toBeInTheDocument(); expect( - renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }), + renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }), ).toBeInTheDocument(); expect( - renderVideoEnabled.getByRole("button", { name: "Start video" }), + renderVideoEnabled.getByRole("switch", { name: "Start video" }), ).toBeInTheDocument(); expect( - renderVideoDisabled.getByRole("button", { name: "Stop video" }), + renderVideoDisabled.getByRole("switch", { name: "Stop video" }), ).toBeInTheDocument(); }); @@ -56,15 +61,17 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onMute = vi.fn(); const { getByRole } = render( - , + + + , ); - await user.click(getByRole("button", { name: "Mute microphone" })); + await user.click(getByRole("switch", { name: "Mute microphone" })); expect(onMute).toHaveBeenCalled(); }); @@ -73,17 +80,19 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onSelect = vi.fn(); const { getByRole } = render( - , + + + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -95,17 +104,19 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onSelect = vi.fn(); const { getByRole } = render( - , + + + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -122,23 +133,25 @@ describe("MediaMuteAndSwitchButton", () => { function Wrapper(): JSX.Element { const [selectedOption, setSelectedOption] = useState("mic1"); return ( - { - onSelectPressed(); - void promise.then(() => { - setSelectedOption(id); - onOptionUpdated(); - }); - }} - /> + + { + onSelectPressed(); + void promise.then(() => { + setSelectedOption(id); + onOptionUpdated(); + }); + }} + /> + ); } @@ -174,42 +187,47 @@ describe("MediaMuteAndSwitchButton", () => { test("renders menu with toggle control and calls toggle callback", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); + const onVideoBlurToggle = vi.fn(); const { getByRole } = render( - , + + + , ); - await user.click(getByRole("button", { name: "Microphone" })); + await user.click(getByRole("button", { name: "Camera" })); const toggle = screen.getByRole("menuitemcheckbox", { - name: "Background blur", + name: "Blur background", }); expect(toggle).toBeInTheDocument(); expect(toggle).toHaveAttribute("aria-checked", "false"); await user.click(toggle); - expect(onSelect).toHaveBeenCalledWith("bg_blur"); + expect(onVideoBlurToggle).toHaveBeenCalled(); }); test("renders check icon to mark the selected menu item", async () => { const user = userEvent.setup(); const { getByRole } = render( - , + + + , ); // open menu diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 7e38c7c6..44bdf5e6 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -12,45 +12,25 @@ import { MenuItem, ToggleMenuItem, } from "@vector-im/compound-web"; -import { t } from "i18next"; import { CheckIcon, ChevronUpIcon, ChevronDownIcon, - MicOffSolidIcon, MicOnIcon, - MicOnSolidIcon, SpinnerIcon, VideoCallIcon, - VideoCallOffSolidIcon, - VideoCallSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { useTranslation } from "react-i18next"; import styles from "./MediaMuteAndSwitchButton.module.css"; +import { MicButton, VideoButton } from "../button"; +import { type DeviceLabel } from "../state/MediaDevices"; export interface MenuOptions { - label: string; + label: DeviceLabel; id: string; } -export interface ToggleOption { - label: string; - enabled: boolean; - id: string; -} - -export interface IconsAndLabels { - /** The Icon used if the mute button is enabled */ - IconEnabled: ComponentType>; - /** The Icon used if the mute button is disabled */ - IconDisabled: ComponentType>; - /** The icon used for the different options */ - IconOptions?: ComponentType>; - enabledLabel: string; - disabledLabel: string; - optionsButtonLabel: string; -} export interface MediaMuteAndSwitchButtonProps { /** The title used in the Switcher modal. */ @@ -59,17 +39,13 @@ export interface MediaMuteAndSwitchButtonProps { enabled?: boolean; /** Callback if the mute button is clicked */ onMuteClick?: () => void; - iconsAndLabels?: "video" | "audio" | IconsAndLabels; + iconsAndLabels: "video" | "audio"; /** The options available for the media device selector modal */ options?: MenuOptions[]; /** The option that will currently be rendered as the selected option */ selectedOption?: string; - /** - * The available toggles (including there current state) - * The toggle state is not stored by this component. - * It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled` - */ - toggles?: ToggleOption[]; + videoBlurToggleClick?: () => void; + videoBlurEnabled?: boolean; /** * For any toggle and option this method will be called. * So toggles need to be implemented by listening here and setting the right toggle item to `enabled` @@ -77,70 +53,80 @@ export interface MediaMuteAndSwitchButtonProps { onSelect?: (id: string) => void; } +const BLUR_ID = "blur"; + export const MediaMuteAndSwitchButton: FC = ({ title, enabled, onMuteClick, - iconsAndLabels: iconsAndLabelsWithDefaultCases, + iconsAndLabels, options, selectedOption, - toggles, + videoBlurEnabled, + videoBlurToggleClick, onSelect, }) => { const [plannedSelection, setPlannedSelection] = useState(null); const [menuOpen, setMenuOpen] = useState(false); - let iconsAndLabels: IconsAndLabels | undefined; - switch (iconsAndLabelsWithDefaultCases) { + const { t } = useTranslation(); + let button; + let toggles: { label: string; enabled: boolean; id: string }[] = []; + switch (iconsAndLabels) { case "video": - iconsAndLabels = { - IconEnabled: VideoCallSolidIcon, - IconDisabled: VideoCallOffSolidIcon, - IconOptions: VideoCallIcon, - disabledLabel: t("stop_video_button_label"), - enabledLabel: t("start_video_button_label"), - optionsButtonLabel: t("settings.devices.microphone"), - }; + button = ( + { + onMuteClick?.(); + e.preventDefault(); + e.stopPropagation(); + }} + disabled={onMuteClick === undefined} + data-testid="incall_videomute" + /> + ); + if (videoBlurToggleClick !== undefined) { + toggles = [ + { + label: t("action.blur_background"), + enabled: videoBlurEnabled ?? false, + id: BLUR_ID, + }, + ]; + } break; case "audio": - iconsAndLabels = { - IconEnabled: MicOnSolidIcon, - IconDisabled: MicOffSolidIcon, - IconOptions: MicOnIcon, - disabledLabel: t("mute_microphone_button_label"), - enabledLabel: t("unmute_microphone_button_label"), - optionsButtonLabel: t("settings.devices.microphone"), - }; - break; - default: - iconsAndLabels = iconsAndLabelsWithDefaultCases; + button = ( + { + onMuteClick?.(); + e.preventDefault(); + e.stopPropagation(); + }} + disabled={onMuteClick === undefined} + data-testid="incall_mute" + /> + ); break; } - const { - IconEnabled, - IconDisabled, - IconOptions, - disabledLabel, - enabledLabel, - optionsButtonLabel, - } = iconsAndLabels ?? { - IconEnabled: undefined, - IconDisabled: undefined, - IconOptions: undefined, - disabledLabel: undefined, - enabledLabel: undefined, - optionsButtonLabel: undefined, - }; - { - logger.info( - "RENDER WITH: selectedOption !== option.id && plannedSelection === option.id", - selectedOption, - " !==", - "option.id", - " && ", - plannedSelection, - " === ", - "option.id", - ); + + let IconOptions: ComponentType> | undefined; + let optionsButtonLabel: string; + let numberedLabel: (number: number) => string; + switch (iconsAndLabels) { + case "video": + IconOptions = VideoCallIcon; + optionsButtonLabel = t("settings.devices.camera"); + numberedLabel = (n): string => + t("settings.devices.microphone_numbered", { n }); + break; + case "audio": + IconOptions = MicOnIcon; + optionsButtonLabel = t("settings.devices.microphone"); + numberedLabel = (n): string => + t("settings.devices.camera_numbered", { n }); + break; } return (
= ({ })} > {/* The mute button lives inside */} -
- - {recentsButtonInFooter && recentsButton} - + {footerVm !== null && ( + + {recentsButtonInFooter && recentsButton} + + )}
{client && ( + + + + + Back to recents + + + + + +`; diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index af38685a..7d66f0ac 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -372,9 +372,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` local )

-
+    
       {
   "region": "local",
   "version": "1.2.3"
@@ -384,9 +382,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
     

Local Participant

-
+    
       localParticipantIdentity
     

@@ -413,9 +409,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` remote )

-
+    
       {
   "region": "remote",
   "version": "4.5.6"
@@ -425,9 +419,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
     

Local Participant

-
+    
       localParticipantIdentity
     

diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 9532b497..2bbf6f4e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -15,6 +15,7 @@ import { } from "livekit-client"; import { type Room as MatrixRoom } from "matrix-js-sdk"; import { + BehaviorSubject, catchError, combineLatest, distinctUntilChanged, @@ -38,7 +39,6 @@ import { tap, throttleTime, timer, - BehaviorSubject, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { @@ -356,6 +356,9 @@ export interface CallViewModel { */ edgeToEdge$: Behavior; + settingsOpen$: Behavior; + setSettingsOpen$: Behavior<(open: boolean) => void>; + // audio routing /** * Whether audio is currently being output through the earpiece. @@ -1397,6 +1400,10 @@ export function createCallViewModel$( map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams), ), ); + const settingsOpen$ = new BehaviorSubject(false); + const setSettingsOpen$ = constant((open: boolean) => { + settingsOpen$.next(open); + }); const showHeader$ = scope.behavior( windowMode$.pipe( @@ -1751,6 +1758,8 @@ export function createCallViewModel$( showNameTags$, showHeader$: showHeader$, showFooter$: showFooter$, + settingsOpen$: settingsOpen$, + setSettingsOpen$: setSettingsOpen$, edgeToEdge$, earpieceMode$: earpieceMode$, audioOutputSwitcher$: audioOutputSwitcher$, diff --git a/src/state/ViewModel.ts b/src/state/ViewModel.ts new file mode 100644 index 00000000..9d245b71 --- /dev/null +++ b/src/state/ViewModel.ts @@ -0,0 +1,49 @@ +/* +Copyright 2026 Element Software Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { BehaviorSubject } from "rxjs"; +import { useState, useEffect } from "react"; + +import { type Behavior } from "./Behavior"; + +export type ViewModel = { + [K in keyof Snapshot as `${string & K}$`]: Behavior; +}; + +/** + * This allows to build a view model (or Partial view model) + * with BehaviorSubjects. + * It can be used in tests and for simplifying view model creation for non reactive snapshot parameters. + * + * @param snapshot The snapshot values this view model with start with. ({a: number, b: string}) + * @returns A view model: ({a$: BehaviroSubject, b$: BehaviroSubject}) (note the automatic addition of $ at the end of the keys) + */ +export function createStaticViewModel( + snapshot: Snapshot, +): ViewModel { + const vm = {} as ViewModel; + for (const key in snapshot) { + (vm as Record>)[`${key}$`] = new BehaviorSubject( + snapshot[key], + ); + } + return vm; +} + +export function useStaticViewModel( + snapshot: Snapshot, +): ViewModel { + const [vm] = useState(() => createStaticViewModel(snapshot)); + useEffect(() => { + for (const key in snapshot) { + (vm as unknown as Record>)[ + `${key}$` + ].next(snapshot[key]); + } + }, [snapshot, vm]); + return vm; +} diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index b5438371..7c670308 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -39,6 +39,9 @@ import { aliceRtcMember, localRtcMember } from "./test-fixtures"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; import { MatrixRTCMode } from "../settings/settings"; +import { createCallFooterViewModel } from "../components/CallFooterViewModel"; +import { type FooterSnapshot } from "../components/CallFooter"; +import { type ViewModel } from "../state/ViewModel"; mockConfig({ livekit: { livekit_service_url: "https://example.com" } }); @@ -136,6 +139,7 @@ export function getBasicCallViewModelEnvironment( callViewModelOptions: Partial = {}, ): { vm: CallViewModel; + footerVm: ViewModel; rtcMemberships$: BehaviorSubject; rtcSession: MockRTCSession; handRaisedSubject$: BehaviorSubject>; @@ -148,12 +152,15 @@ export function getBasicCallViewModelEnvironment( const handRaisedSubject$ = new BehaviorSubject({}); const reactionsSubject$ = new BehaviorSubject({}); + const scope = testScope(); + const muteStates = mockMuteStates(); + const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({}); const vm = createCallViewModel$( - testScope(), + scope, rtcSession.asMockedSession(), matrixRoom, - mediaDevicesOverride ?? mockMediaDevices({}), - mockMuteStates(), + mediaDevices, + muteStates, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, livekitRoomFactory: (): LivekitRoom => @@ -171,8 +178,16 @@ export function getBasicCallViewModelEnvironment( reactionsSubject$, constant({ processor: undefined, supported: false }), ); + const footerVm = createCallFooterViewModel( + testScope(), + vm, + muteStates, + mediaDevices, + "reactionId", + ); return { vm, + footerVm, rtcMemberships$, rtcSession, handRaisedSubject$: handRaisedSubject$, diff --git a/src/widget.ts b/src/widget.ts index 2ec76e15..462fc6e0 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -61,7 +61,7 @@ export interface WidgetHelpers { * is initialized with `initializeWidget`. This should happen at the top level because the widget messaging * needs to be set up ASAP on load to ensure it doesn't miss any requests. */ -export let widget: WidgetHelpers | null; +export let widget: WidgetHelpers | null = null; /** * Should be called as soon as possible on app start. (In the initilizer before react) diff --git a/vitest.config.ts b/vitest.config.ts index 290d1d74..2dc9382c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,13 +12,14 @@ export default defineConfig((configEnv) => vitePluginsConfig(configEnv), defineConfig({ test: { - fileParallelism: false, + fileParallelism: true, projects: [ { extends: true, test: { name: "unit", css: { + include: /.+/, modules: { classNameStrategy: "non-scoped", },