move call footer to viewModel interface approach

This commit is contained in:
Timo K
2026-05-07 18:10:46 +02:00
parent 95a0dcf389
commit 183af5117e
6 changed files with 450 additions and 212 deletions

View File

@@ -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. */

View File

@@ -7,16 +7,23 @@ Please see LICENSE in the repository root for full details.
import { fn } 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";
function CallFooterWrapper(props: FooterProps): ReactNode {
export function CallFooterStoryWrapper(
props: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
},
): ReactNode {
const { children, ...vmProps } = props;
const vm = createMockedViewModel(vmProps);
return (
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
@@ -26,15 +33,15 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter {...props} />
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
);
}
const meta = {
component: CallFooterWrapper,
} satisfies Meta<typeof CallFooterWrapper>;
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -50,9 +57,10 @@ const fnArgType = {
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
hideLogo: true,
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
@@ -62,6 +70,7 @@ export const Default: Story = {
toggleVideo: fn(),
toggleScreenSharing: fn(),
hangup: fn(),
buttonSize: "lg",
},
parameters: {
layout: "fullscreen",
@@ -110,7 +119,7 @@ export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
showLogo: true,
},
};
@@ -150,7 +159,9 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined,
},
};
export const NoControlsWithLogo: Story = {
@@ -158,7 +169,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
hideLogo: false,
showLogo: true,
},
};
@@ -187,7 +198,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -203,7 +214,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +228,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -235,7 +246,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -249,7 +260,7 @@ export const LobbyRecentButtonMobile: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},

View File

@@ -7,8 +7,8 @@ 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, of } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { combineLatest, map } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -25,50 +25,53 @@ import {
} from "../button";
import styles from "./CallFooter.module.css";
import { LayoutToggle } from "../room/LayoutToggle";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
type CallViewModel,
type GridMode,
} from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import {
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedDevice,
} from "../state/MediaDevices";
import { type MediaDevices } from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import {
backgroundBlur as backgroundBlurSettings,
useSetting,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
import { constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { type ViewModel, useViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
export interface AudioOutputSwitcher {
targetOutput: string;
switch: () => void;
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
/** 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;
@@ -76,7 +79,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;
@@ -86,7 +89,6 @@ export interface FooterProps {
reactionIdentifier?: string;
reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
@@ -95,76 +97,311 @@ export interface FooterProps {
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
/**
* If provided the footer will use the switchAndMute buttons.
* If not provided it will use the normal mute Buttons
*/
audioDevice?: MediaDevice<
DeviceLabel | AudioOutputDeviceLabel,
SelectedDevice
>;
/**
* If provided the footer will use the switchAndMute buttons.
* If not provided it will use the normal mute Buttons
*/
videoDevice?: MediaDevice<DeviceLabel, SelectedDevice>;
selectAudioButtonOption?: (deviceId: string) => void;
selectVideoButtonOption?: (option: string) => void;
videoToggles?: ToggleOption[];
}
export const CallFooter: FC<FooterProps> = ({
ref,
children,
asOverlay,
hidden,
hideControls,
hideLogo,
asPip,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
/**
* 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)),
),
};
}
audioDevice,
videoDevice,
}) => {
const videoOptions = useObservableEagerState(
videoDevice?.available$ ?? of(new Map()),
);
const selectedVideo = useObservableEagerState(
videoDevice?.selected$ ?? of(undefined),
);
const audioOptions = useObservableEagerState(
audioDevice?.available$ ?? of(new Map()),
);
const selectedAudio = useObservableEagerState(
audioDevice?.selected$ ?? of(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,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions"
| "selectedAudio"
| "selectAudioButtonOption"
| "videoOptions"
| "selectedVideo"
| "selectVideoButtonOption"
| "videoToggles"
> {
return {
audioOptions: scope.behavior(
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(
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(
backgroundBlurSettings.value$.pipe(
map((blurActive) =>
supportsBackgroundProcessors()
? [{ id: "blur", enabled: blurActive, label: "Blur Background" }]
: [],
),
),
),
};
}
const { supported: blurSupported } = useTrackProcessor();
const [blurActive, setBlurActive] = useSetting(backgroundBlurSettings);
/**
* 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 hideControls - When true the button row is hidden (from URL param).
* @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;
return {
...buildMuteBehaviors(scope, muteStates),
// ── Visibility / sizing ──────────────────────────────────────────────────
hideControls: constant(!showControls),
asOverlay: scope.behavior(
callModel.windowMode$.pipe(map((mode) => mode === "flat")),
),
buttonSize: scope.behavior(
callModel.layout$.pipe(
map((l) => (l.type === "pip" ? "md" : "lg") as "md" | "lg"),
),
),
showSettingsButton: scope.behavior(
combineLatest([callModel.layout$, callModel.showHeader$]).pipe(
map(
([l, showHeader]) =>
openSettings !== undefined &&
l.type !== "pip" &&
showControls &&
!(headerStyle === HeaderStyle.AppBar && showHeader),
),
),
),
showLayoutSwitcher: scope.behavior(
callModel.layout$.pipe(map((l) => l.type !== "pip" && showControls)),
),
showLogoDebugContainer: scope.behavior(
combineLatest([callModel.layout$, debugTileLayoutSetting.value$]).pipe(
map(([l, debugTile]) => l.type !== "pip" || (!hideLogo && !debugTile)),
),
),
showLogo: scope.behavior(
callModel.layout$.pipe(map((l) => !hideLogo && l.type !== "pip")),
),
// ── Layout mode ───────────────────────────────────────────────────────────
layoutMode: callModel.gridMode$,
setLayoutMode: constant(callModel.setGridMode),
// ── Screen sharing ────────────────────────────────────────────────────────
sharingScreen: callModel.sharingScreen$,
toggleScreenSharing: constant(callModel.toggleScreenSharing ?? undefined),
// ── Audio output ─────────────────────────────────────────────────────────
audioOutputSwitcher: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
// ── Actions ───────────────────────────────────────────────────────────────
openSettings: scope.behavior(
callModel.showHeader$.pipe(
map((showHeader) =>
headerStyle === HeaderStyle.AppBar && showHeader
? undefined
: openSettings,
),
),
),
hangup: constant(callModel.hangup),
// ── Reactions ─────────────────────────────────────────────────────────────
reactionIdentifier: constant(reactionIdentifier),
reactionData: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
// ── Debug ─────────────────────────────────────────────────────────────────
debugTileLayout: debugTileLayoutSetting.value$,
tileStoreGeneration: callModel.tileStoreGeneration$,
...buildDeviceBehaviors(scope, mediaDevices),
};
}
/**
* 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),
// ── Visibility / sizing ───────────────────────────────────────────────────
hideControls: constant(false),
asOverlay: constant(false),
buttonSize: constant("lg"),
showSettingsButton: constant(openSettings !== undefined),
showLayoutSwitcher: constant(false),
showLogoDebugContainer: constant(showLogo),
showLogo: constant(showLogo),
// ── Layout mode (not applicable in lobby) ─────────────────────────────────
layoutMode: constant(undefined),
setLayoutMode: constant(undefined),
// ── Screen sharing (not applicable in lobby) ──────────────────────────────
sharingScreen: constant(undefined),
toggleScreenSharing: constant(undefined),
// ── Audio output (not applicable in lobby) ────────────────────────────────
audioOutputSwitcher: constant(undefined),
// ── Actions ───────────────────────────────────────────────────────────────
openSettings: constant(openSettings),
hangup: constant(hangup),
// ── Reactions (not applicable in lobby) ───────────────────────────────────
reactionIdentifier: constant(undefined),
reactionData: constant(undefined),
// ── Debug (not needed in lobby) ───────────────────────────────────────────
debugTileLayout: constant(false),
tileStoreGeneration: constant(0),
};
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ 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);
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(
<SettingsButton
key="settings"
@@ -175,7 +412,7 @@ export const CallFooter: FC<FooterProps> = ({
);
}
if ((audioOptions?.size ?? 0) > 0) {
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
@@ -184,15 +421,9 @@ export const CallFooter: FC<FooterProps> = ({
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={Array.from(audioOptions.entries()).map(([k, v]) => {
const label = mediaDeviceLabelToString(v, (n) => "Audio Device " + n);
return {
id: k,
label: label,
};
})}
selectedOption={selectedAudio?.id}
onSelect={audioDevice?.select}
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
/>,
);
} else {
@@ -207,7 +438,8 @@ export const CallFooter: FC<FooterProps> = ({
/>,
);
}
if ((videoOptions?.size ?? 0) > 0) {
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
@@ -215,32 +447,11 @@ export const CallFooter: FC<FooterProps> = ({
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_mute"
options={Array.from(videoOptions.entries()).map(([k, v]) => ({
id: k,
label: v.type === "name" ? v.name : "Camera " + v.number,
}))}
toggles={
blurSupported
? [
{
id: "blur",
enabled: blurActive,
label: "Blur Background",
},
]
: []
}
selectedOption={selectedVideo?.id}
onSelect={(option) => {
switch (option) {
case "blur":
setBlurActive(!blurActive);
break;
default:
videoDevice?.select(option);
}
}}
data-testid="incall_videomute"
options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
/>,
);
} else {
@@ -273,12 +484,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
@@ -331,7 +537,6 @@ export const CallFooter: FC<FooterProps> = ({
ref={ref}
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})}
>
<div className={styles.settingsLogoContainer}>
@@ -348,7 +553,7 @@ export const CallFooter: FC<FooterProps> = ({
{showLogoDebugContainer && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
{setLayoutMode && layoutMode && (
<LayoutToggle
className={styles.layout}
layout={layoutMode}

View File

@@ -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,7 +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,
createCallFooterViewModel,
} from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
const logger = rootLogger.getChild("[InCallView]");
@@ -221,8 +219,6 @@ export const InCallView: FC<InCallViewProps> = ({
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const mediaDevices = useMediaDevices();
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -241,14 +237,10 @@ export const InCallView: FC<InCallViewProps> = ({
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 +340,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => new BehaviorSubject(defaultPipAlignment),
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
@@ -559,8 +546,34 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId,
);
const settingsButtonInAppBar =
headerStyle === HeaderStyle.AppBar && showHeader;
const footerScope = useMemo(() => new ObservableScope(), []);
useEffect(() => (): void => footerScope.end(), [footerScope]);
// Build the footer view-model once per stable set of domain-object references.
// The scalar inputs (reactionIdentifier) are derived from URL params and are
// effectively static for the call lifetime.
const footerVm = useMemo(
() =>
createCallFooterViewModel(
footerScope,
vm,
muteStates,
mediaDevices,
openSettings,
supportsReactions
? `${client.getUserId()}:${client.getDeviceId()}`
: undefined,
),
[
footerScope,
vm,
muteStates,
mediaDevices,
openSettings,
supportsReactions,
client,
],
);
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
@@ -571,35 +584,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<CallFooter
ref={footerRef}
hidden={!showFooter}
hideControls={!showControls}
asOverlay={windowMode === "flat"}
asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}
layoutMode={gridMode}
setLayoutMode={setGridMode}
audioEnabled={audioEnabled}
toggleAudio={toggleAudio ?? undefined}
videoEnabled={videoEnabled}
toggleVideo={toggleVideo ?? undefined}
sharingScreen={sharingScreen}
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
reactionData={supportsReactions ? vm : undefined}
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
// Only pass the openSettings function if the settings button is not in the app bar.
// If there is no fn the button will be hidden in the footer.
openSettings={settingsButtonInAppBar ? undefined : openSettings}
hangup={vm.hangup}
//Debug props
debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration}
audioDevice={mediaDevices.audioInput}
videoDevice={mediaDevices.videoInput}
/>
<>{showFooter && <CallFooter ref={footerRef} vm={footerVm} />}</>
);
const allConnections = useBehavior(vm.allConnections$);

View File

@@ -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,7 +47,10 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import {
CallFooter,
createLobbyFooterViewModel,
} from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props {
@@ -184,6 +188,21 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const footerScope = useInitial(() => new ObservableScope());
useEffect((): (() => void) => () => footerScope.end(), [footerScope]);
const footerVm = useInitial(() =>
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
openSettings,
!confineToRoom ? onLeaveClick : undefined,
// Logo and header are connected: only show the logo in SPA with header.
!hideHeader,
),
);
// 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 +246,7 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<CallFooter
audioEnabled={audioEnabled}
videoEnabled={videoEnabled}
toggleAudio={toggleAudio ?? undefined}
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
audioDevice={devices.audioInput}
videoDevice={devices.videoInput}
>
<CallFooter vm={footerVm}>
{recentsButtonInFooter && recentsButton}
</CallFooter>
</div>

33
src/state/ViewModel.ts Normal file
View File

@@ -0,0 +1,33 @@
/*
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 { useBehavior } from "../useBehavior";
import { type Behavior, constant } from "./Behavior";
export type ViewModel<Snapshot> = {
[K in keyof Snapshot]: Behavior<Snapshot[K]>;
};
export function useViewModel<Snapshot>(vm: ViewModel<Snapshot>): Snapshot {
const snapshot = {} as Snapshot;
for (const key in vm) {
const value$ = vm[key];
// eslint-disable-next-line react-hooks/rules-of-hooks
snapshot[key] = useBehavior(value$);
}
return snapshot;
}
export function createMockedViewModel<Snapshot>(
snapshot: Snapshot,
): ViewModel<Snapshot> {
const vm = {} as ViewModel<Snapshot>;
for (const key in snapshot) {
vm[key] = constant(snapshot[key]);
}
return vm;
}