mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +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 {
|
.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. */
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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