mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-30 19:39:22 +00:00
Merge pull request #3885 from element-hq/toger5/bottom-bar-storybook
[Corrected merge target] Footer component -> Storybook
This commit is contained in:
@@ -1,7 +1,14 @@
|
|||||||
|
/*
|
||||||
|
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 { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: ["@storybook/addon-docs"],
|
addons: ["@storybook/addon-docs"],
|
||||||
framework: "@storybook/react-vite",
|
framework: "@storybook/react-vite",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/*
|
||||||
|
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 { create } from "storybook/theming";
|
import { create } from "storybook/theming";
|
||||||
import { addons } from "storybook/manager-api";
|
import { addons } from "storybook/manager-api";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/*
|
||||||
|
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 { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ mobileTest(
|
|||||||
.click();
|
.click();
|
||||||
|
|
||||||
// dismiss settings
|
// dismiss settings
|
||||||
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
|
await guestPage.locator("#root").press("Escape");
|
||||||
|
|
||||||
await guestPage.pause();
|
await guestPage.pause();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
||||||
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { Header, LeftNav, RightNav } from "./Header";
|
import { Header, LeftNav, RightNav } from "./Header";
|
||||||
import { platform } from "./Platform";
|
import { platform } from "./Platform";
|
||||||
@@ -49,7 +50,9 @@ export const AppBar: FC<Props> = ({ children }) => {
|
|||||||
|
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
const [hidden, setHidden] = useState<boolean>(false);
|
const [hidden, setHidden] = useState<boolean>(false);
|
||||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
|
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const context = useMemo(
|
const context = useMemo(
|
||||||
() => ({ setTitle, setSecondaryButton, setHidden }),
|
() => ({ setTitle, setSecondaryButton, setHidden }),
|
||||||
[setTitle, setHidden, setSecondaryButton],
|
[setTitle, setHidden, setSecondaryButton],
|
||||||
@@ -114,6 +117,10 @@ export function useAppBarHidden(hidden: boolean): void {
|
|||||||
if (setHidden !== undefined) {
|
if (setHidden !== undefined) {
|
||||||
setHidden(hidden);
|
setHidden(hidden);
|
||||||
return (): void => setHidden(false);
|
return (): void => setHidden(false);
|
||||||
|
} else if (platform !== "desktop") {
|
||||||
|
logger.warn(
|
||||||
|
"[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [setHidden, hidden]);
|
}, [setHidden, hidden]);
|
||||||
}
|
}
|
||||||
@@ -129,6 +136,10 @@ export function useAppBarSecondaryButton(button: ReactNode): void {
|
|||||||
if (setSecondaryButton !== undefined) {
|
if (setSecondaryButton !== undefined) {
|
||||||
setSecondaryButton(button);
|
setSecondaryButton(button);
|
||||||
return (): void => setSecondaryButton("");
|
return (): void => setSecondaryButton("");
|
||||||
|
} else if (platform !== "desktop") {
|
||||||
|
logger.warn(
|
||||||
|
"[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [button, setSecondaryButton]);
|
}, [button, setSecondaryButton]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import {
|
|||||||
ShareScreenSolidIcon,
|
ShareScreenSolidIcon,
|
||||||
OverflowHorizontalIcon,
|
OverflowHorizontalIcon,
|
||||||
OverflowVerticalIcon,
|
OverflowVerticalIcon,
|
||||||
|
VolumeOnSolidIcon,
|
||||||
|
VolumeOffSolidIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
|
import callFooterStyles from "../components/CallFooter.module.css";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
|
|
||||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
@@ -130,32 +133,89 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
|
size?: "sm" | "lg";
|
||||||
|
loudspeakerModeEnabled: boolean;
|
||||||
|
}
|
||||||
|
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
|
||||||
|
loudspeakerModeEnabled,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// if the target is the earpice, we are currently in loudspeaker mode.
|
||||||
|
const label = loudspeakerModeEnabled
|
||||||
|
? t("settings.devices.loudspeaker")
|
||||||
|
: t("settings.devices.handset");
|
||||||
|
return (
|
||||||
|
<Tooltip label={label}>
|
||||||
|
<CpdButton
|
||||||
|
iconOnly
|
||||||
|
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
|
||||||
|
{...props}
|
||||||
|
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
|
||||||
|
aria-checked={loudspeakerModeEnabled}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function classNamesForScreenWidth(
|
||||||
|
className?: string,
|
||||||
|
forScreenWidth?: "wide" | "narrow",
|
||||||
|
): string {
|
||||||
|
return classNames(className, {
|
||||||
|
[callFooterStyles.settingsOnlyShowWide]: forScreenWidth === "wide",
|
||||||
|
[callFooterStyles.settingsOnlyShowNarrow]: forScreenWidth === "narrow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
|
/** If this buttons should be setup to be used in the app bar */
|
||||||
|
showForScreenWidth?: "wide" | "narrow";
|
||||||
kind?: "secondary" | "primary";
|
kind?: "secondary" | "primary";
|
||||||
}
|
}
|
||||||
export const SettingsIconButton: FC<SettingsIconButtonProps> = (props) => {
|
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
|
||||||
|
showForScreenWidth,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const Icon =
|
const Icon =
|
||||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("common.settings")}>
|
<Tooltip label={t("common.settings")}>
|
||||||
<IconButton {...props}>
|
<IconButton
|
||||||
|
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<Icon aria-hidden />
|
<Icon aria-hidden />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
// size?: "sm" | "lg";
|
size?: "sm" | "lg";
|
||||||
// }
|
/** If this buttons should be setup to be used in the app bar */
|
||||||
// const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
showForScreenWidth?: "wide" | "narrow";
|
||||||
// const { t } = useTranslation();
|
}
|
||||||
// const Icon =
|
export const SettingsButton: FC<SettingsButtonProps> = ({
|
||||||
// platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
showForScreenWidth,
|
||||||
// return (
|
className,
|
||||||
// <Tooltip label={t("common.settings")}>
|
...props
|
||||||
// <CpdButton iconOnly Icon={Icon} kind="secondary" {...props} />
|
}) => {
|
||||||
// </Tooltip>
|
const { t } = useTranslation();
|
||||||
// );
|
return (
|
||||||
// };
|
<Tooltip label={t("common.settings")}>
|
||||||
|
<CpdButton
|
||||||
|
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||||
|
iconOnly
|
||||||
|
Icon={
|
||||||
|
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
|
||||||
|
}
|
||||||
|
kind={"secondary"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ function TestComponent({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
>
|
>
|
||||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
<ReactionToggleButton
|
||||||
|
reactionData={{
|
||||||
|
reactions$: vm.reactions$,
|
||||||
|
handsRaised$: vm.handsRaised$,
|
||||||
|
}}
|
||||||
|
identifier={localIdent}
|
||||||
|
/>
|
||||||
</ReactionsSenderProvider>
|
</ReactionsSenderProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ import classNames from "classnames";
|
|||||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||||
import styles from "./ReactionToggleButton.module.css";
|
import styles from "./ReactionToggleButton.module.css";
|
||||||
import {
|
import {
|
||||||
|
type RaisedHandInfo,
|
||||||
type ReactionOption,
|
type ReactionOption,
|
||||||
ReactionSet,
|
ReactionSet,
|
||||||
ReactionsRowSize,
|
ReactionsRowSize,
|
||||||
} from "../reactions";
|
} from "../reactions";
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
raised: boolean;
|
raised: boolean;
|
||||||
@@ -163,15 +164,22 @@ export function ReactionPopupMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReactionData {
|
||||||
|
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
|
||||||
|
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
||||||
|
reactions$: Behavior<Record<string, ReactionOption>>;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
|
reactionData: ReactionData;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
vm: CallViewModel;
|
|
||||||
size?: "sm" | "lg";
|
size?: "sm" | "lg";
|
||||||
|
/** List of participants raising their hand */
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReactionToggleButton({
|
export function ReactionToggleButton({
|
||||||
identifier,
|
identifier,
|
||||||
vm,
|
reactionData: { handsRaised$, reactions$ },
|
||||||
...props
|
...props
|
||||||
}: ReactionToggleButtonProps): ReactNode {
|
}: ReactionToggleButtonProps): ReactNode {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -180,8 +188,8 @@ export function ReactionToggleButton({
|
|||||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||||
const [errorText, setErrorText] = useState<string>();
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
|
||||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
|
||||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
const canReact = !useBehavior(reactions$)[identifier];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear whenever the reactions menu state changes.
|
// Clear whenever the reactions menu state changes.
|
||||||
|
|||||||
37
src/components/CallFooter.mdx
Normal file
37
src/components/CallFooter.mdx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{/**
|
||||||
|
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.
|
||||||
|
**/}
|
||||||
|
|
||||||
|
{/**
|
||||||
|
This is a custom doc page overwriting the default autodocs tag.
|
||||||
|
This can be done by using the same filename as the component
|
||||||
|
With the help of Primary, Controls,Stories the overhead is minimal
|
||||||
|
**/}
|
||||||
|
|
||||||
|
import {
|
||||||
|
Meta,
|
||||||
|
Primary,
|
||||||
|
Controls,
|
||||||
|
Stories,
|
||||||
|
Title,
|
||||||
|
Subtitle,
|
||||||
|
} from "@storybook/addon-docs/blocks";
|
||||||
|
import * as CallFooterStories from "./CallFooter.stories";
|
||||||
|
|
||||||
|
<Meta of={CallFooterStories} />
|
||||||
|
|
||||||
|
<Title> Call Footer </Title>
|
||||||
|
|
||||||
|
The footer compoentn contains all main interactions needed for a call.
|
||||||
|
|
||||||
|
<Subtitle> Mobile layouts </Subtitle>
|
||||||
|
|
||||||
|
This component is reactive. To properly check the mobile layout, you will need to click on the stories in the left sidebar to see the
|
||||||
|
component on a mobile screen.
|
||||||
|
The story summary here does not render the mobile layouts correctly.
|
||||||
|
|
||||||
|
<Primary />
|
||||||
|
<Controls of={CallFooterStories.Primary} />
|
||||||
|
<Stories />
|
||||||
158
src/components/CallFooter.module.css
Normal file
158
src/components/CallFooter.module.css
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: sticky;
|
||||||
|
inset-block-end: 0;
|
||||||
|
z-index: var(--call-view-header-footer-layer);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
grid-template-areas: ". buttons layout";
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-3x);
|
||||||
|
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
var(--cpd-color-bg-canvas-default) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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. */
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer.overlay.hidden {
|
||||||
|
display: grid;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Switch to position: absolute so the footer takes up no space in the layout
|
||||||
|
when hidden. */
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer.overlay:has(:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLogoContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-4x);
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
justify-self: start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
padding-inline-start: var(--cpd-space-1x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
grid-area: buttons;
|
||||||
|
justify-self: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--cpd-space-3x);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
grid-area: layout;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*First hide the logo*/
|
||||||
|
@media (max-width: 750px) {
|
||||||
|
.logo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsOnlyShowNarrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settingsOnlyShowWide {
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
||||||
|
Once we exceed 500 we hide everything except the buttons.
|
||||||
|
*/
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.footer {
|
||||||
|
grid-template-areas: "buttons buttons buttons";
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsOnlyShowNarrow {
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
|
.settingsOnlyShowWide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLogoContainer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 800px) {
|
||||||
|
.footer {
|
||||||
|
padding-block: var(--cpd-space-8x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 400px) {
|
||||||
|
.footer {
|
||||||
|
padding-block: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 370px) {
|
||||||
|
.shareScreen {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PIP custom css */
|
||||||
|
@media (max-height: 400px) {
|
||||||
|
.shareScreen {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding-block-start: var(--cpd-space-3x);
|
||||||
|
padding-block-end: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 320px) {
|
||||||
|
.raiseHand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.buttons {
|
||||||
|
gap: var(--cpd-space-4x);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/components/CallFooter.stories.tsx
Normal file
242
src/components/CallFooter.stories.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
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 { fn } from "storybook/test";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { 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 inCallViewStyles from "../room/InCallView.module.css";
|
||||||
|
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||||
|
import { type ReactionOption } from "../reactions";
|
||||||
|
|
||||||
|
function CallFooterWrapper(props: FooterProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={inCallViewStyles.inRoom}>
|
||||||
|
<ReactionsSenderContext
|
||||||
|
value={{
|
||||||
|
supportsReactions: false,
|
||||||
|
toggleRaisedHand: async () => Promise.resolve(),
|
||||||
|
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CallFooter {...props} />
|
||||||
|
</ReactionsSenderContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: CallFooterWrapper,
|
||||||
|
} satisfies Meta<typeof CallFooterWrapper>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||||
|
const reactionData = {
|
||||||
|
handsRaised$: new BehaviorSubject({}),
|
||||||
|
reactions$: new BehaviorSubject({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fnArgType = {
|
||||||
|
control: { type: "select" as const },
|
||||||
|
options: ["MockedCallback", "undefined"],
|
||||||
|
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||||
|
};
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
hideLogo: true,
|
||||||
|
layoutMode: "grid",
|
||||||
|
audioEnabled: true,
|
||||||
|
videoEnabled: true,
|
||||||
|
setLayoutMode: fn(),
|
||||||
|
openSettings: fn(),
|
||||||
|
toggleAudio: fn(),
|
||||||
|
toggleVideo: fn(),
|
||||||
|
toggleScreenSharing: fn(),
|
||||||
|
hangup: fn(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen",
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
|
||||||
|
audioOutputSwitcher: {
|
||||||
|
control: "select",
|
||||||
|
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||||
|
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||||
|
mapping: {
|
||||||
|
NoOutputCallback: undefined,
|
||||||
|
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||||
|
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||||
|
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toggleScreenSharing: fnArgType,
|
||||||
|
setLayoutMode: fnArgType,
|
||||||
|
openSettings: fnArgType,
|
||||||
|
toggleAudio: fnArgType,
|
||||||
|
toggleVideo: fnArgType,
|
||||||
|
hangup: fnArgType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithLogo: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
hideLogo: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AudioVideoEnabled: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioEnabled: true,
|
||||||
|
videoEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithAudioOutputSpeaker: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioOutputSwitcher: { targetOutput: "earpiece", switch: fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithAudioOutputEarpiece: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const WithReactions: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
reactionIdentifier,
|
||||||
|
reactionData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Pip: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
asPip: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const NoControlsWithLogo: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
hideControls: true,
|
||||||
|
hideLogo: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DebugData: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
debugTileLayout: true,
|
||||||
|
tileStoreGeneration: 74,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnavailableMediaDevices: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
toggleAudio: undefined,
|
||||||
|
toggleVideo: undefined,
|
||||||
|
audioOutputSwitcher: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileLayout: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
hideLogo: true,
|
||||||
|
|
||||||
|
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
viewport: { value: "mobile2", isRotated: false },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...Default.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Lobby: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
hideLogo: true,
|
||||||
|
openSettings: undefined,
|
||||||
|
setLayoutMode: undefined,
|
||||||
|
toggleScreenSharing: undefined,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...Default.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LobbyMobile: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
hideLogo: true,
|
||||||
|
|
||||||
|
setLayoutMode: undefined,
|
||||||
|
toggleScreenSharing: undefined,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
viewport: { value: "mobile2", isRotated: false },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...Default.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LobbyRecentButton: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
children: <Link>Back To Recents</Link>,
|
||||||
|
hideLogo: true,
|
||||||
|
setLayoutMode: undefined,
|
||||||
|
toggleScreenSharing: undefined,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...Default.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LobbyRecentButtonMobile: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
children: <Link>Back To Recents</Link>,
|
||||||
|
hideLogo: true,
|
||||||
|
setLayoutMode: undefined,
|
||||||
|
toggleScreenSharing: undefined,
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
viewport: { value: "mobile2", isRotated: false },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...Default.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
243
src/components/CallFooter.tsx
Normal file
243
src/components/CallFooter.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
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 { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
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 { LayoutToggle } from "../room/LayoutToggle";
|
||||||
|
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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.*/
|
||||||
|
asOverlay?: boolean;
|
||||||
|
|
||||||
|
layoutMode?: GridMode;
|
||||||
|
/** Also controls if the layout button is visible */
|
||||||
|
setLayoutMode?: (mode: GridMode) => void;
|
||||||
|
|
||||||
|
sharingScreen?: boolean;
|
||||||
|
toggleScreenSharing?: () => void;
|
||||||
|
|
||||||
|
/** Also controls if the audio button is visible */
|
||||||
|
audioOutputSwitcher?: AudioOutputSwitcher;
|
||||||
|
/** Also controls if the settings button is visible */
|
||||||
|
openSettings?: () => void;
|
||||||
|
/** Also controls if the hangup button is visible */
|
||||||
|
hangup?: () => void;
|
||||||
|
|
||||||
|
reactionIdentifier?: string;
|
||||||
|
reactionData?: ReactionData;
|
||||||
|
|
||||||
|
hideLogo?: boolean;
|
||||||
|
// debug stuff
|
||||||
|
debugTileLayout?: boolean;
|
||||||
|
tileStoreGeneration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
const buttons: JSX.Element[] = [];
|
||||||
|
const buttonSize = asPip ? "sm" : "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.
|
||||||
|
buttons.push(
|
||||||
|
<SettingsButton
|
||||||
|
key="settings"
|
||||||
|
showForScreenWidth="narrow"
|
||||||
|
onClick={openSettings}
|
||||||
|
data-testid="settings-bottom-center"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push(
|
||||||
|
<MicButton
|
||||||
|
size={buttonSize}
|
||||||
|
key="audio"
|
||||||
|
enabled={audioEnabled ?? false}
|
||||||
|
onClick={toggleAudio}
|
||||||
|
disabled={toggleAudio === undefined}
|
||||||
|
data-testid="incall_mute"
|
||||||
|
/>,
|
||||||
|
<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 ?? {
|
||||||
|
handsRaised$: new BehaviorSubject({}),
|
||||||
|
reactions$: new BehaviorSubject({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
className={classNames(styles.footer, {
|
||||||
|
[styles.overlay]: asOverlay,
|
||||||
|
[styles.hidden]: hidden,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.settingsLogoContainer}>
|
||||||
|
{showSettingsButton && (
|
||||||
|
<SettingsIconButton
|
||||||
|
key="settings"
|
||||||
|
kind="secondary"
|
||||||
|
showForScreenWidth="wide"
|
||||||
|
onClick={openSettings}
|
||||||
|
data-testid="settings-bottom-left"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{showLogoDebugContainer && logoDebugContainer}
|
||||||
|
</div>
|
||||||
|
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||||
|
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
||||||
|
<LayoutToggle
|
||||||
|
className={styles.layout}
|
||||||
|
layout={layoutMode}
|
||||||
|
setLayout={setLayoutMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,7 +29,7 @@ interface ReactionsSenderContextType {
|
|||||||
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionsSenderContext = createContext<
|
export const ReactionsSenderContext = createContext<
|
||||||
ReactionsSenderContextType | undefined
|
ReactionsSenderContextType | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
|
|||||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { HeaderStyle } from "../UrlParams";
|
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
import { type MuteStates } from "../state/MuteStates.ts";
|
import { type MuteStates } from "../state/MuteStates.ts";
|
||||||
|
|
||||||
@@ -173,7 +172,6 @@ function createGroupCallView(
|
|||||||
confineToRoom={false}
|
confineToRoom={false}
|
||||||
preload={false}
|
preload={false}
|
||||||
skipLobby={false}
|
skipLobby={false}
|
||||||
header={HeaderStyle.Standard}
|
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
muteStates={muteState}
|
muteStates={muteState}
|
||||||
widget={widget}
|
widget={widget}
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ interface Props {
|
|||||||
confineToRoom: boolean;
|
confineToRoom: boolean;
|
||||||
preload: UrlParams["preload"];
|
preload: UrlParams["preload"];
|
||||||
skipLobby: UrlParams["skipLobby"];
|
skipLobby: UrlParams["skipLobby"];
|
||||||
header: HeaderStyle;
|
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
setJoined: (value: boolean) => void;
|
setJoined: (value: boolean) => void;
|
||||||
@@ -107,7 +106,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
confineToRoom,
|
confineToRoom,
|
||||||
preload,
|
preload,
|
||||||
skipLobby,
|
skipLobby,
|
||||||
header,
|
|
||||||
rtcSession,
|
rtcSession,
|
||||||
joined,
|
joined,
|
||||||
setJoined,
|
setJoined,
|
||||||
@@ -182,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
perParticipantE2EE,
|
perParticipantE2EE,
|
||||||
returnToLobby,
|
returnToLobby,
|
||||||
password: passwordFromUrl,
|
password: passwordFromUrl,
|
||||||
|
header,
|
||||||
} = useUrlParams();
|
} = useUrlParams();
|
||||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||||
|
|
||||||
@@ -437,7 +436,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
onEnter={() => setJoined(true)}
|
onEnter={() => setJoined(true)}
|
||||||
confineToRoom={confineToRoom}
|
confineToRoom={confineToRoom}
|
||||||
hideHeader={header === HeaderStyle.None}
|
hideHeader={header !== HeaderStyle.Standard}
|
||||||
participantCount={participantCount}
|
participantCount={participantCount}
|
||||||
onShareClick={onShareClick}
|
onShareClick={onShareClick}
|
||||||
/>
|
/>
|
||||||
@@ -463,7 +462,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
rtcSession={rtcSession as MatrixRTCSession}
|
rtcSession={rtcSession as MatrixRTCSession}
|
||||||
matrixRoom={room}
|
matrixRoom={room}
|
||||||
onLeft={onLeft}
|
onLeft={onLeft}
|
||||||
header={header}
|
|
||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
e2eeSystem={e2eeSystem}
|
e2eeSystem={e2eeSystem}
|
||||||
//otelGroupCallMembership={otelGroupCallMembership}
|
//otelGroupCallMembership={otelGroupCallMembership}
|
||||||
|
|||||||
@@ -31,144 +31,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
|
||||||
position: sticky;
|
|
||||||
inset-block-end: 0;
|
|
||||||
z-index: var(--call-view-header-footer-layer);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
grid-template-areas: ". buttons layout";
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--cpd-space-3x);
|
|
||||||
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(0, 0, 0, 0) 0%,
|
|
||||||
var(--cpd-color-bg-canvas-default) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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. */
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer.overlay.hidden {
|
|
||||||
display: grid;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
/* Switch to position: absolute so the footer takes up no space in the layout
|
|
||||||
when hidden. */
|
|
||||||
position: absolute;
|
|
||||||
inset-block-end: 0;
|
|
||||||
inset-inline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer.overlay:has(:focus-visible) {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsLogoContainer {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--cpd-space-4x);
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
justify-self: start;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--cpd-space-2x);
|
|
||||||
padding-inline-start: var(--cpd-space-1x);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
grid-area: buttons;
|
|
||||||
justify-self: center;
|
|
||||||
display: flex;
|
|
||||||
gap: var(--cpd-space-3x);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
grid-area: layout;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*First hide the logo*/
|
|
||||||
@media (max-width: 750px) {
|
|
||||||
.logo {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
|
||||||
Once we exceed 500 we hide everything except the buttons.
|
|
||||||
*/
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.footer {
|
|
||||||
grid-template-areas: "buttons buttons buttons";
|
|
||||||
}
|
|
||||||
|
|
||||||
/*.settingsLogoContainer {
|
|
||||||
display: none;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-height: 800px) {
|
|
||||||
.footer {
|
|
||||||
padding-block: var(--cpd-space-8x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-height: 400px) {
|
|
||||||
.footer {
|
|
||||||
padding-block: var(--cpd-space-4x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 370px) {
|
|
||||||
.shareScreen {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* PIP custom css */
|
|
||||||
@media (max-height: 400px) {
|
|
||||||
.shareScreen {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
padding-block-start: var(--cpd-space-3x);
|
|
||||||
padding-block-end: var(--cpd-space-2x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 320px) {
|
@media (max-width: 320px) {
|
||||||
.invite,
|
.invite {
|
||||||
.raiseHand {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
.buttons {
|
|
||||||
gap: var(--cpd-space-4x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixedGrid {
|
.fixedGrid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { render, type RenderResult } from "@testing-library/react";
|
import { render, type RenderResult } from "@testing-library/react";
|
||||||
import { type LocalParticipant } from "livekit-client";
|
import { type LocalParticipant } from "livekit-client";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
@@ -34,13 +34,15 @@ import {
|
|||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||||
|
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
|
||||||
import { alice, local } from "../utils/test-fixtures";
|
import { alice, local } from "../utils/test-fixtures";
|
||||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { HeaderStyle } from "../UrlParams";
|
|
||||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||||
|
import { constant } from "../state/Behavior";
|
||||||
|
import { AppBar } from "../AppBar";
|
||||||
import { initializeWidget } from "../widget";
|
import { initializeWidget } from "../widget";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
@@ -97,6 +99,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
interface CreateInCallViewArgs {
|
interface CreateInCallViewArgs {
|
||||||
mediaDevices?: ECMediaDevices;
|
mediaDevices?: ECMediaDevices;
|
||||||
|
callViewModelOptions?: Partial<CallViewModelOptions>;
|
||||||
|
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
|
||||||
|
initialRoute?: string;
|
||||||
|
/** If true, wraps the rendered tree in an AppBar provider */
|
||||||
|
withAppBar?: boolean;
|
||||||
}
|
}
|
||||||
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
@@ -115,47 +122,59 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
|||||||
[local, alice],
|
[local, alice],
|
||||||
undefined,
|
undefined,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
{},
|
args.callViewModelOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
rtcSession.joined = true;
|
rtcSession.joined = true;
|
||||||
const room = rtcSession.room;
|
const room = rtcSession.room;
|
||||||
const client = room.client;
|
const client = room.client;
|
||||||
|
|
||||||
|
const Router = args.initialRoute
|
||||||
|
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
|
||||||
|
<MemoryRouter initialEntries={[args.initialRoute!]}>
|
||||||
|
{children}
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
: BrowserRouter;
|
||||||
|
|
||||||
|
const inCallView = (
|
||||||
|
<InCallView
|
||||||
|
client={client}
|
||||||
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
|
muteStates={muteState}
|
||||||
|
vm={vm}
|
||||||
|
matrixInfo={{
|
||||||
|
userId: "",
|
||||||
|
displayName: "",
|
||||||
|
avatarUrl: "",
|
||||||
|
roomId: "",
|
||||||
|
roomName: "",
|
||||||
|
roomAlias: null,
|
||||||
|
roomAvatar: null,
|
||||||
|
e2eeSystem: {
|
||||||
|
kind: E2eeType.NONE,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
matrixRoom={room}
|
||||||
|
onShareClick={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
|
||||||
|
|
||||||
const renderResult = render(
|
const renderResult = render(
|
||||||
<BrowserRouter>
|
<Router>
|
||||||
<MediaDevicesContext value={mediaDevices}>
|
<MediaDevicesContext value={mediaDevices}>
|
||||||
<ReactionsSenderProvider
|
<ReactionsSenderProvider
|
||||||
vm={vm}
|
vm={vm}
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<RoomContext value={livekitRoom}>
|
<RoomContext value={livekitRoom}>{content}</RoomContext>
|
||||||
<InCallView
|
|
||||||
client={client}
|
|
||||||
header={HeaderStyle.Standard}
|
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
|
||||||
muteStates={muteState}
|
|
||||||
vm={vm}
|
|
||||||
matrixInfo={{
|
|
||||||
userId: "",
|
|
||||||
displayName: "",
|
|
||||||
avatarUrl: "",
|
|
||||||
roomId: "",
|
|
||||||
roomName: "",
|
|
||||||
roomAlias: null,
|
|
||||||
roomAvatar: null,
|
|
||||||
e2eeSystem: {
|
|
||||||
kind: E2eeType.NONE,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
matrixRoom={room}
|
|
||||||
onShareClick={null}
|
|
||||||
/>
|
|
||||||
</RoomContext>
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ReactionsSenderProvider>
|
</ReactionsSenderProvider>
|
||||||
</MediaDevicesContext>
|
</MediaDevicesContext>
|
||||||
</BrowserRouter>,
|
</Router>,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...renderResult,
|
...renderResult,
|
||||||
@@ -170,6 +189,57 @@ describe("InCallView", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("settings button with AppBar header", () => {
|
||||||
|
it("mobile landscape, is accessible when showHeader is false", () => {
|
||||||
|
// windowSize with height <= 600 results in "flat" windowMode,
|
||||||
|
// which means showHeader$ emits false.
|
||||||
|
const { getAllByRole } = createInCallView({
|
||||||
|
initialRoute: "/?header=app_bar",
|
||||||
|
withAppBar: true,
|
||||||
|
callViewModelOptions: {
|
||||||
|
// Set windowMode$ to "flat" (height <= 600)
|
||||||
|
windowSize$: constant({ width: 1000, height: 500 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// When showHeader is false, hideSettingsButton is false,
|
||||||
|
// so the settings button is visible in the footer.
|
||||||
|
const settingsBtn = getAllByRole("button", { name: "Settings" });
|
||||||
|
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
|
||||||
|
// the narrow layout and another one for the wide layout.
|
||||||
|
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
|
||||||
|
// but we can at least check that both buttons are rendered and have the correct classes.
|
||||||
|
expect(settingsBtn.length).toBe(2);
|
||||||
|
expect(settingsBtn[0]).toHaveAttribute(
|
||||||
|
"data-testid",
|
||||||
|
"settings-bottom-left",
|
||||||
|
);
|
||||||
|
expect(settingsBtn[0]).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mobile portrait, is accessible when showHeader is true", () => {
|
||||||
|
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
|
||||||
|
// which means showHeader$ emits true.
|
||||||
|
const { getAllByRole } = createInCallView({
|
||||||
|
initialRoute: "/?header=app_bar",
|
||||||
|
withAppBar: true,
|
||||||
|
callViewModelOptions: {
|
||||||
|
// Set windowMode$ to "normal" (height >= 600)
|
||||||
|
windowSize$: constant({ width: 1000, height: 800 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// When showHeader is true and headerStyle is AppBar,
|
||||||
|
// hideSettingsButton is true in the footer, but the settings
|
||||||
|
// button is rendered in the AppBar via useAppBarSecondaryButton.
|
||||||
|
const settingsBtns = getAllByRole("button", { name: "Settings" });
|
||||||
|
|
||||||
|
expect(settingsBtns.length).toBe(1);
|
||||||
|
expect(settingsBtns[0]).toHaveAttribute(
|
||||||
|
"data-testid",
|
||||||
|
"settings-app-bar",
|
||||||
|
);
|
||||||
|
expect(settingsBtns[0]).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
describe("audioOutputSwitcher", () => {
|
describe("audioOutputSwitcher", () => {
|
||||||
it("is visible and can be clicked", async () => {
|
it("is visible and can be clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -183,9 +253,10 @@ describe("InCallView", () => {
|
|||||||
["earpiece-id", { type: "earpiece" }],
|
["earpiece-id", { type: "earpiece" }],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const selected$ = new BehaviorSubject<
|
const selected$ = new BehaviorSubject({
|
||||||
{ id: string; virtualEarpiece: boolean } | undefined
|
id: "speaker-id",
|
||||||
>({ id: "speaker-id", virtualEarpiece: false });
|
virtualEarpiece: false,
|
||||||
|
});
|
||||||
|
|
||||||
const mediaDevices = mockMediaDevices({
|
const mediaDevices = mockMediaDevices({
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
@@ -197,8 +268,7 @@ describe("InCallView", () => {
|
|||||||
|
|
||||||
const { getByRole } = createInCallView({ mediaDevices });
|
const { getByRole } = createInCallView({ mediaDevices });
|
||||||
// The button should be visible. When current output is "speaker",
|
// The button should be visible. When current output is "speaker",
|
||||||
// the switcher targets "earpiece", so the tooltip label is "Handset".
|
const audioOutputBtn = getByRole("button", { name: "Loudspeaker" });
|
||||||
const audioOutputBtn = getByRole("button", { name: "Handset" });
|
|
||||||
expect(audioOutputBtn).toBeVisible();
|
expect(audioOutputBtn).toBeVisible();
|
||||||
|
|
||||||
await user.click(audioOutputBtn);
|
await user.click(audioOutputBtn);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
|
||||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@@ -25,22 +24,8 @@ import classNames from "classnames";
|
|||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
import { useObservable } from "observable-hooks";
|
import { useObservable } from "observable-hooks";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
|
||||||
VoiceCallSolidIcon,
|
|
||||||
VolumeOnSolidIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
|
||||||
import LogoType from "../icons/LogoType.svg?react";
|
|
||||||
import {
|
|
||||||
EndCallButton,
|
|
||||||
MicButton,
|
|
||||||
VideoButton,
|
|
||||||
ShareScreenButton,
|
|
||||||
ReactionToggleButton,
|
|
||||||
SettingsIconButton,
|
|
||||||
} from "../button";
|
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
@@ -55,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
|
|||||||
import { type MuteStates } from "../state/MuteStates";
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { InviteButton } from "../button/InviteButton";
|
import { InviteButton } from "../button/InviteButton";
|
||||||
import { LayoutToggle } from "./LayoutToggle";
|
|
||||||
import {
|
import {
|
||||||
type CallViewModel,
|
type CallViewModel,
|
||||||
createCallViewModel$,
|
createCallViewModel$,
|
||||||
@@ -106,6 +90,8 @@ 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 { SettingsIconButton } from "../button/Button.tsx";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
|
|
||||||
@@ -185,7 +171,6 @@ export interface InCallViewProps {
|
|||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
matrixRoom: MatrixRoom;
|
matrixRoom: MatrixRoom;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
header: HeaderStyle;
|
|
||||||
onShareClick: (() => void) | null;
|
onShareClick: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +180,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
matrixInfo,
|
matrixInfo,
|
||||||
matrixRoom,
|
matrixRoom,
|
||||||
muteStates,
|
muteStates,
|
||||||
|
|
||||||
header: headerStyle,
|
|
||||||
onShareClick,
|
onShareClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -220,7 +203,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// Merge the refs so they can attach to the same element
|
// Merge the refs so they can attach to the same element
|
||||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||||
|
|
||||||
const { showControls } = useUrlParams();
|
const { showControls, header: headerStyle } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
|
|
||||||
@@ -378,7 +361,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
let header: ReactNode = null;
|
let header: ReactNode = null;
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
switch (headerStyle) {
|
switch (headerStyle) {
|
||||||
case "none":
|
case HeaderStyle.AppBar: {
|
||||||
|
// dont build a header here. The AppBar will take care of it.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case HeaderStyle.None:
|
||||||
// Cosmetic header to fill out space while still affecting the bounds of
|
// Cosmetic header to fill out space while still affecting the bounds of
|
||||||
// the grid
|
// the grid
|
||||||
header = (
|
header = (
|
||||||
@@ -388,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "standard":
|
case HeaderStyle.Standard:
|
||||||
header = (
|
header = (
|
||||||
<Header
|
<Header
|
||||||
className={styles.header}
|
className={styles.header}
|
||||||
@@ -575,138 +562,46 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
matrixRoom.roomId,
|
matrixRoom.roomId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttons: JSX.Element[] = [];
|
const settingsButtonInAppBar =
|
||||||
|
headerStyle === HeaderStyle.AppBar && showHeader;
|
||||||
const buttonSize = layout.type === "pip" ? "sm" : "lg";
|
|
||||||
buttons.push(
|
|
||||||
<MicButton
|
|
||||||
size={buttonSize}
|
|
||||||
key="audio"
|
|
||||||
enabled={audioEnabled}
|
|
||||||
onClick={toggleAudio ?? undefined}
|
|
||||||
disabled={toggleAudio === null}
|
|
||||||
data-testid="incall_mute"
|
|
||||||
/>,
|
|
||||||
<VideoButton
|
|
||||||
size={buttonSize}
|
|
||||||
key="video"
|
|
||||||
enabled={videoEnabled}
|
|
||||||
onClick={toggleVideo ?? undefined}
|
|
||||||
disabled={toggleVideo === null}
|
|
||||||
data-testid="incall_videomute"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
if (vm.toggleScreenSharing !== null) {
|
|
||||||
buttons.push(
|
|
||||||
<ShareScreenButton
|
|
||||||
size={buttonSize}
|
|
||||||
key="share_screen"
|
|
||||||
className={styles.shareScreen}
|
|
||||||
enabled={sharingScreen}
|
|
||||||
onClick={vm.toggleScreenSharing}
|
|
||||||
data-testid="incall_screenshare"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (supportsReactions) {
|
|
||||||
buttons.push(
|
|
||||||
<ReactionToggleButton
|
|
||||||
size={buttonSize}
|
|
||||||
vm={vm}
|
|
||||||
key="raise_hand"
|
|
||||||
className={styles.raiseHand}
|
|
||||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In this PR we just move the button ot the bottom bar. We do not yet update its apperance
|
|
||||||
const audioOutputButton = useMemo(() => {
|
|
||||||
if (audioOutputSwitcher === null) return null;
|
|
||||||
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
|
||||||
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
|
|
||||||
const label = isEarpieceTarget
|
|
||||||
? t("settings.devices.handset")
|
|
||||||
: t("settings.devices.loudspeaker");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={label}>
|
|
||||||
<IconButton
|
|
||||||
key="audio_output_switcher"
|
|
||||||
onClick={(e) => {
|
|
||||||
audioOutputSwitcher.switch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}, [t, audioOutputSwitcher]);
|
|
||||||
if (audioOutputButton) buttons.push(audioOutputButton);
|
|
||||||
|
|
||||||
useAppBarSecondaryButton(
|
useAppBarSecondaryButton(
|
||||||
<SettingsIconButton key="settings" onClick={openSettings} />,
|
<SettingsIconButton
|
||||||
);
|
key="settings"
|
||||||
|
onClick={openSettings}
|
||||||
buttons.push(
|
data-testid="settings-app-bar"
|
||||||
<EndCallButton
|
|
||||||
size={buttonSize}
|
|
||||||
key="end_call"
|
|
||||||
onClick={function (): void {
|
|
||||||
vm.hangup();
|
|
||||||
}}
|
|
||||||
data-testid="incall_leave"
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const logo = (
|
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||||
<div className={styles.logo}>
|
|
||||||
<LogoMark width={24} height={24} aria-hidden />
|
|
||||||
<LogoType
|
|
||||||
width={80}
|
|
||||||
height={11}
|
|
||||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
|
||||||
/>
|
|
||||||
{/* Don't mind this odd placement, it's just a little debug label */}
|
|
||||||
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<div
|
<CallFooter
|
||||||
ref={footerRef}
|
ref={footerRef}
|
||||||
className={classNames(styles.footer, {
|
hidden={!showFooter}
|
||||||
[styles.overlay]: windowMode === "flat",
|
hideControls={!showControls}
|
||||||
[styles.hidden]:
|
asOverlay={windowMode === "flat"}
|
||||||
!showFooter || (!showControls && headerStyle === "none"),
|
asPip={layout.type === "pip"}
|
||||||
})}
|
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
||||||
>
|
hideLogo={headerStyle !== HeaderStyle.Standard}
|
||||||
<div className={styles.settingsLogoContainer}>
|
layoutMode={gridMode}
|
||||||
{showControls &&
|
setLayoutMode={setGridMode}
|
||||||
// Settings button is also shown in the app bar if present
|
audioEnabled={audioEnabled}
|
||||||
headerStyle !== HeaderStyle.AppBar &&
|
toggleAudio={toggleAudio ?? undefined}
|
||||||
layout.type !== "pip" && (
|
videoEnabled={videoEnabled}
|
||||||
<SettingsIconButton
|
toggleVideo={toggleVideo ?? undefined}
|
||||||
kind="secondary"
|
sharingScreen={sharingScreen}
|
||||||
key="settings"
|
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
|
||||||
onClick={openSettings}
|
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.
|
||||||
{headerStyle !== "none" && logo}
|
// If there is no fn the button will be hidden in the footer.
|
||||||
</div>
|
openSettings={settingsButtonInAppBar ? undefined : openSettings}
|
||||||
|
hangup={vm.hangup}
|
||||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
//Debug props
|
||||||
{showControls && (
|
debugTileLayout={debugTileLayout}
|
||||||
<LayoutToggle
|
tileStoreGeneration={tileStoreGeneration}
|
||||||
className={styles.layout}
|
/>
|
||||||
layout={gridMode}
|
|
||||||
setLayout={setGridMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const allConnections = useBehavior(vm.allConnections$);
|
const allConnections = useBehavior(vm.allConnections$);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.toggle, className)}>
|
<form className={classNames(styles.toggle, className)}>
|
||||||
<Tooltip label={t("layout_spotlight_label")}>
|
<Tooltip label={t("layout_spotlight_label")}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -54,6 +54,6 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<GridIcon aria-hidden width={24} height={24} />
|
<GridIcon aria-hidden width={24} height={24} />
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
|||||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||||
import { type MuteStates } from "../state/MuteStates";
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
import { InviteButton } from "../button/InviteButton";
|
import { InviteButton } from "../button/InviteButton";
|
||||||
import {
|
|
||||||
EndCallButton,
|
|
||||||
MicButton,
|
|
||||||
SettingsIconButton,
|
|
||||||
VideoButton,
|
|
||||||
} from "../button/Button";
|
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
import { useMediaQuery } from "../useMediaQuery";
|
import { useMediaQuery } from "../useMediaQuery";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
@@ -52,6 +46,7 @@ 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";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -226,23 +221,18 @@ export const LobbyView: FC<Props> = ({
|
|||||||
</VideoPreview>
|
</VideoPreview>
|
||||||
{!recentsButtonInFooter && recentsButton}
|
{!recentsButtonInFooter && recentsButton}
|
||||||
</div>
|
</div>
|
||||||
<div className={inCallStyles.footer}>
|
<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}
|
||||||
|
>
|
||||||
{recentsButtonInFooter && recentsButton}
|
{recentsButtonInFooter && recentsButton}
|
||||||
<SettingsIconButton kind="secondary" onClick={openSettings} />
|
</CallFooter>
|
||||||
<div className={inCallStyles.buttons}>
|
|
||||||
<MicButton
|
|
||||||
enabled={audioEnabled}
|
|
||||||
onClick={toggleAudio ?? undefined}
|
|
||||||
disabled={toggleAudio === null}
|
|
||||||
/>
|
|
||||||
<VideoButton
|
|
||||||
enabled={videoEnabled}
|
|
||||||
onClick={toggleVideo ?? undefined}
|
|
||||||
disabled={toggleVideo === null}
|
|
||||||
/>
|
|
||||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{client && (
|
{client && (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
confineToRoom={confineToRoom}
|
confineToRoom={confineToRoom}
|
||||||
preload={preload}
|
preload={preload}
|
||||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||||
header={header}
|
|
||||||
muteStates={muteStates}
|
muteStates={muteStates}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -170,8 +170,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="_r_8_"
|
aria-labelledby="_r_8_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
|
data-testid="settings-bottom-left"
|
||||||
role="button"
|
role="button"
|
||||||
style="--cpd-icon-button-size: 32px;"
|
style="--cpd-icon-button-size: 32px;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -302,10 +303,32 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="buttons"
|
class="buttons"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_d_"
|
||||||
|
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||||
|
data-kind="secondary"
|
||||||
|
data-size="lg"
|
||||||
|
data-testid="settings-bottom-center"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_d_"
|
aria-labelledby="_r_i_"
|
||||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -329,7 +352,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
<button
|
<button
|
||||||
aria-checked="false"
|
aria-checked="false"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-labelledby="_r_i_"
|
aria-labelledby="_r_n_"
|
||||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -351,7 +374,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="_r_n_"
|
aria-labelledby="_r_s_"
|
||||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="lg"
|
data-size="lg"
|
||||||
@@ -373,11 +396,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<form
|
||||||
class="toggle layout"
|
class="toggle layout"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="_r_s_"
|
aria-labelledby="_r_11_"
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
@@ -395,7 +418,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="_r_11_"
|
aria-labelledby="_r_16_"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layout"
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -413,7 +436,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ import { constant, type Behavior } from "../Behavior";
|
|||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||||
import { type MuteStates } from "../MuteStates";
|
import { type MuteStates } from "../MuteStates";
|
||||||
import { getUrlParams } from "../../UrlParams";
|
import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { ElementWidgetActions, widget } from "../../widget";
|
import { ElementWidgetActions, widget } from "../../widget";
|
||||||
import {
|
import {
|
||||||
@@ -180,6 +180,8 @@ export interface CallViewModelOptions {
|
|||||||
connectionFactory?: ConnectionFactory;
|
connectionFactory?: ConnectionFactory;
|
||||||
/** The version & compatibility mode of MatrixRTC that we should use. */
|
/** The version & compatibility mode of MatrixRTC that we should use. */
|
||||||
matrixRTCMode$?: Behavior<MatrixRTCMode>;
|
matrixRTCMode$?: Behavior<MatrixRTCMode>;
|
||||||
|
/** Optional behavior overriding for the screensharing, for testing */
|
||||||
|
toggleScreensharing?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not play any sounds if the participant count has exceeded this
|
// Do not play any sounds if the participant count has exceeded this
|
||||||
@@ -1326,7 +1328,11 @@ export function createCallViewModel$(
|
|||||||
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
|
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
|
||||||
);
|
);
|
||||||
|
|
||||||
const showFooter$ = scope.behavior<boolean>(
|
const urlParams = getUrlParams();
|
||||||
|
const showFooterUrlParams = !(
|
||||||
|
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||||
|
);
|
||||||
|
const showFooterLayout$ = scope.behavior<boolean>(
|
||||||
windowMode$.pipe(
|
windowMode$.pipe(
|
||||||
switchMap((mode) => {
|
switchMap((mode) => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
@@ -1380,7 +1386,11 @@ export function createCallViewModel$(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const showFooter$ = scope.behavior(
|
||||||
|
showFooterLayout$.pipe(
|
||||||
|
map((showFooter) => showFooter && showFooterUrlParams),
|
||||||
|
),
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* Whether audio is currently being output through the earpiece.
|
* Whether audio is currently being output through the earpiece.
|
||||||
*/
|
*/
|
||||||
@@ -1507,7 +1517,8 @@ export function createCallViewModel$(
|
|||||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||||
*/
|
*/
|
||||||
// reassigned here to make it publicly accessible
|
// reassigned here to make it publicly accessible
|
||||||
const toggleScreenSharing = localMembership.toggleScreenSharing;
|
const toggleScreenSharing =
|
||||||
|
options.toggleScreensharing ?? localMembership.toggleScreenSharing;
|
||||||
|
|
||||||
const errors$ = scope.behavior<{
|
const errors$ = scope.behavior<{
|
||||||
transportError?: ElementCallError;
|
transportError?: ElementCallError;
|
||||||
|
|||||||
Reference in New Issue
Block a user