diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index adff99d5..228d7654 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details. ); } -.footer.hidden { - display: none; -} - .footer.overlay { /* Note that the footer is still position: sticky in this case so that certain tiles can move up out of the way of the footer when visible. */ diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7090a338..ace5d519 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -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 (
Promise.resolve(), }} > - +
); } const meta = { - component: CallFooterWrapper, -} satisfies Meta; + component: CallFooterStoryWrapper, +} satisfies Meta; export default meta; type Story = StoryObj; @@ -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: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, @@ -249,7 +260,7 @@ export const LobbyRecentButtonMobile: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index efea2ce1..6be68217 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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; - /** 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; + selectAudioButtonOption?: (deviceId: string) => void; + selectVideoButtonOption?: (option: string) => void; + videoToggles?: ToggleOption[]; } -export const CallFooter: FC = ({ - ref, - children, - asOverlay, - hidden, - hideControls, - hideLogo, - asPip, - layoutMode, - setLayoutMode, - openSettings, - audioEnabled, - videoEnabled, - toggleAudio, - toggleVideo, - sharingScreen, - toggleScreenSharing, - reactionIdentifier, - reactionData, - audioOutputSwitcher, - hangup, - debugTileLayout, - tileStoreGeneration, +/** + * 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)), + ), + }; +} - 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, + | "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 { + 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 { + 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; + children?: JSX.Element | JSX.Element[] | false; + vm: ViewModel; +} +export const CallFooter: FC = ({ ref, children, vm }) => { + const { + asOverlay, + hideControls, + layoutMode, + setLayoutMode, + openSettings, + audioEnabled, + videoEnabled, + toggleAudio, + toggleVideo, + sharingScreen, + toggleScreenSharing, + reactionIdentifier, + reactionData, + audioOutputSwitcher, + hangup, + debugTileLayout, + tileStoreGeneration, + videoOptions, + selectedVideo, + audioOptions, + selectedAudio, + selectAudioButtonOption, + selectVideoButtonOption, + videoToggles, + buttonSize, + showSettingsButton, + showLogoDebugContainer, + showLogo, + } = useViewModel(vm); 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( = ({ ); } - if ((audioOptions?.size ?? 0) > 0) { + if ((audioOptions?.length ?? 0) > 0) { buttons.push( = ({ 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 = ({ />, ); } - if ((videoOptions?.size ?? 0) > 0) { + + if ((videoOptions?.length ?? 0) > 0) { buttons.push( = ({ 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 = ({ buttons.push( = ({ ref={ref} className={classNames(styles.footer, { [styles.overlay]: asOverlay, - [styles.hidden]: hidden, })} >
@@ -348,7 +553,7 @@ export const CallFooter: FC = ({ {showLogoDebugContainer && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && showLayoutSwitcher && ( + {setLayoutMode && layoutMode && ( = ({ }); 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 = ({ 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 = ({ () => 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 = ({ 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( = ({ // Only hide the settings button if we have an AppBar header and we are showing the header const footer = ( -