From 9f6ae4ba55d99a087ae3459ac97839a89709ddd8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 11 May 2026 17:27:38 +0200 Subject: [PATCH 01/31] small device selection refactor --- src/settings/DeviceSelection.tsx | 73 ++++++++++++++------------------ 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 197046c3..f189348b 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type ChangeEvent, - type FC, - type ReactElement, - type ReactNode, - useCallback, - useId, -} from "react"; +import { type ChangeEvent, type FC, useCallback, useId } from "react"; import { Heading, InlineField, @@ -20,8 +13,8 @@ import { RadioControl, Separator, } from "@vector-im/compound-web"; -import { Trans, useTranslation } from "react-i18next"; import { useObservableEagerState } from "observable-hooks"; +import { t } from "i18next"; import { type AudioOutputDeviceLabel, @@ -37,12 +30,39 @@ interface Props { numberedLabel: (number: number) => string; } +export function mediaDeviceLabelToString( + label: DeviceLabel | AudioOutputDeviceLabel, + numberedLabel: (number: number) => string, +): string { + let labelText = ""; + switch (label.type) { + case "name": + labelText = label.name; + break; + case "number": + labelText = numberedLabel(label.number); + break; + case "default": + labelText = + label.name === null + ? t("settings.devices.default") + : t("settings.devices.default") + " (" + label.name + ")"; + break; + case "speaker": + labelText = t("settings.devices.loudspeaker"); + break; + case "earpiece": + labelText = t("settings.devices.handset"); + break; + } + return labelText; +} + export const DeviceSelection: FC = ({ device, title, numberedLabel, }) => { - const { t } = useTranslation(); const groupId = useId(); const available = useObservableEagerState(device.available$); const selectedId = useObservableEagerState(device.selected$)?.id; @@ -70,38 +90,7 @@ export const DeviceSelection: FC = ({
{[...available].map(([id, label]) => { - let labelText: ReactNode; - switch (label.type) { - case "name": - labelText = label.name; - break; - case "number": - labelText = numberedLabel(label.number); - break; - case "default": - labelText = - label.name === null ? ( - t("settings.devices.default") - ) : ( - - Default{" "} - - ({{ name: label.name } as unknown as ReactElement}) - - - ); - break; - case "speaker": - labelText = t("settings.devices.loudspeaker"); - break; - case "earpiece": - labelText = t("settings.devices.handset"); - break; - } - + const labelText = mediaDeviceLabelToString(label, numberedLabel); return ( Date: Mon, 11 May 2026 17:29:41 +0200 Subject: [PATCH 02/31] Introduce ViewModel.ts --- src/state/ViewModel.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/state/ViewModel.ts diff --git a/src/state/ViewModel.ts b/src/state/ViewModel.ts new file mode 100644 index 00000000..12016cd4 --- /dev/null +++ b/src/state/ViewModel.ts @@ -0,0 +1,39 @@ +/* +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 { useBehavior } from "../useBehavior"; +import { type Behavior } from "./Behavior"; + +export type ViewModel = { + [K in keyof Snapshot as `${string & K}$`]: Behavior; +}; + +export function useViewModel(vm: ViewModel): Snapshot { + const snapshot = {} as Snapshot; + for (const key in vm) { + const value$ = (vm as Record>)[key]; + const snapshotKey = key.slice(0, -1) as keyof Snapshot; + // we allow using hooks in a loop here because we know the shape of the vm is static and won't change between renders, so the order of hooks calls will always be the same. + // eslint-disable-next-line react-hooks/rules-of-hooks + snapshot[snapshotKey] = useBehavior(value$) as Snapshot[keyof Snapshot]; + } + return snapshot; +} + +export function createMockedViewModel( + snapshot: Snapshot, +): ViewModel { + const vm = {} as ViewModel; + for (const key in snapshot) { + (vm as Record>)[`${key}$`] = new BehaviorSubject( + snapshot[key], + ); + } + return vm; +} From 7615e146a53adf2191a8fad83c0bf2e4306b54a8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 11 May 2026 17:36:54 +0200 Subject: [PATCH 03/31] Use ViewModel approach for the CallFooterView + interaction tests (this is just imaginary. There is no view model yet.) --- locales/en/app.json | 1 - src/components/CallFooter.module.css | 4 - src/components/CallFooter.stories.tsx | 143 +++++++++++++++++++++----- src/components/CallFooter.tsx | 130 +++++++++++------------ 4 files changed, 185 insertions(+), 93 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index b51c6ed9..1d5eaa19 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -202,7 +202,6 @@ "camera_numbered": "Camera {{n}}", "change_device_button": "Change audio device", "default": "Default", - "default_named": "Default <2>({{name}})", "handset": "Handset", "loudspeaker": "Loudspeaker", "microphone": "Microphone", diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index adff99d5..228d7654 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details. ); } -.footer.hidden { - display: none; -} - .footer.overlay { /* Note that the footer is still position: sticky in this case so that certain tiles can move up out of the way of the footer when visible. */ diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7090a338..129c2b3a 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -5,18 +5,40 @@ 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 { createMockedViewModel } 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 + * @param chilren used for the "Back to Recents" button in the lobby stories, but can be used for anything really + * @param vmSnapshot the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter. + * @returns + */ +function CallFooterStoryWrapper({ + children, + ...vmSnapshot +}: FooterSnapshot & { + children?: false | JSX.Element | JSX.Element[] | undefined; +}): ReactNode { + const vm = createMockedViewModel(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, @@ -62,12 +79,16 @@ export const Default: Story = { toggleVideo: fn(), toggleScreenSharing: fn(), hangup: fn(), + buttonSize: "lg", }, 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"], @@ -110,7 +131,7 @@ export const WithLogo: Story = { ...Default, args: { ...Default.args, - hideLogo: false, + showLogo: true, }, }; @@ -121,6 +142,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 +216,38 @@ export const Pip: Story = { ...Default, args: { ...Default.args, - asPip: true, + buttonSize: "md", + showSettingsButton: false, + 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 +255,7 @@ export const NoControlsWithLogo: Story = { args: { ...Default.args, hideControls: true, - hideLogo: false, + showLogo: true, }, }; @@ -187,7 +284,7 @@ export const MobileLayout: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, }, @@ -203,7 +300,7 @@ export const Lobby: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, openSettings: undefined, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -217,7 +314,7 @@ export const LobbyMobile: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -235,7 +332,7 @@ export const LobbyRecentButton: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, @@ -249,7 +346,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..bbb8f7e2 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"; @@ -33,35 +32,36 @@ import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { MediaMuteAndSwitchButton, type MenuOptions, + type ToggleOption, } from "./MediaMuteAndSwitchButton"; +import { type ViewModel, useViewModel } from "../state/ViewModel"; 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; - +export interface FooterSnapshot { audioEnabled: boolean; /** Also controls if the audioMute button is disabled */ toggleAudio: (() => void) | undefined; + videoEnabled: boolean; /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; /* 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; /** The footer should be used as an overlay. - * (Over the Call Grid) This saves spaces on small screens.*/ + * (Over the Call Grid) This saves spaces on small screens. */ asOverlay?: boolean; + buttonSize: "md" | "lg"; + showSettingsButton?: boolean; + showLayoutSwitcher?: boolean; + showLogoDebugContainer?: boolean; + showLogo?: boolean; + layoutMode?: GridMode; /** Also controls if the layout button is visible */ setLayoutMode?: (mode: GridMode) => void; @@ -69,7 +69,7 @@ export interface FooterProps { sharingScreen?: boolean; toggleScreenSharing?: () => void; - /** Also controls if the audio button is visible */ + /** Also controls if the audio output button is visible */ audioOutputSwitcher?: AudioOutputSwitcher; /** Also controls if the settings button is visible */ openSettings?: () => void; @@ -79,60 +79,64 @@ export interface FooterProps { reactionIdentifier?: string; reactionData?: ReactionData; - hideLogo?: boolean; // debug stuff debugTileLayout?: boolean; tileStoreGeneration?: number; + /** 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; selectedVideo?: string; - selectAudioDevice?: (deviceId: string) => void; - selectVideoDevice?: (deviceId: string) => void; + selectAudioButtonOption?: (deviceId: string) => void; + selectVideoButtonOption?: (option: string) => void; + videoToggles?: ToggleOption[]; } -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, + hideControls, + layoutMode, + setLayoutMode, + openSettings, + audioEnabled, + videoEnabled, + toggleAudio, + toggleVideo, + sharingScreen, + toggleScreenSharing, + reactionIdentifier, + reactionData, + audioOutputSwitcher, + hangup, + debugTileLayout, + tileStoreGeneration, + videoOptions, + selectedVideo, + audioOptions, + selectedAudio, + selectAudioButtonOption, + selectVideoButtonOption, + videoToggles, + buttonSize, + showSettingsButton, + showLogoDebugContainer, + showLogo, + } = useViewModel(vm); - 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. + // 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 +173,7 @@ export const CallFooter: FC = ({ />, ); } + if ((videoOptions?.length ?? 0) > 0) { buttons.push( = ({ onMuteClick={toggleVideo} data-testid="incall_videomute" options={videoOptions} + toggles={videoToggles} selectedOption={selectedVideo} - onSelect={selectVideoDevice} + onSelect={selectVideoButtonOption} />, ); } else { @@ -213,12 +219,7 @@ export const CallFooter: FC = ({ buttons.push( = ({ ref={ref} className={classNames(styles.footer, { [styles.overlay]: asOverlay, - [styles.hidden]: hidden, })} >
@@ -288,7 +288,7 @@ export const CallFooter: FC = ({ {showLogoDebugContainer && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && showLayoutSwitcher && ( + {setLayoutMode && layoutMode && ( name="layoutMode" aria-label={t("layout_switch_label")} From 936dfb193f0988cfd4e2544a73b8383d625bd799 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 11 May 2026 17:37:06 +0200 Subject: [PATCH 04/31] add the CallFooterViewModel --- src/components/CallFooterViewModel.test.ts | 143 ++++++++++ src/components/CallFooterViewModel.tsx | 302 +++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 src/components/CallFooterViewModel.test.ts create mode 100644 src/components/CallFooterViewModel.tsx diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts new file mode 100644 index 00000000..601a8393 --- /dev/null +++ b/src/components/CallFooterViewModel.test.ts @@ -0,0 +1,143 @@ +/* +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 { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; +import { constant } from "../state/Behavior"; +import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; +import type { 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), + windowMode$: constant("normal"), + 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), + } as unknown as CallViewModel; +} + +/** A regular grid layout (not PiP). */ +const gridLayout: Layout = { + type: "grid", + grid: [], + setVisibleTiles: (): void => {}, +}; + +/** 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: "name", name: "Microphone 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", () => { + it("are empty when the layout is PiP on desktop", () => { + platformMock.mockReturnValue("desktop"); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(pipLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* openSettings */ undefined, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$?.value).toEqual([]); + expect(vm.videoOptions$?.value).toEqual([]); + }); + + it("are empty when the platform is iOS regardless of layout", () => { + platformMock.mockReturnValue("ios"); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* openSettings */ undefined, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$?.value).toEqual([]); + expect(vm.videoOptions$?.value).toEqual([]); + }); + + 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, + /* openSettings */ undefined, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$?.value).toEqual([ + { id: "mic1", label: "Microphone 1" }, + { id: "mic2", label: "Microphone 2" }, + ]); + expect(vm.videoOptions$?.value).toEqual([ + { id: "cam1", label: "Camera 1" }, + ]); + }); + }); +}); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx new file mode 100644 index 00000000..c1ca11fa --- /dev/null +++ b/src/components/CallFooterViewModel.tsx @@ -0,0 +1,302 @@ +/* +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, + type ToggleOption, +} from "./MediaMuteAndSwitchButton"; +import { type MediaDevices } from "../state/MediaDevices"; +import { mediaDeviceLabelToString } from "../settings/DeviceSelection"; +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 { 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$" + | "videoToggles$" +> { + 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: mediaDeviceLabelToString( + label, + (n) => "Audio Device " + n, + ), + })), + ), + ), + ), + ), + ), + 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: mediaDeviceLabelToString( + label, + (n) => "Camera " + n, + ), + })), + ), + ), + ), + ), + ), + selectedVideo$: scope.behavior( + mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), + ), + selectVideoButtonOption$: scope.behavior( + backgroundBlurSettings.value$.pipe( + map((current) => { + return (option: string) => { + if (option === "blur") { + backgroundBlurSettings.setValue(!current); + } else { + mediaDevices.videoInput.select(option); + } + }; + }), + ), + ), + videoToggles$: scope.behavior( + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as ToggleOption[]) + : backgroundBlurSettings.value$.pipe( + map((blurActive) => + supportsBackgroundProcessors() + ? [ + { + id: "blur", + enabled: blurActive, + label: "Blur Background", + }, + ] + : [], + ), + ), + ), + ), + ), + }; +} + +/** + * Creates the ViewModel for the CallFooter. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param vm - 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 openSettings - Callback to open the settings modal, or undefined if the + * settings button should be hidden (e.g. when it is already shown in an app bar). + * @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, + openSettings: (() => void) | undefined, + reactionIdentifier: string | undefined, +): ViewModel { + const { showControls, header: headerStyle } = getUrlParams(); + + const hideLogo = 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$), + + hideControls$: constant(!showControls), + asOverlay$: scope.behavior( + callModel.windowMode$.pipe(map((mode) => mode === "flat")), + ), + buttonSize$: scope.behavior( + isPip$.pipe(map((pip) => (pip ? "md" : "lg") as "md" | "lg")), + ), + showSettingsButton$: scope.behavior( + combineLatest([isPip$, callModel.showHeader$]).pipe( + map( + ([isPip, showHeader]) => + openSettings !== undefined && + !isPip && + showControls && + !(headerStyle === HeaderStyle.AppBar && showHeader), + ), + ), + ), + showLayoutSwitcher$: scope.behavior( + isPip$.pipe(map((l) => !isPip$ && showControls)), + ), + showLogoDebugContainer$: scope.behavior( + combineLatest([isPip$, debugTileLayoutSetting.value$]).pipe( + map(([isPip, debugTile]) => !isPip || (!hideLogo && !debugTile)), + ), + ), + showLogo$: scope.behavior(isPip$.pipe(map((l) => !hideLogo && !isPip$))), + + layoutMode$: callModel.gridMode$, + setLayoutMode$: constant(callModel.setGridMode), + + sharingScreen$: callModel.sharingScreen$, + toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), + + audioOutputSwitcher$: scope.behavior( + callModel.audioOutputSwitcher$.pipe( + map((switcher) => switcher ?? undefined), + ), + ), + + openSettings$: scope.behavior( + callModel.showHeader$.pipe( + map((showHeader) => + headerStyle === HeaderStyle.AppBar && showHeader + ? undefined + : openSettings, + ), + ), + ), + 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 { + ...buildMuteBehaviors(scope, muteStates), + ...buildDeviceBehaviors(scope, mediaDevices, constant(false)), + hideControls$: constant(false), + asOverlay$: constant(false), + buttonSize$: constant("lg"), + showSettingsButton$: constant(openSettings !== undefined), + showLayoutSwitcher$: constant(false), + showLogoDebugContainer$: constant(showLogo), + showLogo$: constant(showLogo), + + layoutMode$: constant(undefined), + setLayoutMode$: constant(undefined), + + sharingScreen$: constant(undefined), + toggleScreenSharing$: constant(undefined), + + audioOutputSwitcher$: constant(undefined), + + openSettings$: constant(openSettings), + hangup$: constant(hangup), + + reactionIdentifier$: constant(undefined), + reactionData$: constant(undefined), + + debugTileLayout$: constant(false), + tileStoreGeneration$: constant(0), + }; +} From d29b92058109af55a7ab5f94b52163eea1fb8d5c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 11 May 2026 17:53:09 +0200 Subject: [PATCH 05/31] use footer view model in lobby --- src/room/LobbyView.tsx | 44 +++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 277ed61c..cec9f6ac 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery"; import { E2eeType } from "../e2ee/e2eeType"; import { Link } from "../button/Link"; import { useMediaDevices } from "../MediaDevicesContext"; +import { ObservableScope } from "../state/ObservableScope"; import { useInitial } from "../useInitial"; import { useTrackProcessor, @@ -46,8 +47,10 @@ import { import { usePageTitle } from "../usePageTitle"; import { getValue } from "../utils/observable"; import { useBehavior } from "../useBehavior"; -import { CallFooter } from "../components/CallFooter"; +import { CallFooter, type FooterSnapshot } from "../components/CallFooter"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; +import { createLobbyFooterViewModel } from "../components/CallFooterViewModel"; +import { type ViewModel } from "../state/ViewModel"; interface Props { client: MatrixClient; @@ -112,6 +115,7 @@ export const LobbyView: FC = ({ logger.error("Failed to navigate to /", error); }); }, [navigate]); + const hangup = confineToRoom ? undefined : onLeaveClick; const recentsButtonInFooter = useMediaQuery("(max-height: 500px)"); const recentsButton = !confineToRoom && ( @@ -184,6 +188,27 @@ export const LobbyView: FC = ({ useTrackProcessorSync(videoTrack); + const [footerVm, setFooterVm] = useState | null>( + null, + ); + useEffect(() => { + const footerScope = new ObservableScope(); + setFooterVm( + createLobbyFooterViewModel( + footerScope, + muteStates, + devices, + openSettings, + hangup, + // Logo and header are connected: only show the logo in SPA with header. + !hideHeader, + ), + ); + return (): void => { + footerScope.end(); + }; + }, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]); + // TODO: Unify this component with InCallView, so we can get slick joining // animations and don't have to feel bad about reusing its CSS return ( @@ -227,18 +252,11 @@ export const LobbyView: FC = ({ {!recentsButtonInFooter && recentsButton}
- - {recentsButtonInFooter && recentsButton} - + {footerVm !== null && ( + + {recentsButtonInFooter && recentsButton} + + )} {client && ( Date: Mon, 11 May 2026 17:53:24 +0200 Subject: [PATCH 06/31] use footer view model in InCallview --- src/room/InCallView.tsx | 77 +++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 49 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7fc97e27..932bb40d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -43,7 +43,6 @@ import { InviteButton } from "../button/InviteButton"; import { type CallViewModel, createCallViewModel$, - type GridMode, } from "../state/CallViewModel/CallViewModel.ts"; import { Grid, type TileProps } from "../grid/Grid"; import { useInitial } from "../useInitial"; @@ -68,11 +67,7 @@ import { import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsOverlay } from "./ReactionsOverlay"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; -import { - debugTileLayout as debugTileLayoutSetting, - matrixRTCMode as matrixRTCModeSetting, - useSetting, -} from "../settings/settings"; +import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; @@ -90,8 +85,10 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t import { type Layout } from "../state/layout-types.ts"; import { ObservableScope } from "../state/ObservableScope.ts"; import { useLatest } from "../useLatest.ts"; -import { CallFooter } from "../components/CallFooter.tsx"; +import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx"; import { SettingsIconButton } from "../button/Button.tsx"; +import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx"; +import { type ViewModel } from "../state/ViewModel.ts"; const logger = rootLogger.getChild("[InCallView]"); @@ -220,9 +217,7 @@ export const InCallView: FC = ({ muted: muteAllAudio, }); const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); - - const audioEnabled = useBehavior(muteStates.audio.enabled$); - const videoEnabled = useBehavior(muteStates.video.enabled$); + const mediaDevices = useMediaDevices(); const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); @@ -241,14 +236,10 @@ export const InCallView: FC = ({ const reconnecting = useBehavior(vm.reconnecting$); const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); - const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); - const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useBehavior(vm.gridMode$); const showHeader = useBehavior(vm.showHeader$); const showFooter = useBehavior(vm.showFooter$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - const sharingScreen = useBehavior(vm.sharingScreen$); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary @@ -348,11 +339,6 @@ export const InCallView: FC = ({ () => new BehaviorSubject(defaultPipAlignment), ); - const setGridMode = useCallback( - (mode: GridMode) => vm.setGridMode(mode), - [vm], - ); - useAppBarHidden(!showHeader); let header: ReactNode = null; @@ -558,9 +544,28 @@ export const InCallView: FC = ({ const rageshakeRequestModalProps = useRageshakeRequestModal( matrixRoom.roomId, ); + const [footerVm, setFooterVm] = useState | null>( + null, + ); + useEffect(() => { + const footerScope = new ObservableScope(); + setFooterVm( + createCallFooterViewModel( + footerScope, + vm, + muteStates, + mediaDevices, + openSettings, + supportsReactions + ? `${client.getUserId()}:${client.getDeviceId()}` + : undefined, + ), + ); + return (): void => { + footerScope.end(); + }; + }, [client, mediaDevices, muteStates, openSettings, supportsReactions, vm]); - const settingsButtonInAppBar = - headerStyle === HeaderStyle.AppBar && showHeader; useAppBarSecondaryButton( = ({ ); // Only hide the settings button if we have an AppBar header and we are showing the header - const footer = ( -