diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx
index 28b8be9e..04533891 100644
--- a/src/components/CallFooter.stories.tsx
+++ b/src/components/CallFooter.stories.tsx
@@ -13,7 +13,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
-import { createMockedViewModel } from "../state/ViewModel";
+import { createStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
@@ -38,7 +38,7 @@ function CallFooterStoryWrapper({
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
- const vm = createMockedViewModel(vmSnapshot);
+ const vm = createStaticViewModel(vmSnapshot);
return (
= ({ ref, children, vm }) => {
videoToggles,
buttonSize,
showSettingsButton,
- showLogoDebugContainer,
showLogo,
} = useViewModel(vm);
@@ -285,10 +283,10 @@ export const CallFooter: FC = ({ ref, children, vm }) => {
/>
)}
{children}
- {showLogoDebugContainer && logoDebugContainer}
+ {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls && {buttons}
}
- {setLayoutMode && layoutMode && (
+ {!hideControls && setLayoutMode && layoutMode && (
name="layoutMode"
aria-label={t("layout_switch_label")}
diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts
index efb3d4e4..c6ce6594 100644
--- a/src/components/CallFooterViewModel.test.ts
+++ b/src/components/CallFooterViewModel.test.ts
@@ -104,30 +104,30 @@ describe("createCallFooterViewModel", () => {
}
it("are empty when both the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
- it("are empty when both the layout is pip", () => {
- checkEmptyFor("desktop", pipLayout);
- });
+ });
+ it("are empty when both the layout is pip", () => {
+ checkEmptyFor("desktop", pipLayout);
+ });
- it("are populated when the platform is desktop and the layout is not PiP", () => {
- platformMock.mockReturnValue("desktop");
+ 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,
- /* openSettings */ undefined,
- /* reactionIdentifier */ undefined,
- );
+ const vm = createCallFooterViewModel(
+ testScope(),
+ buildMinimalCallViewModel(gridLayout),
+ mockMuteStates(),
+ twoMicsAndOneCamMediaDevices,
+ /* openSettings */ undefined,
+ /* reactionIdentifier */ undefined,
+ );
- expect(vm.audioOptions$?.value).toEqual([
- { id: "mic1", label: "Microphone 1" },
- { id: "mic2", label: "Microphone 2" },
- ]);
- expect(vm.videoOptions$?.value).toEqual([
- { id: "cam1", label: "Camera 1" },
- ]);
- });
+ expect(vm.audioOptions$?.value).toEqual([
+ { id: "mic1", label: "Microphone 1" },
+ { id: "mic2", label: "Microphone 2" },
+ ]);
+ expect(vm.videoOptions$?.value).toEqual([
+ { id: "cam1", label: "Camera 1" },
+ ]);
});
});
});
diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx
index c1ca11fa..a8c5b83e 100644
--- a/src/components/CallFooterViewModel.tsx
+++ b/src/components/CallFooterViewModel.tsx
@@ -22,7 +22,7 @@ import {
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
-import { type ViewModel } from "../state/ViewModel";
+import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
@@ -173,8 +173,8 @@ export function createCallFooterViewModel(
reactionIdentifier: string | undefined,
): ViewModel {
const { showControls, header: headerStyle } = getUrlParams();
+ const showLogo = headerStyle === HeaderStyle.Standard;
- const hideLogo = headerStyle !== HeaderStyle.Standard;
const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")),
);
@@ -206,12 +206,7 @@ export function createCallFooterViewModel(
showLayoutSwitcher$: scope.behavior(
isPip$.pipe(map((l) => !isPip$ && showControls)),
),
- showLogoDebugContainer$: scope.behavior(
- combineLatest([isPip$, debugTileLayoutSetting.value$]).pipe(
- map(([isPip, debugTile]) => !isPip || (!hideLogo && !debugTile)),
- ),
- ),
- showLogo$: scope.behavior(isPip$.pipe(map((l) => !hideLogo && !isPip$))),
+ showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
layoutMode$: callModel.gridMode$,
setLayoutMode$: constant(callModel.setGridMode),
@@ -272,31 +267,22 @@ export function createLobbyFooterViewModel(
showLogo: boolean,
): ViewModel {
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,
+ showSettingsButton: openSettings !== undefined,
+ }),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
- hideControls$: constant(false),
- asOverlay$: constant(false),
- buttonSize$: constant("lg"),
- showSettingsButton$: constant(openSettings !== undefined),
- showLayoutSwitcher$: constant(false),
- showLogoDebugContainer$: constant(showLogo),
- showLogo$: constant(showLogo),
-
- layoutMode$: constant(undefined),
- setLayoutMode$: constant(undefined),
-
- sharingScreen$: constant(undefined),
- toggleScreenSharing$: constant(undefined),
-
- audioOutputSwitcher$: constant(undefined),
-
- openSettings$: constant(openSettings),
- hangup$: constant(hangup),
-
- reactionIdentifier$: constant(undefined),
- reactionData$: constant(undefined),
-
- debugTileLayout$: constant(false),
- tileStoreGeneration$: constant(0),
};
}
diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx
index c23a9dcb..cf4c2d92 100644
--- a/src/room/InCallView.test.tsx
+++ b/src/room/InCallView.test.tsx
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
+ afterEach,
beforeEach,
describe,
expect,
@@ -14,10 +15,10 @@ import {
type MockedFunction,
vi,
} from "vitest";
-import { render, type RenderResult } from "@testing-library/react";
+import { act, render, type RenderResult } 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";
@@ -34,7 +35,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";
@@ -100,13 +104,12 @@ beforeEach(() => {
interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices;
callViewModelOptions?: Partial;
- /** 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();
@@ -129,14 +132,6 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const room = rtcSession.room;
const client = room.client;
- const Router = args.initialRoute
- ? ({ children }: { children: React.ReactNode }): React.ReactNode => (
-
- {children}
-
- )
- : BrowserRouter;
-
const inCallView = (
{inCallView} : inCallView;
const renderResult = render(
-
+
- ,
+ ,
);
return {
...renderResult,
rtcSession,
+ vm,
};
}
@@ -190,17 +186,37 @@ 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 landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false.
- const { getAllByRole } = createInCallView({
- initialRoute: "/?header=app_bar",
+ const { getAllByRole, queryAllByRole, vm } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600)
windowSize$: constant({ width: 1000, height: 500 }),
},
});
+
+ // In flat (landscape) mode the footer starts hidden until the user
+ // taps the screen, so no settings button should be accessible yet.
+ expect(queryAllByRole("button", { name: "Settings" })).toHaveLength(0);
+
+ // Simulate a touch tap on the call view to reveal the footer.
+ // (PointerEvent is not available in JSDOM, so we call tapScreen() directly,
+ // which is exactly what the onClick handler does for touch events.)
+ act(() => vm.tapScreen());
+
// When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" });
@@ -220,7 +236,6 @@ describe("InCallView", () => {
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
// which means showHeader$ emits true.
const { getAllByRole } = createInCallView({
- initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600)
@@ -228,7 +243,7 @@ describe("InCallView", () => {
},
});
// When showHeader is true and headerStyle is AppBar,
- // hideSettingsButton is true in the footer, but the settings
+ // showSettingsButton is false in the footer, but the settings
// button is rendered in the AppBar via useAppBarSecondaryButton.
const settingsBtns = getAllByRole("button", { name: "Settings" });
diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap
index d9f768e7..831bf307 100644
--- a/src/room/__snapshots__/InCallView.test.tsx.snap
+++ b/src/room/__snapshots__/InCallView.test.tsx.snap
@@ -169,7 +169,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="settingsLogoContainer"
>