mirror of
https://github.com/vector-im/element-call.git
synced 2026-05-13 10:34:37 +00:00
move call footer to viewModel interface approach
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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$);
|
||||
|
||||
|
||||
@@ -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
33
src/state/ViewModel.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user