Merge pull request #3961 from element-hq/toger5/view-model-call-footer-example

Implement fast switcher (+ ViewModel with snapshot example)
This commit is contained in:
Timo
2026-05-20 23:38:00 +08:00
committed by GitHub
24 changed files with 1652 additions and 490 deletions

View File

@@ -3,6 +3,7 @@
"user_menu": "User menu"
},
"action": {
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",

View File

@@ -54,6 +54,8 @@ test("can only interact with header and footer while reconnecting", async ({
page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();

View File

@@ -5,18 +5,41 @@ 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 { expect, fn, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterProps } from "./CallFooter";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
function CallFooterWrapper(props: FooterProps): ReactNode {
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
* - Add additional react context
* The paraeters are all params from the FooterSnapshot,
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
* `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really.
* @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook.
*/
function CallFooterStoryWrapper({
children,
...vmSnapshot
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
const vm = useStaticViewModel(vmSnapshot);
return (
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
@@ -26,33 +49,28 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter {...props} />
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
);
}
const meta = {
component: CallFooterWrapper,
} satisfies Meta<typeof CallFooterWrapper>;
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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,
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
@@ -61,13 +79,34 @@ export const Default: Story = {
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
hideControls: false,
asOverlay: false,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
layout: "fullscreen",
},
argTypes: {
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
layoutMode: {
control: "radio",
options: ["grid", "spotlight"] satisfies GridMode[],
},
audioOutputSwitcher: {
control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"],
@@ -95,12 +134,12 @@ export const WithAudioAndVideoOptions: Story = {
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoOptions: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
@@ -110,7 +149,7 @@ export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
showLogo: true,
},
};
@@ -121,6 +160,51 @@ export const AudioVideoEnabled: Story = {
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
/** used to test switching to grid mode */
export const SpotlightMode: Story = {
...Default,
args: {
...Default.args,
layoutMode: "spotlight",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
},
};
export const WithAudioOutputSpeaker: Story = {
@@ -150,7 +234,37 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
buttonSize: "md",
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
@@ -158,7 +272,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
hideLogo: false,
showLogo: true,
},
};
@@ -187,7 +301,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -203,7 +317,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +331,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -235,7 +349,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -249,7 +363,7 @@ export const LobbyRecentButtonMobile: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},

View File

@@ -7,13 +7,12 @@ 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 { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -34,105 +33,123 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
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;
/**
* The Snapshot combines all fields required to populate the view.
*
* It is a combination of Actions and State.
* All Actions and State will be wrappen in behaviors.
* This has the advantage, that actions can mutate.
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
* With it being reactive we can use the existance of the action to update the rendering without
* requiring additional state.
*
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
* just happens on the syntax level with the `type = ... & ...` notation.
*/
export type FooterSnapshot = FooterActions & FooterState;
export interface FooterActions {
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
videoEnabled: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
hideControls?: boolean;
/** hide the entire footer*/
hidden?: boolean;
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
asPip?: boolean;
hideControls: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens.*/
asOverlay?: boolean;
* (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;
buttonSize: "md" | "lg";
showLogo: boolean;
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
layoutMode: GridMode | undefined;
/** 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;
sharingScreen: boolean;
reactionIdentifier?: string;
reactionData?: ReactionData;
/** Also controls if the audio output button is visible */
audioOutputSwitcher: AudioOutputSwitcher | undefined;
reactionIdentifier: string | undefined;
reactionData: ReactionData | undefined;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
debugTileLayout: boolean;
tileStoreGeneration: number | undefined;
audioOptions?: MenuOptions[];
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
export 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,
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const layoutMode = useBehavior(vm.layoutMode$);
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
const reactionData = useBehavior(vm.reactionData$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
const showLogo = !hideLogo && !asPip;
if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens.
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
if (openSettings !== undefined) {
// Add the settings button to the center group so it's visible on small
// screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push(
<SettingsButton
key="settings"
@@ -154,7 +171,7 @@ export const CallFooter: FC<FooterProps> = ({
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
onSelect={selectAudioButtonOption}
/>,
);
} else {
@@ -169,6 +186,7 @@ export const CallFooter: FC<FooterProps> = ({
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
@@ -177,10 +195,11 @@ export const CallFooter: FC<FooterProps> = ({
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_videomute"
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoDevice}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
/>,
);
} else {
@@ -213,12 +232,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
@@ -269,13 +283,14 @@ export const CallFooter: FC<FooterProps> = ({
return (
<div
ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
[styles.hidden]: !showFooter,
})}
>
<div className={styles.settingsLogoContainer}>
{showSettingsButton && (
{openSettings !== undefined && (
<SettingsIconButton
key="settings"
kind="secondary"
@@ -285,10 +300,10 @@ export const CallFooter: FC<FooterProps> = ({
/>
)}
{children}
{showLogoDebugContainer && logoDebugContainer}
{(showLogo || debugTileLayout) && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
{!hideControls && setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}

View File

@@ -0,0 +1,157 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
* simple constant values.
*/
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
gridMode$: constant("grid"),
setGridMode: (): void => {},
sharingScreen$: constant(false),
toggleScreenSharing: null,
audioOutputSwitcher$: constant(null),
handsRaised$: constant({}),
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;
}
/** A regular grid layout (not PiP). */
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
/** A PiP layout only the `type` matters for the tests. */
const pipLayout: Layout = {
type: "pip",
spotlight: {} as SpotlightTileViewModel,
};
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "number", number: 1 }],
["mic2", { type: "name", name: "Microphone 2" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
videoInput: {
available$: constant(
new Map<string, DeviceLabel>([
["cam1", { type: "name", name: "Camera 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
});
describe("createCallFooterViewModel", () => {
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$.value).toEqual([]);
}
it("are both empty when the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
});
it("are both empty when the layout is pip", () => {
checkEmptyFor("desktop", pipLayout);
});
it("are populated when the platform is desktop and the layout is not PiP", () => {
platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([
{
id: "mic1",
label: {
number: 1,
type: "number",
},
},
{
id: "mic2",
label: {
name: "Microphone 2",
type: "name",
},
},
]);
expect(vm.videoOptions$?.value).toEqual([
{
id: "cam1",
label: {
name: "Camera 1",
type: "name",
},
},
]);
});
});
});

View File

@@ -0,0 +1,272 @@
/*
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 { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
"audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
};
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
toggleBlur$: scope.behavior(
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
map(([current, switcherDisabled]) => {
return !switcherDisabled && supportsBackgroundProcessors()
? (): void => {
backgroundBlurSettings.setValue(!current);
}
: undefined;
}),
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
};
}
/**
* Creates the ViewModel for the CallFooter.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
*/
export function createCallFooterViewModel(
scope: ObservableScope,
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")),
);
const disableDeviceSwitcher$ = scope.behavior(
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
);
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
asOverlay$: callModel.edgeToEdge$,
buttonSize$: scope.behavior(
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
),
openSettings$: scope.behavior(
combineLatest([
isPip$,
callModel.showHeader$,
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip &&
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
showControls
? (): void => setSettingsOpen(true)
: undefined,
),
),
),
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
layoutMode$: callModel.gridMode$,
setLayoutMode$: scope.behavior(
isPip$.pipe(
map((isPip) =>
!isPip && showControls ? callModel.setGridMode : undefined,
),
),
),
sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
audioOutputSwitcher$: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
hangup$: constant(callModel.hangup),
reactionIdentifier$: constant(reactionIdentifier),
reactionData$: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
debugTileLayout$: debugTileLayoutSetting.value$,
tileStoreGeneration$: callModel.tileStoreGeneration$,
};
}
/**
* Creates a simplified ViewModel for the CallFooter used in the lobby
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
* a CallViewModel — it only needs mute states, device lists, and callbacks.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
*/
export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return {
...createStaticViewModel({
// we can safly skip any props that we do not need.
// The view model will then have less keys.
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
// be correcty matching the snapshot type.
showLogo,
hideControls: false,
asOverlay: false,
buttonSize: "lg",
showLayoutSwitcher: false,
openSettings,
hangup,
debugTileLayout: false,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
videoEnabled: undefined,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
};
}

View File

@@ -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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { fn, userEvent, within, expect } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -21,17 +20,11 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
iconsAndLabels: {
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
enabledLabel: "Enabled",
disabledLabel: "Disabled",
optionsButtonLabel: "Options",
},
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "option 1", id: "1" },
{ label: "option 2", id: "2" },
{ label: { type: "name", name: "Option 1" }, id: "1" },
{ label: { type: "name", name: "Option 2" }, id: "2" },
],
selectedOption: "1",
onMuteClick: fn(),
@@ -46,23 +39,18 @@ export const AudioMute: Story = {
iconsAndLabels: "audio",
enabled: false,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [
{
label: "example toggle",
id: "t0",
enabled: true,
},
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
// (both are TODO placeholders in the component). The mute button is first in the DOM.
const muteButton = canvas.getByLabelText("Unmute microphone");
const muteButton = canvas.getByTestId("incall_mute");
await userEvent.click(muteButton);
await expect(args.onMuteClick).toHaveBeenCalled();
},
@@ -74,10 +62,10 @@ export const AudioUnmute: Story = {
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
toggles: [],
selectedOption: "2",
},
};
@@ -88,10 +76,10 @@ export const VideoMute: Story = {
iconsAndLabels: "video",
enabled: false,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
toggles: [],
selectedOption: "1",
},
};
@@ -102,16 +90,11 @@ export const VideoUnmute: Story = {
iconsAndLabels: "video",
enabled: true,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [
{
label: "Blur Background",
id: "background_blurring",
enabled: false,
},
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
},
};

View File

@@ -9,13 +9,16 @@ import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<MediaMuteAndSwitchButton title={"Switcher"} />,
<TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
@@ -26,11 +29,13 @@ describe("MediaMuteAndSwitchButton", () => {
enabled: boolean,
): RenderResult => {
return render(
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>
</TooltipProvider>,
);
};
const renderAudioEndabled = renderLabels("audio", true);
@@ -39,16 +44,16 @@ describe("MediaMuteAndSwitchButton", () => {
const renderVideoDisabled = renderLabels("video", false);
expect(
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("button", { name: "Start video" }),
renderVideoEnabled.getByRole("switch", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
renderVideoDisabled.getByRole("switch", { name: "Stop video" }),
).toBeInTheDocument();
});
@@ -56,15 +61,17 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>
</TooltipProvider>,
);
await user.click(getByRole("button", { name: "Mute microphone" }));
await user.click(getByRole("switch", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
@@ -73,17 +80,19 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>
</TooltipProvider>,
);
await user.click(getByRole("button", { name: "Microphone" }));
@@ -95,17 +104,19 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>
</TooltipProvider>,
);
await user.click(getByRole("button", { name: "Microphone" }));
@@ -122,23 +133,25 @@ describe("MediaMuteAndSwitchButton", () => {
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
</TooltipProvider>
);
}
@@ -174,42 +187,47 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onVideoBlurToggle = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
onSelect={onSelect}
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
videoBlurToggleClick={onVideoBlurToggle}
onSelect={onSelect}
/>
</TooltipProvider>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(getByRole("button", { name: "Camera" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
name: "Blur background",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic2"
/>,
<TooltipProvider>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
/>
</TooltipProvider>,
);
// open menu

View File

@@ -12,45 +12,25 @@ import {
MenuItem,
ToggleMenuItem,
} from "@vector-im/compound-web";
import { t } from "i18next";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOffSolidIcon,
MicOnIcon,
MicOnSolidIcon,
SpinnerIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import { useTranslation } from "react-i18next";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices";
export interface MenuOptions {
label: string;
label: DeviceLabel;
id: string;
}
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface IconsAndLabels {
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
enabledLabel: string;
disabledLabel: string;
optionsButtonLabel: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
@@ -59,17 +39,13 @@ export interface MediaMuteAndSwitchButtonProps {
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
iconsAndLabels: "video" | "audio";
/** The options available for the media device selector modal */
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* The available toggles (including there current state)
* The toggle state is not stored by this component.
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
*/
toggles?: ToggleOption[];
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -77,70 +53,80 @@ export interface MediaMuteAndSwitchButtonProps {
onSelect?: (id: string) => void;
}
const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
onMuteClick,
iconsAndLabels: iconsAndLabelsWithDefaultCases,
iconsAndLabels,
options,
selectedOption,
toggles,
videoBlurEnabled,
videoBlurToggleClick,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let iconsAndLabels: IconsAndLabels | undefined;
switch (iconsAndLabelsWithDefaultCases) {
const { t } = useTranslation();
let button;
let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) {
case "video":
iconsAndLabels = {
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
disabledLabel: t("stop_video_button_label"),
enabledLabel: t("start_video_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
button = (
<VideoButton
enabled={enabled ?? false}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
if (videoBlurToggleClick !== undefined) {
toggles = [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false,
id: BLUR_ID,
},
];
}
break;
case "audio":
iconsAndLabels = {
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
disabledLabel: t("mute_microphone_button_label"),
enabledLabel: t("unmute_microphone_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
default:
iconsAndLabels = iconsAndLabelsWithDefaultCases;
button = (
<MicButton
enabled={enabled ?? false}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={onMuteClick === undefined}
data-testid="incall_mute"
/>
);
break;
}
const {
IconEnabled,
IconDisabled,
IconOptions,
disabledLabel,
enabledLabel,
optionsButtonLabel,
} = iconsAndLabels ?? {
IconEnabled: undefined,
IconDisabled: undefined,
IconOptions: undefined,
disabledLabel: undefined,
enabledLabel: undefined,
optionsButtonLabel: undefined,
};
{
logger.info(
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
selectedOption,
" !==",
"option.id",
" && ",
plannedSelection,
" === ",
"option.id",
);
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
let optionsButtonLabel: string;
let numberedLabel: (number: number) => string;
switch (iconsAndLabels) {
case "video":
IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera");
numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n });
break;
case "audio":
IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone");
numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n });
break;
}
return (
<div
@@ -150,19 +136,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
})}
>
{/* The mute button lives inside */}
<Button
iconOnly
Icon={enabled ? IconEnabled : IconDisabled}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
kind={enabled ? "secondary" : "primary"}
size="lg"
className={styles.button}
aria-label={enabled ? disabledLabel : enabledLabel}
/>
{button}
<Menu
title={title}
showTitle={true}
@@ -183,44 +157,53 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{options?.map((option) => (
<MenuItem
hideChevron
label={option.label}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (option.id === selectedOption) return;
setPlannedSelection(option.id);
onSelect?.(option.id);
}}
key={option.id}
>
{selectedOption === option.id && (
<CheckIcon width={24} height={24} />
)}
{selectedOption !== option.id && plannedSelection === option.id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
))}
{options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
}
return (
<MenuItem
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
>
{selectedOption === id && <CheckIcon width={24} height={24} />}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
onSelect?.(toggle.id);
videoBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled}
checked={toggle.enabled ?? false}
key={toggle.id}
/>
))}

View File

@@ -6,21 +6,39 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
class="container"
>
<button
class="_button_1nw83_8 button _icon-only_1nw83_53"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_0_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
data-testid="incall_mute"
role="switch"
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="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
id="radix-_r_5_"
role="button"
tabindex="0"
type="button"

View File

@@ -25,7 +25,9 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import userEvent, {
PointerEventsCheckLevel,
} from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -395,7 +397,11 @@ test("user can reconnect after a membership manager error", async () => {
// async state update should be processed automatically by the waitFor call),
// and yet here we are.
await act(async () =>
user.click(screen.getByRole("button", { name: "Reconnect" })),
user
// With css vitest turned on this test thinks that the button has pointer_events: none;.
// TODO investigate if this is a test setup issue or an actual problem.
.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never })
.click(screen.getByRole("button", { name: "Reconnect" })),
);
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
beforeEach,
describe,
expect,
@@ -15,14 +16,14 @@ import {
vi,
} from "vitest";
import {
getByRole,
render,
screen,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { BrowserRouter } 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";
@@ -39,7 +40,10 @@ import {
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import {
type CallViewModel,
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -105,13 +109,12 @@ 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;
vm: CallViewModel;
} {
const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
const muteState = mockMuteStates();
@@ -123,7 +126,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, rtcSession } = getBasicCallViewModelEnvironment(
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
@@ -134,20 +137,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
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}
footerVm={footerVm}
matrixInfo={{
userId: "",
displayName: "",
@@ -168,7 +164,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render(
<Router>
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
@@ -179,11 +175,12 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</Router>,
</BrowserRouter>,
);
return {
...renderResult,
rtcSession,
vm,
};
}
@@ -196,9 +193,19 @@ describe("InCallView", () => {
});
describe("settings button with AppBar header", () => {
beforeEach(() => {
// getUrlParams() reads window.location directly rather than from the
// React Router context, so MemoryRouter alone is not enough to make
// it see "header=app_bar". Push the real URL so both paths agree.
window.history.pushState({}, "", "?header=app_bar");
});
afterEach(() => {
window.history.pushState({}, "", "/");
});
it("mobile portrait, is visible in the header", () => {
createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Narrow like a mobile phone in portrait orientation
@@ -206,12 +213,13 @@ describe("InCallView", () => {
},
});
getByRole(screen.getByRole("banner"), "button", { name: "Settings" });
getByRole(screen.getByRole("banner"), "button", {
name: "Settings",
});
});
it("mobile landscape, is not visible anywhere", () => {
const { queryByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Flat like a mobile phone in landscape orientation
@@ -219,7 +227,7 @@ describe("InCallView", () => {
},
});
expect(queryByRole("button", { name: "Settings" })).toBe(null);
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
});
});

View File

@@ -42,7 +42,6 @@ import { InviteButton } from "../button/InviteButton";
import {
type CallViewModel,
createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
@@ -64,11 +63,7 @@ import {
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
matrixRTCMode as matrixRTCModeSetting,
useSetting,
} from "../settings/settings";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
@@ -86,8 +81,10 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter } from "../components/CallFooter.tsx";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
declare module "react" {
interface CSSProperties {
@@ -100,7 +97,7 @@ const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState"
"vm" | "livekitRoom" | "connState" | "footerVm"
> {
e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
@@ -111,7 +108,9 @@ export interface ActiveCallProps extends Omit<
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const [vm, setVm] = useState<CallViewModel | null>(null);
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
@@ -121,6 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = createCallViewModel$(
scope,
props.rtcSession,
@@ -144,7 +144,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
return (): void => {
logger.info("END CALL VIEW SCOPE");
scope.end();
};
}, [
@@ -156,13 +155,44 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
]);
useEffect(() => {
if (vm === null) return;
const scope = new ObservableScope();
const footerVm = createCallFooterViewModel(
scope,
vm,
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
);
setFooterVm(footerVm);
return (): void => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
props.muteStates,
props.e2eeSystem,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
vm,
]);
if (vm === null) return null;
if (footerVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView {...props} vm={vm} />
<InCallView {...props} vm={vm} footerVm={footerVm} />
</ReactionsSenderProvider>
);
};
@@ -170,6 +200,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -180,14 +211,14 @@ export interface InCallViewProps {
export const InCallView: FC<InCallViewProps> = ({
client,
vm,
footerVm,
matrixInfo,
matrixRoom,
muteStates,
onShareClick,
}) => {
const { t } = useTranslation();
const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
useWakeLock();
// TODO-MULTI-SFU This is unused now??
@@ -223,9 +254,6 @@ export const InCallView: FC<InCallViewProps> = ({
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -244,15 +272,12 @@ export const InCallView: FC<InCallViewProps> = ({
const reconnecting = useBehavior(vm.reconnecting$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const showNameTags = useBehavior(vm.showNameTags$);
const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary
@@ -300,28 +325,18 @@ export const InCallView: FC<InCallViewProps> = ({
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useMemo(
() =>
// Profile settings are unavailable in widget mode
widget === null
? (): void => {
setSettingsTab("profile");
setSettingsModalOpen(true);
setSettingsOpen(true);
}
: null,
[setSettingsTab, setSettingsModalOpen],
[setSettingsTab, setSettingsOpen],
);
const [headerRef, headerBounds] = useMeasure();
@@ -347,11 +362,6 @@ export const InCallView: FC<InCallViewProps> = ({
[gridBounds],
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
@@ -492,6 +502,7 @@ export const InCallView: FC<InCallViewProps> = ({
};
}, [gridBoundsObservable$]);
const showFooter = useBehavior(footerVm.showFooter$);
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {
return (
@@ -580,42 +591,14 @@ export const InCallView: FC<InCallViewProps> = ({
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
onClick={openSettings}
onClick={() => setSettingsOpen(true)}
data-testid="settings-app-bar"
/>,
);
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<CallFooter
ref={footerRef}
hidden={!showFooter}
hideControls={!showControls}
asOverlay={edgeToEdge}
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={
headerStyle === HeaderStyle.AppBar ? undefined : openSettings
}
hangup={vm.hangup}
//Debug props
debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration}
/>
const footer = footerVm !== null && (
<CallFooter ref={footerRef} vm={footerVm} />
);
const allConnections = useBehavior(vm.allConnections$);
@@ -653,8 +636,8 @@ export const InCallView: FC<InCallViewProps> = ({
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
livekitRooms={allConnections

111
src/room/LobbyView.test.tsx Normal file
View File

@@ -0,0 +1,111 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
}));
vi.mock("../livekit/TrackProcessorContext", () => ({
useTrackProcessor: (): ProcessorState => ({
supported: false,
processor: undefined,
}),
useTrackProcessorSync: (): void => {},
}));
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
vi.mock("../settings/SettingsModal", () => ({
SettingsModal: (): null => null,
defaultSettingsTab: "general",
}));
const mockClient = {
getUserId: () => "@user:example.org",
getDeviceId: () => "DEVICE",
} as Partial<MatrixClient> as MatrixClient;
const matrixInfo = {
userId: "@user:example.org",
displayName: "Test User",
avatarUrl: "",
roomId: "!room:example.org",
roomName: "Test Room",
roomAlias: null,
roomAvatar: null,
e2eeSystem: { kind: E2eeType.NONE } satisfies EncryptionSystem,
};
function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
): ReturnType<typeof render> {
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
return render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<TooltipProvider>
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={false}
participantCount={3}
onShareClick={null}
{...props}
/>
</TooltipProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
}
describe("LobbyView", () => {
it("renders with header and participant count", async () => {
const { container } = renderLobbyView();
expect(container).toMatchSnapshot();
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeTruthy();
expect(await axe(container)).toHaveNoViolations();
});
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeFalsy();
});
it("renders with waiting for invite state", () => {
const { getByTestId } = renderLobbyView({
waitingForInvite: true,
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
});

View File

@@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useInitial } from "../useInitial";
import {
useTrackProcessor,
@@ -46,8 +47,10 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
interface Props {
client: MatrixClient;
@@ -112,6 +115,7 @@ export const LobbyView: FC<Props> = ({
logger.error("Failed to navigate to /", error);
});
}, [navigate]);
const hangup = confineToRoom ? undefined : onLeaveClick;
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
const recentsButton = !confineToRoom && (
@@ -184,6 +188,27 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
useEffect(() => {
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
!hideHeader,
),
);
return (): void => {
footerScope.end();
};
}, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -227,18 +252,11 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<CallFooter
audioEnabled={audioEnabled}
videoEnabled={videoEnabled}
toggleAudio={toggleAudio ?? undefined}
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
>
{recentsButtonInFooter && recentsButton}
</CallFooter>
{footerVm !== null && (
<CallFooter vm={footerVm}>
{recentsButtonInFooter && recentsButton}
</CallFooter>
)}
</div>
{client && (
<SettingsModal

View File

@@ -143,7 +143,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
Reconnect
</button>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -451,7 +451,7 @@ exports[`should render the error page with link back to home 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -605,7 +605,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -763,7 +763,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
Reconnect
</button>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -912,7 +912,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.
</p>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -1061,7 +1061,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.
</p>
<button
class="_button_1nw83_8 homeLink"
class="_button_1nw83_8"
data-kind="tertiary"
data-size="lg"
role="button"

View File

@@ -165,6 +165,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
@@ -375,7 +376,33 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_s_"
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
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
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
fill-rule="evenodd"
/>
</svg>
</button>
<button
aria-labelledby="_r_14_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -403,7 +430,7 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg"
>
<input
aria-labelledby="_r_11_"
aria-labelledby="_r_19_"
name="layoutMode"
type="radio"
value="spotlight"
@@ -421,7 +448,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_16_"
aria-labelledby="_r_1e_"
checked=""
name="layoutMode"
type="radio"

View File

@@ -0,0 +1,380 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LobbyView > renders with header and participant count 1`] = `
<div>
<div
class="inRoom"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<div
class="roomHeaderInfo"
data-size="lg"
>
<span
aria-label="!room:example.org"
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
>
T
</span>
<div
class="nameLine"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
data-testid="roomHeader_roomName"
>
Test Room
</h1>
<span
tabindex="0"
>
<svg
aria-labelledby="_r_0_"
class="lock"
data-encrypted="false"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
/>
</svg>
</span>
</div>
<div
class="participantsLine"
>
<svg
aria-label="Participants"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
<path
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
data-testid="roomHeader_participants_count"
>
3
</span>
</div>
</div>
</div>
<div
class="nav rightNav"
/>
</header>
<div
class="content"
>
<div
class="preview"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="avatarContainer"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="buttonBar"
>
<button
class="_button_1nw83_8 join"
data-kind="primary"
data-size="lg"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_k9ljz_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
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>
</div>
</button>
<div
class="logo"
>
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Mark"
>
<rect
fill="#0DBD8B"
height="48"
rx="23.93"
width="47.86"
/>
<g
id="Union"
>
<path
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
fill="white"
/>
<path
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
fill="white"
/>
<path
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
fill="white"
/>
<path
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
fill="white"
/>
</g>
</g>
</svg>
<svg
aria-label="Element Call"
fill="none"
height="11"
viewBox="0 0 160 22"
width="80"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Type"
>
<g
id="Vector"
>
<path
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
fill="currentColor"
/>
<path
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
fill="currentColor"
/>
<path
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
fill="currentColor"
/>
<path
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
fill="currentColor"
/>
<path
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
fill="currentColor"
/>
<path
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
fill="currentColor"
/>
<path
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
fill="currentColor"
/>
<path
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
fill="currentColor"
/>
<path
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
fill="currentColor"
/>
<path
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
fill="currentColor"
/>
<path
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
fill="currentColor"
/>
</g>
</g>
</svg>
</div>
</div>
<div
class="buttons"
>
<button
aria-labelledby="_r_b_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_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_g_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
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="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
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="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_q_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
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="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -372,9 +372,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
local
)
</p>
<pre
class="pre"
>
<pre>
{
"region": "local",
"version": "1.2.3"
@@ -384,9 +382,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<p>
Local Participant
</p>
<pre
class="pre"
>
<pre>
localParticipantIdentity
</pre>
<p>
@@ -413,9 +409,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
remote
)
</p>
<pre
class="pre"
>
<pre>
{
"region": "remote",
"version": "4.5.6"
@@ -425,9 +419,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<p>
Local Participant
</p>
<pre
class="pre"
>
<pre>
localParticipantIdentity
</pre>
<p>

View File

@@ -15,6 +15,7 @@ import {
} from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
@@ -38,7 +39,6 @@ import {
tap,
throttleTime,
timer,
BehaviorSubject,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
@@ -356,6 +356,9 @@ export interface CallViewModel {
*/
edgeToEdge$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
// audio routing
/**
* Whether audio is currently being output through the earpiece.
@@ -1397,6 +1400,10 @@ export function createCallViewModel$(
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
),
);
const settingsOpen$ = new BehaviorSubject(false);
const setSettingsOpen$ = constant((open: boolean) => {
settingsOpen$.next(open);
});
const showHeader$ = scope.behavior<boolean>(
windowMode$.pipe(
@@ -1751,6 +1758,8 @@ export function createCallViewModel$(
showNameTags$,
showHeader$: showHeader$,
showFooter$: showFooter$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,

49
src/state/ViewModel.ts Normal file
View File

@@ -0,0 +1,49 @@
/*
Copyright 2026 Element Software Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { useState, useEffect } from "react";
import { type Behavior } from "./Behavior";
export type ViewModel<Snapshot> = {
[K in keyof Snapshot as `${string & K}$`]: Behavior<Snapshot[K]>;
};
/**
* This allows to build a view model (or Partial view model)
* with BehaviorSubjects.
* It can be used in tests and for simplifying view model creation for non reactive snapshot parameters.
*
* @param snapshot The snapshot values this view model with start with. ({a: number, b: string})
* @returns A view model: ({a$: BehaviroSubject<number>, b$: BehaviroSubject<string>}) (note the automatic addition of $ at the end of the keys)
*/
export function createStaticViewModel<Snapshot>(
snapshot: Snapshot,
): ViewModel<Snapshot> {
const vm = {} as ViewModel<Snapshot>;
for (const key in snapshot) {
(vm as Record<string, Behavior<unknown>>)[`${key}$`] = new BehaviorSubject(
snapshot[key],
);
}
return vm;
}
export function useStaticViewModel<Snapshot>(
snapshot: Snapshot,
): ViewModel<Snapshot> {
const [vm] = useState(() => createStaticViewModel(snapshot));
useEffect(() => {
for (const key in snapshot) {
(vm as unknown as Record<string, BehaviorSubject<unknown>>)[
`${key}$`
].next(snapshot[key]);
}
}, [snapshot, vm]);
return vm;
}

View File

@@ -39,6 +39,9 @@ import { aliceRtcMember, localRtcMember } from "./test-fixtures";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior";
import { MatrixRTCMode } from "../settings/settings";
import { createCallFooterViewModel } from "../components/CallFooterViewModel";
import { type FooterSnapshot } from "../components/CallFooter";
import { type ViewModel } from "../state/ViewModel";
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
@@ -136,6 +139,7 @@ export function getBasicCallViewModelEnvironment(
callViewModelOptions: Partial<CallViewModelOptions> = {},
): {
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
@@ -148,12 +152,15 @@ export function getBasicCallViewModelEnvironment(
const handRaisedSubject$ = new BehaviorSubject({});
const reactionsSubject$ = new BehaviorSubject({});
const scope = testScope();
const muteStates = mockMuteStates();
const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({});
const vm = createCallViewModel$(
testScope(),
scope,
rtcSession.asMockedSession(),
matrixRoom,
mediaDevicesOverride ?? mockMediaDevices({}),
mockMuteStates(),
mediaDevices,
muteStates,
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
livekitRoomFactory: (): LivekitRoom =>
@@ -171,8 +178,16 @@ export function getBasicCallViewModelEnvironment(
reactionsSubject$,
constant({ processor: undefined, supported: false }),
);
const footerVm = createCallFooterViewModel(
testScope(),
vm,
muteStates,
mediaDevices,
"reactionId",
);
return {
vm,
footerVm,
rtcMemberships$,
rtcSession,
handRaisedSubject$: handRaisedSubject$,

View File

@@ -61,7 +61,7 @@ export interface WidgetHelpers {
* is initialized with `initializeWidget`. This should happen at the top level because the widget messaging
* needs to be set up ASAP on load to ensure it doesn't miss any requests.
*/
export let widget: WidgetHelpers | null;
export let widget: WidgetHelpers | null = null;
/**
* Should be called as soon as possible on app start. (In the initilizer before react)

View File

@@ -12,13 +12,14 @@ export default defineConfig((configEnv) =>
vitePluginsConfig(configEnv),
defineConfig({
test: {
fileParallelism: false,
fileParallelism: true,
projects: [
{
extends: true,
test: {
name: "unit",
css: {
include: /.+/,
modules: {
classNameStrategy: "non-scoped",
},