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
-4
View File
@@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details.
); );
} }
.footer.hidden {
display: none;
}
.footer.overlay { .footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain /* 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. */ tiles can move up out of the way of the footer when visible. */
+26 -15
View File
@@ -7,16 +7,23 @@ Please see LICENSE in the repository root for full details.
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react"; import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web"; import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite"; 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 inCallViewStyles from "../room/InCallView.module.css";
import { createMockedViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; 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 ( return (
<div className={inCallViewStyles.inRoom}> <div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext <ReactionsSenderContext
@@ -26,15 +33,15 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(), sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}} }}
> >
<CallFooter {...props} /> <CallFooter vm={vm} />
</ReactionsSenderContext> </ReactionsSenderContext>
</div> </div>
); );
} }
const meta = { const meta = {
component: CallFooterWrapper, component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterWrapper>; } satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
@@ -50,9 +57,10 @@ const fnArgType = {
options: ["MockedCallback", "undefined"], options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined }, mapping: { MockedCallback: fn(), undefined: undefined },
}; };
export const Default: Story = { export const Default: Story = {
args: { args: {
hideLogo: true, showLogo: false,
layoutMode: "grid", layoutMode: "grid",
audioEnabled: true, audioEnabled: true,
videoEnabled: true, videoEnabled: true,
@@ -62,6 +70,7 @@ export const Default: Story = {
toggleVideo: fn(), toggleVideo: fn(),
toggleScreenSharing: fn(), toggleScreenSharing: fn(),
hangup: fn(), hangup: fn(),
buttonSize: "lg",
}, },
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
@@ -110,7 +119,7 @@ export const WithLogo: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: false, showLogo: true,
}, },
}; };
@@ -150,7 +159,9 @@ export const Pip: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
asPip: true, buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined,
}, },
}; };
export const NoControlsWithLogo: Story = { export const NoControlsWithLogo: Story = {
@@ -158,7 +169,7 @@ export const NoControlsWithLogo: Story = {
args: { args: {
...Default.args, ...Default.args,
hideControls: true, hideControls: true,
hideLogo: false, showLogo: true,
}, },
}; };
@@ -187,7 +198,7 @@ export const MobileLayout: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
}, },
@@ -203,7 +214,7 @@ export const Lobby: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
openSettings: undefined, openSettings: undefined,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
@@ -217,7 +228,7 @@ export const LobbyMobile: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
@@ -235,7 +246,7 @@ export const LobbyRecentButton: Story = {
args: { args: {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
@@ -249,7 +260,7 @@ export const LobbyRecentButtonMobile: Story = {
args: { args: {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
+336 -131
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 { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, of } from "rxjs"; import { combineLatest, map } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { supportsBackgroundProcessors } from "@livekit/track-processors";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -25,50 +25,53 @@ import {
} from "../button"; } from "../button";
import styles from "./CallFooter.module.css"; import styles from "./CallFooter.module.css";
import { LayoutToggle } from "../room/LayoutToggle"; import { LayoutToggle } from "../room/LayoutToggle";
import { type GridMode } from "../state/CallViewModel/CallViewModel"; import {
type CallViewModel,
type GridMode,
} from "../state/CallViewModel/CallViewModel";
import { import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { import { type MediaDevices } from "../state/MediaDevices";
type AudioOutputDeviceLabel,
type DeviceLabel,
type MediaDevice,
type SelectedDevice,
} from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection"; import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import { import {
backgroundBlur as backgroundBlurSettings, backgroundBlur as backgroundBlurSettings,
useSetting, debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings"; } 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 { export interface AudioOutputSwitcher {
targetOutput: string; targetOutput: string;
switch: () => void; switch: () => void;
} }
export interface FooterProps { export interface FooterSnapshot {
ref?: Ref<HTMLDivElement>;
/** Children will only be visible if the component is wider than 5*/
children?: JSX.Element | JSX.Element[] | false;
audioEnabled: boolean; audioEnabled: boolean;
/** Also controls if the audioMute button is disabled */ /** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined; toggleAudio: (() => void) | undefined;
videoEnabled: boolean; videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */ /** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined; toggleVideo: (() => void) | undefined;
/* This is needed for WindowMode = "flat" */ /* This is needed for WindowMode = "flat" */
hideControls?: boolean; 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. /** 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; asOverlay?: boolean;
buttonSize: "md" | "lg";
showSettingsButton?: boolean;
showLayoutSwitcher?: boolean;
showLogoDebugContainer?: boolean;
showLogo?: boolean;
layoutMode?: GridMode; layoutMode?: GridMode;
/** Also controls if the layout button is visible */ /** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void; setLayoutMode?: (mode: GridMode) => void;
@@ -76,7 +79,7 @@ export interface FooterProps {
sharingScreen?: boolean; sharingScreen?: boolean;
toggleScreenSharing?: () => void; toggleScreenSharing?: () => void;
/** Also controls if the audio button is visible */ /** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher; audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */ /** Also controls if the settings button is visible */
openSettings?: () => void; openSettings?: () => void;
@@ -86,7 +89,6 @@ export interface FooterProps {
reactionIdentifier?: string; reactionIdentifier?: string;
reactionData?: ReactionData; reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff // debug stuff
debugTileLayout?: boolean; debugTileLayout?: boolean;
tileStoreGeneration?: number; tileStoreGeneration?: number;
@@ -95,76 +97,311 @@ export interface FooterProps {
videoOptions?: MenuOptions[]; videoOptions?: MenuOptions[];
selectedAudio?: string; selectedAudio?: string;
selectedVideo?: string; selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void; selectAudioButtonOption?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void; selectVideoButtonOption?: (option: string) => void;
/** videoToggles?: ToggleOption[];
* 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>;
} }
export const CallFooter: FC<FooterProps> = ({ /**
ref, * Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
children, * needed by FooterSnapshot.
asOverlay, */
hidden, function buildMuteBehaviors(
hideControls, scope: ObservableScope,
hideLogo, muteStates: MuteStates,
asPip, ): Pick<
layoutMode, ViewModel<FooterSnapshot>,
setLayoutMode, "audioEnabled" | "toggleAudio" | "videoEnabled" | "toggleVideo"
openSettings, > {
audioEnabled, return {
videoEnabled, audioEnabled: muteStates.audio.enabled$,
toggleAudio, toggleAudio: scope.behavior(
toggleVideo, muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
sharingScreen, ),
toggleScreenSharing, videoEnabled: muteStates.video.enabled$,
reactionIdentifier, toggleVideo: scope.behavior(
reactionData, muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
audioOutputSwitcher, ),
hangup, };
debugTileLayout, }
tileStoreGeneration,
audioDevice, /**
videoDevice, * Shared helper: maps MediaDevices into the audio/video device-list behaviors
}) => { * needed by FooterSnapshot (options, selection, callbacks, blur toggle).
const videoOptions = useObservableEagerState( */
videoDevice?.available$ ?? of(new Map()), function buildDeviceBehaviors(
); scope: ObservableScope,
const selectedVideo = useObservableEagerState( mediaDevices: MediaDevices,
videoDevice?.selected$ ?? of(undefined), ): Pick<
); ViewModel<FooterSnapshot>,
const audioOptions = useObservableEagerState( | "audioOptions"
audioDevice?.available$ ?? of(new Map()), | "selectedAudio"
); | "selectAudioButtonOption"
const selectedAudio = useObservableEagerState( | "videoOptions"
audioDevice?.selected$ ?? of(undefined), | "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 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) { if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens. // Add the settings button to the center group so it's visible on small
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible. // screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push( buttons.push(
<SettingsButton <SettingsButton
key="settings" key="settings"
@@ -175,7 +412,7 @@ export const CallFooter: FC<FooterProps> = ({
); );
} }
if ((audioOptions?.size ?? 0) > 0) { if ((audioOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Mic Source"} title={"Mic Source"}
@@ -184,15 +421,9 @@ export const CallFooter: FC<FooterProps> = ({
enabled={audioEnabled ?? false} enabled={audioEnabled ?? false}
onMuteClick={toggleAudio} onMuteClick={toggleAudio}
data-testid="incall_mute" data-testid="incall_mute"
options={Array.from(audioOptions.entries()).map(([k, v]) => { options={audioOptions}
const label = mediaDeviceLabelToString(v, (n) => "Audio Device " + n); selectedOption={selectedAudio}
return { onSelect={selectAudioButtonOption}
id: k,
label: label,
};
})}
selectedOption={selectedAudio?.id}
onSelect={audioDevice?.select}
/>, />,
); );
} else { } else {
@@ -207,7 +438,8 @@ export const CallFooter: FC<FooterProps> = ({
/>, />,
); );
} }
if ((videoOptions?.size ?? 0) > 0) {
if ((videoOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title={"Camera Source"} title={"Camera Source"}
@@ -215,32 +447,11 @@ export const CallFooter: FC<FooterProps> = ({
iconsAndLabels="video" iconsAndLabels="video"
enabled={videoEnabled ?? false} enabled={videoEnabled ?? false}
onMuteClick={toggleVideo} onMuteClick={toggleVideo}
data-testid="incall_mute" data-testid="incall_videomute"
options={Array.from(videoOptions.entries()).map(([k, v]) => ({ options={videoOptions}
id: k, toggles={videoToggles}
label: v.type === "name" ? v.name : "Camera " + v.number, selectedOption={selectedVideo}
}))} onSelect={selectVideoButtonOption}
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);
}
}}
/>, />,
); );
} else { } else {
@@ -273,12 +484,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push( buttons.push(
<ReactionToggleButton <ReactionToggleButton
size={buttonSize} size={buttonSize}
reactionData={ reactionData={reactionData}
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
key="raise_hand" key="raise_hand"
className={styles.raiseHand} className={styles.raiseHand}
identifier={reactionIdentifier} identifier={reactionIdentifier}
@@ -331,7 +537,6 @@ export const CallFooter: FC<FooterProps> = ({
ref={ref} ref={ref}
className={classNames(styles.footer, { className={classNames(styles.footer, {
[styles.overlay]: asOverlay, [styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})} })}
> >
<div className={styles.settingsLogoContainer}> <div className={styles.settingsLogoContainer}>
@@ -348,7 +553,7 @@ export const CallFooter: FC<FooterProps> = ({
{showLogoDebugContainer && logoDebugContainer} {showLogoDebugContainer && logoDebugContainer}
</div> </div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>} {!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && ( {setLayoutMode && layoutMode && (
<LayoutToggle <LayoutToggle
className={styles.layout} className={styles.layout}
layout={layoutMode} layout={layoutMode}
+34 -49
View File
@@ -43,7 +43,6 @@ import { InviteButton } from "../button/InviteButton";
import { import {
type CallViewModel, type CallViewModel,
createCallViewModel$, createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts"; } from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid"; import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
@@ -68,11 +67,7 @@ import {
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
debugTileLayout as debugTileLayoutSetting,
matrixRTCMode as matrixRTCModeSetting,
useSetting,
} from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader"; import { ReactionsReader } from "../reactions/ReactionsReader";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; 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 { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts"; import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.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"; import { SettingsIconButton } from "../button/Button.tsx";
const logger = rootLogger.getChild("[InCallView]"); const logger = rootLogger.getChild("[InCallView]");
@@ -221,8 +219,6 @@ export const InCallView: FC<InCallViewProps> = ({
}); });
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const mediaDevices = useMediaDevices(); const mediaDevices = useMediaDevices();
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -241,14 +237,10 @@ export const InCallView: FC<InCallViewProps> = ({
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$); 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 showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const fatalCallError = useBehavior(vm.fatalError$); const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary // Stop the rendering and throw for the error boundary
@@ -348,11 +340,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => new BehaviorSubject(defaultPipAlignment), () => new BehaviorSubject(defaultPipAlignment),
); );
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useAppBarHidden(!showHeader); useAppBarHidden(!showHeader);
let header: ReactNode = null; let header: ReactNode = null;
@@ -559,8 +546,34 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId, matrixRoom.roomId,
); );
const settingsButtonInAppBar = const footerScope = useMemo(() => new ObservableScope(), []);
headerStyle === HeaderStyle.AppBar && showHeader; 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( useAppBarSecondaryButton(
<SettingsIconButton <SettingsIconButton
key="settings" 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 // Only hide the settings button if we have an AppBar header and we are showing the header
const footer = ( const footer = (
<CallFooter <>{showFooter && <CallFooter ref={footerRef} vm={footerVm} />}</>
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}
/>
); );
const allConnections = useBehavior(vm.allConnections$); const allConnections = useBehavior(vm.allConnections$);
+21 -13
View File
@@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link"; import { Link } from "../button/Link";
import { useMediaDevices } from "../MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { import {
useTrackProcessor, useTrackProcessor,
@@ -46,7 +47,10 @@ import {
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter"; import {
CallFooter,
createLobbyFooterViewModel,
} from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props { interface Props {
@@ -184,6 +188,21 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack); 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 // TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS // animations and don't have to feel bad about reusing its CSS
return ( return (
@@ -227,18 +246,7 @@ export const LobbyView: FC<Props> = ({
</VideoPreview> </VideoPreview>
{!recentsButtonInFooter && recentsButton} {!recentsButtonInFooter && recentsButton}
</div> </div>
<CallFooter <CallFooter vm={footerVm}>
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}
>
{recentsButtonInFooter && recentsButton} {recentsButtonInFooter && recentsButton}
</CallFooter> </CallFooter>
</div> </div>
+33
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;
}