mirror of
https://github.com/vector-im/element-call.git
synced 2026-05-01 09:54:37 +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";
|
||||
|
||||
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"],
|
||||
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 { 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 { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -103,7 +103,7 @@ mobileTest(
|
||||
.click();
|
||||
|
||||
// dismiss settings
|
||||
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
|
||||
await guestPage.locator("#root").press("Escape");
|
||||
|
||||
await guestPage.pause();
|
||||
await expect(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
import { platform } from "./Platform";
|
||||
@@ -49,7 +50,9 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [hidden, setHidden] = useState<boolean>(false);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const context = useMemo(
|
||||
() => ({ setTitle, setSecondaryButton, setHidden }),
|
||||
[setTitle, setHidden, setSecondaryButton],
|
||||
@@ -114,6 +117,10 @@ export function useAppBarHidden(hidden: boolean): void {
|
||||
if (setHidden !== undefined) {
|
||||
setHidden(hidden);
|
||||
return (): void => setHidden(false);
|
||||
} else if (platform !== "desktop") {
|
||||
logger.warn(
|
||||
"[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect",
|
||||
);
|
||||
}
|
||||
}, [setHidden, hidden]);
|
||||
}
|
||||
@@ -129,6 +136,10 @@ export function useAppBarSecondaryButton(button: ReactNode): void {
|
||||
if (setSecondaryButton !== undefined) {
|
||||
setSecondaryButton(button);
|
||||
return (): void => setSecondaryButton("");
|
||||
} else if (platform !== "desktop") {
|
||||
logger.warn(
|
||||
"[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect",
|
||||
);
|
||||
}
|
||||
}, [button, setSecondaryButton]);
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
ShareScreenSolidIcon,
|
||||
OverflowHorizontalIcon,
|
||||
OverflowVerticalIcon,
|
||||
VolumeOnSolidIcon,
|
||||
VolumeOffSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./Button.module.css";
|
||||
import callFooterStyles from "../components/CallFooter.module.css";
|
||||
import { platform } from "../Platform";
|
||||
|
||||
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"> {
|
||||
/** If this buttons should be setup to be used in the app bar */
|
||||
showForScreenWidth?: "wide" | "narrow";
|
||||
kind?: "secondary" | "primary";
|
||||
}
|
||||
export const SettingsIconButton: FC<SettingsIconButtonProps> = (props) => {
|
||||
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon =
|
||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<IconButton {...props}>
|
||||
<IconButton
|
||||
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||
{...props}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
// size?: "sm" | "lg";
|
||||
// }
|
||||
// const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
// const { t } = useTranslation();
|
||||
// const Icon =
|
||||
// platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||
// return (
|
||||
// <Tooltip label={t("common.settings")}>
|
||||
// <CpdButton iconOnly Icon={Icon} kind="secondary" {...props} />
|
||||
// </Tooltip>
|
||||
// );
|
||||
// };
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
/** If this buttons should be setup to be used in the app bar */
|
||||
showForScreenWidth?: "wide" | "narrow";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
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}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
||||
<ReactionToggleButton
|
||||
reactionData={{
|
||||
reactions$: vm.reactions$,
|
||||
handsRaised$: vm.handsRaised$,
|
||||
}}
|
||||
identifier={localIdent}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -28,13 +28,14 @@ import classNames from "classnames";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
import {
|
||||
type RaisedHandInfo,
|
||||
type ReactionOption,
|
||||
ReactionSet,
|
||||
ReactionsRowSize,
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
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"> {
|
||||
reactionData: ReactionData;
|
||||
identifier: string;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
/** List of participants raising their hand */
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
identifier,
|
||||
vm,
|
||||
reactionData: { handsRaised$, reactions$ },
|
||||
...props
|
||||
}: ReactionToggleButtonProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,8 +188,8 @@ export function ReactionToggleButton({
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
||||
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(reactions$)[identifier];
|
||||
|
||||
useEffect(() => {
|
||||
// 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>;
|
||||
}
|
||||
|
||||
const ReactionsSenderContext = createContext<
|
||||
export const ReactionsSenderContext = createContext<
|
||||
ReactionsSenderContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MuteStates } from "../state/MuteStates.ts";
|
||||
|
||||
@@ -173,7 +172,6 @@ function createGroupCallView(
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
|
||||
@@ -93,7 +93,6 @@ interface Props {
|
||||
confineToRoom: boolean;
|
||||
preload: UrlParams["preload"];
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
header: HeaderStyle;
|
||||
rtcSession: MatrixRTCSession;
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
@@ -107,7 +106,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
header,
|
||||
rtcSession,
|
||||
joined,
|
||||
setJoined,
|
||||
@@ -182,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
perParticipantE2EE,
|
||||
returnToLobby,
|
||||
password: passwordFromUrl,
|
||||
header,
|
||||
} = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
@@ -437,7 +436,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
hideHeader={header !== HeaderStyle.Standard}
|
||||
participantCount={participantCount}
|
||||
onShareClick={onShareClick}
|
||||
/>
|
||||
@@ -463,7 +462,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
|
||||
@@ -31,144 +31,12 @@ Please see LICENSE in the repository root for full details.
|
||||
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) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
.invite {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.buttons {
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
|
||||
.fixedGrid {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
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 { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -34,13 +34,15 @@ import {
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { initializeWidget } from "../widget";
|
||||
|
||||
initializeWidget();
|
||||
@@ -97,6 +99,11 @@ beforeEach(() => {
|
||||
});
|
||||
interface CreateInCallViewArgs {
|
||||
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 & {
|
||||
rtcSession: MockRTCSession;
|
||||
@@ -115,47 +122,59 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
{},
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
|
||||
rtcSession.joined = true;
|
||||
const room = rtcSession.room;
|
||||
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(
|
||||
<BrowserRouter>
|
||||
<Router>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<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>
|
||||
<RoomContext value={livekitRoom}>{content}</RoomContext>
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
</Router>,
|
||||
);
|
||||
return {
|
||||
...renderResult,
|
||||
@@ -170,6 +189,57 @@ describe("InCallView", () => {
|
||||
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", () => {
|
||||
it("is visible and can be clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -183,9 +253,10 @@ describe("InCallView", () => {
|
||||
["earpiece-id", { type: "earpiece" }],
|
||||
]),
|
||||
);
|
||||
const selected$ = new BehaviorSubject<
|
||||
{ id: string; virtualEarpiece: boolean } | undefined
|
||||
>({ id: "speaker-id", virtualEarpiece: false });
|
||||
const selected$ = new BehaviorSubject({
|
||||
id: "speaker-id",
|
||||
virtualEarpiece: false,
|
||||
});
|
||||
|
||||
const mediaDevices = mockMediaDevices({
|
||||
audioOutput: {
|
||||
@@ -197,8 +268,7 @@ describe("InCallView", () => {
|
||||
|
||||
const { getByRole } = createInCallView({ mediaDevices });
|
||||
// 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: "Handset" });
|
||||
const audioOutputBtn = getByRole("button", { name: "Loudspeaker" });
|
||||
expect(audioOutputBtn).toBeVisible();
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
@@ -25,22 +24,8 @@ import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
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 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 { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
@@ -55,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
@@ -106,6 +90,8 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { useLatest } from "../useLatest.ts";
|
||||
import { CallFooter } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
@@ -185,7 +171,6 @@ export interface InCallViewProps {
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
muteStates: MuteStates;
|
||||
header: HeaderStyle;
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
@@ -195,8 +180,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixInfo,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
|
||||
header: headerStyle,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -220,7 +203,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const { showControls } = useUrlParams();
|
||||
const { showControls, header: headerStyle } = useUrlParams();
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
|
||||
@@ -378,7 +361,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
let header: ReactNode = null;
|
||||
if (showHeader) {
|
||||
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
|
||||
// the grid
|
||||
header = (
|
||||
@@ -388,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "standard":
|
||||
case HeaderStyle.Standard:
|
||||
header = (
|
||||
<Header
|
||||
className={styles.header}
|
||||
@@ -575,138 +562,46 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
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);
|
||||
|
||||
const settingsButtonInAppBar =
|
||||
headerStyle === HeaderStyle.AppBar && showHeader;
|
||||
useAppBarSecondaryButton(
|
||||
<SettingsIconButton key="settings" onClick={openSettings} />,
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
<EndCallButton
|
||||
size={buttonSize}
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
vm.hangup();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-app-bar"
|
||||
/>,
|
||||
);
|
||||
|
||||
const logo = (
|
||||
<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>
|
||||
);
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = (
|
||||
<div
|
||||
<CallFooter
|
||||
ref={footerRef}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: windowMode === "flat",
|
||||
[styles.hidden]:
|
||||
!showFooter || (!showControls && headerStyle === "none"),
|
||||
})}
|
||||
>
|
||||
<div className={styles.settingsLogoContainer}>
|
||||
{showControls &&
|
||||
// Settings button is also shown in the app bar if present
|
||||
headerStyle !== HeaderStyle.AppBar &&
|
||||
layout.type !== "pip" && (
|
||||
<SettingsIconButton
|
||||
kind="secondary"
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{headerStyle !== "none" && logo}
|
||||
</div>
|
||||
|
||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.toggle, className)}>
|
||||
<form className={classNames(styles.toggle, className)}>
|
||||
<Tooltip label={t("layout_spotlight_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -54,6 +54,6 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<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 MuteStates } from "../state/MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
SettingsIconButton,
|
||||
VideoButton,
|
||||
} from "../button/Button";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@@ -52,6 +46,7 @@ import {
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { CallFooter } from "../components/CallFooter";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -226,23 +221,18 @@ export const LobbyView: FC<Props> = ({
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
</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}
|
||||
<SettingsIconButton kind="secondary" onClick={openSettings} />
|
||||
<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>
|
||||
</CallFooter>
|
||||
</div>
|
||||
{client && (
|
||||
<SettingsModal
|
||||
|
||||
@@ -132,7 +132,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -170,8 +170,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_8_"
|
||||
class="_icon-button_1215g_8"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
@@ -302,10 +303,32 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<div
|
||||
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
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_d_"
|
||||
aria-labelledby="_r_i_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -329,7 +352,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_i_"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -351,7 +374,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</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"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -373,11 +396,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
<form
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="_r_s_"
|
||||
aria-labelledby="_r_11_"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -395,7 +418,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="_r_11_"
|
||||
aria-labelledby="_r_16_"
|
||||
checked=""
|
||||
name="layout"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||
import { type MuteStates } from "../MuteStates";
|
||||
import { getUrlParams } from "../../UrlParams";
|
||||
import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
@@ -180,6 +180,8 @@ export interface CallViewModelOptions {
|
||||
connectionFactory?: ConnectionFactory;
|
||||
/** The version & compatibility mode of MatrixRTC that we should use. */
|
||||
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
|
||||
@@ -1326,7 +1328,11 @@ export function createCallViewModel$(
|
||||
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(
|
||||
switchMap((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.
|
||||
*/
|
||||
@@ -1507,7 +1517,8 @@ export function createCallViewModel$(
|
||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||
*/
|
||||
// reassigned here to make it publicly accessible
|
||||
const toggleScreenSharing = localMembership.toggleScreenSharing;
|
||||
const toggleScreenSharing =
|
||||
options.toggleScreensharing ?? localMembership.toggleScreenSharing;
|
||||
|
||||
const errors$ = scope.behavior<{
|
||||
transportError?: ElementCallError;
|
||||
|
||||
Reference in New Issue
Block a user