From 936dfb193f0988cfd4e2544a73b8383d625bd799 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 11 May 2026 17:37:06 +0200 Subject: [PATCH] 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), + }; +}