mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
add the CallFooterViewModel
This commit is contained in:
143
src/components/CallFooterViewModel.test.ts
Normal file
143
src/components/CallFooterViewModel.test.ts
Normal file
@@ -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<string, DeviceLabel>([
|
||||||
|
["mic1", { type: "name", name: "Microphone 1" }],
|
||||||
|
["mic2", { type: "name", name: "Microphone 2" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
videoInput: {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
302
src/components/CallFooterViewModel.tsx
Normal file
302
src/components/CallFooterViewModel.tsx
Normal file
@@ -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<FooterSnapshot>,
|
||||||
|
"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<boolean>,
|
||||||
|
): Pick<
|
||||||
|
ViewModel<FooterSnapshot>,
|
||||||
|
| "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<FooterSnapshot> {
|
||||||
|
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<FooterSnapshot> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user