mirror of
https://github.com/element-hq/element-call.git
synced 2026-05-25 14:14:25 +00:00
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
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 {
|
|
SpotlightIcon,
|
|
GridIcon,
|
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
import { Switch } from "@vector-im/compound-web";
|
|
import { t } from "i18next";
|
|
|
|
import LogoMark from "../icons/LogoMark.svg?react";
|
|
import LogoType from "../icons/LogoType.svg?react";
|
|
import {
|
|
EndCallButton,
|
|
MicButton,
|
|
VideoButton,
|
|
ShareScreenButton,
|
|
SettingsButton,
|
|
ReactionToggleButton,
|
|
LoudspeakerButton,
|
|
SettingsIconButton,
|
|
type ReactionData,
|
|
} from "../button";
|
|
import styles from "./CallFooter.module.css";
|
|
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
|
import {
|
|
MediaMuteAndSwitchButton,
|
|
type MenuOptions,
|
|
} from "./MediaMuteAndSwitchButton";
|
|
import { type ViewModel } from "../state/ViewModel";
|
|
import { useBehavior } from "../useBehavior";
|
|
|
|
export interface AudioOutputSwitcher {
|
|
targetOutput: string;
|
|
switch: () => void;
|
|
}
|
|
|
|
/**
|
|
* The Snapshot combines all fields required to populate the view.
|
|
*
|
|
* It is a combination of Actions and State.
|
|
* All Actions and State will be wrappen in behaviors.
|
|
* This has the advantage, that actions can mutate.
|
|
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
|
|
* With it being reactive we can use the existance of the action to update the rendering without
|
|
* requiring additional state.
|
|
*
|
|
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
|
|
* just happens on the syntax level with the `type = ... & ...` notation.
|
|
*/
|
|
export type FooterSnapshot = FooterActions & FooterState;
|
|
export interface FooterActions {
|
|
/** Also controls if the audioMute button is disabled */
|
|
toggleAudio: (() => void) | undefined;
|
|
/** Also controls if the videoMute button is disabled */
|
|
toggleVideo: (() => void) | undefined;
|
|
toggleBlur: (() => void) | undefined;
|
|
/** Also controls if the layout button is visible */
|
|
setLayoutMode: ((mode: GridMode) => void) | undefined;
|
|
toggleScreenSharing: (() => void) | undefined;
|
|
/** Also controls if the settings button is visible */
|
|
openSettings: (() => void) | undefined;
|
|
/** Also controls if the hangup button is visible */
|
|
hangup: (() => void) | undefined;
|
|
}
|
|
// we do not use any ? optional properties so that the vm type is including all fields.
|
|
export interface FooterState {
|
|
audioEnabled: boolean;
|
|
videoEnabled: boolean;
|
|
videoBlurEnabled: boolean;
|
|
showFooter: boolean;
|
|
|
|
/* This is needed for WindowMode = "flat" */
|
|
hideControls: boolean;
|
|
/** The footer should be used as an overlay.
|
|
* (Over the Call Grid) This saves spaces on small screens. */
|
|
asOverlay: boolean;
|
|
|
|
buttonSize: "md" | "lg";
|
|
showLogo: boolean;
|
|
|
|
layoutMode: GridMode | undefined;
|
|
|
|
sharingScreen: boolean;
|
|
|
|
/** Also controls if the audio output button is visible */
|
|
audioOutputSwitcher: AudioOutputSwitcher | undefined;
|
|
|
|
reactionIdentifier: string | undefined;
|
|
reactionData: ReactionData | undefined;
|
|
|
|
// debug stuff
|
|
debugTileLayout: boolean;
|
|
tileStoreGeneration: number | undefined;
|
|
|
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
|
audioOptions: MenuOptions[];
|
|
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
|
videoOptions: MenuOptions[];
|
|
selectedAudio: string | undefined;
|
|
selectedVideo: string | undefined;
|
|
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
|
selectVideoButtonOption: ((option: string) => void) | undefined;
|
|
}
|
|
|
|
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 = useBehavior(vm.asOverlay$);
|
|
const showFooter = useBehavior(vm.showFooter$);
|
|
const hideControls = useBehavior(vm.hideControls$);
|
|
const layoutMode = useBehavior(vm.layoutMode$);
|
|
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
|
const openSettings = useBehavior(vm.openSettings$);
|
|
const audioEnabled = useBehavior(vm.audioEnabled$);
|
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
|
const toggleAudio = useBehavior(vm.toggleAudio$);
|
|
const toggleVideo = useBehavior(vm.toggleVideo$);
|
|
const sharingScreen = useBehavior(vm.sharingScreen$);
|
|
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
|
|
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
|
|
const reactionData = useBehavior(vm.reactionData$);
|
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
|
const hangup = useBehavior(vm.hangup$);
|
|
const debugTileLayout = useBehavior(vm.debugTileLayout$);
|
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
|
const videoOptions = useBehavior(vm.videoOptions$);
|
|
const selectedVideo = useBehavior(vm.selectedVideo$);
|
|
const audioOptions = useBehavior(vm.audioOptions$);
|
|
const selectedAudio = useBehavior(vm.selectedAudio$);
|
|
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
|
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
|
const toggleBlur = useBehavior(vm.toggleBlur$);
|
|
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
|
const buttonSize = useBehavior(vm.buttonSize$);
|
|
const showLogo = useBehavior(vm.showLogo$);
|
|
|
|
const buttons: JSX.Element[] = [];
|
|
|
|
if (openSettings !== undefined) {
|
|
// 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"
|
|
showForScreenWidth="narrow"
|
|
onClick={openSettings}
|
|
data-testid="settings-bottom-center"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if ((audioOptions?.length ?? 0) > 0) {
|
|
buttons.push(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Mic Source"}
|
|
key="audio"
|
|
iconsAndLabels="audio"
|
|
enabled={audioEnabled ?? false}
|
|
onMuteClick={toggleAudio}
|
|
data-testid="incall_mute"
|
|
options={audioOptions}
|
|
selectedOption={selectedAudio}
|
|
onSelect={selectAudioButtonOption}
|
|
/>,
|
|
);
|
|
} else {
|
|
buttons.push(
|
|
<MicButton
|
|
size={buttonSize}
|
|
key="audio"
|
|
enabled={audioEnabled ?? false}
|
|
onClick={toggleAudio}
|
|
disabled={toggleAudio === undefined}
|
|
data-testid="incall_mute"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if ((videoOptions?.length ?? 0) > 0) {
|
|
buttons.push(
|
|
<MediaMuteAndSwitchButton
|
|
title={"Camera Source"}
|
|
key="video"
|
|
iconsAndLabels="video"
|
|
enabled={videoEnabled ?? false}
|
|
onMuteClick={toggleVideo}
|
|
options={videoOptions}
|
|
selectedOption={selectedVideo}
|
|
onSelect={selectVideoButtonOption}
|
|
videoBlurToggleClick={toggleBlur}
|
|
videoBlurEnabled={videoBlurEnabled}
|
|
/>,
|
|
);
|
|
} else {
|
|
buttons.push(
|
|
<VideoButton
|
|
size={buttonSize}
|
|
key="video"
|
|
enabled={videoEnabled ?? false}
|
|
onClick={toggleVideo}
|
|
disabled={toggleVideo === undefined}
|
|
data-testid="incall_videomute"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (toggleScreenSharing !== undefined) {
|
|
buttons.push(
|
|
<ShareScreenButton
|
|
size={buttonSize}
|
|
key="share_screen"
|
|
className={styles.shareScreen}
|
|
enabled={sharingScreen ?? false}
|
|
onClick={toggleScreenSharing}
|
|
data-testid="incall_screenshare"
|
|
/>,
|
|
);
|
|
}
|
|
|
|
if (reactionIdentifier && reactionData) {
|
|
buttons.push(
|
|
<ReactionToggleButton
|
|
size={buttonSize}
|
|
reactionData={reactionData}
|
|
key="raise_hand"
|
|
className={styles.raiseHand}
|
|
identifier={reactionIdentifier}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
// In this PR we just move the button to the bottom bar. We do not yet update its appearance
|
|
const audioOutputButton = useMemo(() => {
|
|
if (audioOutputSwitcher === undefined) return null;
|
|
return (
|
|
<LoudspeakerButton
|
|
size={buttonSize}
|
|
onClick={() => audioOutputSwitcher.switch()}
|
|
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
|
|
/>
|
|
);
|
|
}, [audioOutputSwitcher, buttonSize]);
|
|
|
|
if (audioOutputButton) buttons.push(audioOutputButton);
|
|
|
|
if (hangup)
|
|
buttons.push(
|
|
<EndCallButton
|
|
size={buttonSize}
|
|
key="end_call"
|
|
onClick={hangup}
|
|
data-testid="incall_leave"
|
|
/>,
|
|
);
|
|
|
|
const logoDebugContainer = (
|
|
<div className={styles.logo}>
|
|
{showLogo && (
|
|
<>
|
|
<LogoMark width={24} height={24} aria-hidden />
|
|
<LogoType
|
|
width={80}
|
|
height={11}
|
|
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
|
/>
|
|
</>
|
|
)}
|
|
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-testid="footer-container"
|
|
className={classNames(styles.footer, {
|
|
[styles.overlay]: asOverlay,
|
|
[styles.hidden]: !showFooter,
|
|
})}
|
|
>
|
|
<div className={styles.settingsLogoContainer}>
|
|
{openSettings !== undefined && (
|
|
<SettingsIconButton
|
|
key="settings"
|
|
kind="secondary"
|
|
showForScreenWidth="wide"
|
|
onClick={openSettings}
|
|
data-testid="settings-bottom-left"
|
|
/>
|
|
)}
|
|
{children}
|
|
{(showLogo || debugTileLayout) && logoDebugContainer}
|
|
</div>
|
|
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
|
{!hideControls && setLayoutMode && layoutMode && (
|
|
<Switch<"spotlight", "grid">
|
|
name="layoutMode"
|
|
aria-label={t("layout_switch_label")}
|
|
leftLabel={t("layout_spotlight_label")}
|
|
leftValue="spotlight"
|
|
leftIcon={SpotlightIcon}
|
|
rightLabel={t("layout_grid_label")}
|
|
rightValue="grid"
|
|
rightIcon={GridIcon}
|
|
className={styles.layout}
|
|
value={layoutMode}
|
|
onChange={setLayoutMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|