mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-12 18:39:19 +00:00
Merge branch 'livekit' into toger5/view-model-call-footer-example
This commit is contained in:
@@ -15,7 +15,12 @@ import {
|
|||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { act, render, type RenderResult } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
type RenderResult,
|
||||||
|
getByRole,
|
||||||
|
screen,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { type LocalParticipant } from "livekit-client";
|
import { type LocalParticipant } from "livekit-client";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -186,6 +191,7 @@ describe("InCallView", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings button with AppBar header", () => {
|
describe("settings button with AppBar header", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// getUrlParams() reads window.location directly rather than from the
|
// getUrlParams() reads window.location directly rather than from the
|
||||||
@@ -198,67 +204,33 @@ describe("InCallView", () => {
|
|||||||
window.history.pushState({}, "", "/");
|
window.history.pushState({}, "", "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mobile landscape, is accessible when showHeader is false", () => {
|
it("mobile portrait, is visible in the header", () => {
|
||||||
// windowSize with height <= 600 results in "flat" windowMode,
|
createInCallView({
|
||||||
// which means showHeader$ emits false.
|
|
||||||
const { getAllByRole, getByRole, getByTestId, vm } = createInCallView({
|
|
||||||
withAppBar: true,
|
withAppBar: true,
|
||||||
callViewModelOptions: {
|
callViewModelOptions: {
|
||||||
// Set windowMode$ to "flat" (height <= 600)
|
// Narrow like a mobile phone in portrait orientation
|
||||||
windowSize$: constant({ width: 1000, height: 500 }),
|
windowSize$: constant({ width: 400, height: 700 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// In flat (landscape) mode the footer starts hidden until the user
|
getByRole(screen.getByRole("banner", { name: "" }), "button", {
|
||||||
// taps the screen, so no settings button should be accessible yet.
|
name: "Settings",
|
||||||
|
});
|
||||||
expect(getByTestId("footer-container")).not.toBeVisible();
|
|
||||||
const buttons = getAllByRole("button", { name: "Settings" });
|
|
||||||
for (const b of buttons) {
|
|
||||||
expect(b).not.toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = getByRole("button", { name: "Settings" });
|
|
||||||
// There are two buttons in the bottom bar. One for the
|
|
||||||
// the narrow layout and another one for the wide layout.
|
|
||||||
// Their visibility uses @media css queries, which we can test JSDOM (see `test.css.include` vitest config).
|
|
||||||
expect(settingsBtn).toHaveAttribute(
|
|
||||||
"data-testid",
|
|
||||||
"settings-bottom-left",
|
|
||||||
);
|
|
||||||
expect(settingsBtn).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mobile portrait, is accessible when showHeader is true", () => {
|
it("mobile landscape, is not visible anywhere", () => {
|
||||||
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
|
const { queryByRole } = createInCallView({
|
||||||
// which means showHeader$ emits true.
|
|
||||||
const { getAllByRole } = createInCallView({
|
|
||||||
withAppBar: true,
|
withAppBar: true,
|
||||||
callViewModelOptions: {
|
callViewModelOptions: {
|
||||||
// Set windowMode$ to "normal" (height >= 600)
|
// Flat like a mobile phone in landscape orientation
|
||||||
windowSize$: constant({ width: 1000, height: 800 }),
|
windowSize$: constant({ width: 700, height: 400 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// When showHeader is true and headerStyle is AppBar,
|
|
||||||
// showSettingsButton is false in the footer, but the settings
|
|
||||||
// button is rendered in the AppBar via useAppBarSecondaryButton.
|
|
||||||
const settingsBtns = getAllByRole("button", { name: "Settings" });
|
|
||||||
|
|
||||||
expect(settingsBtns.length).toBe(1);
|
expect(queryByRole("button", { name: "Settings" })).toBe(null);
|
||||||
expect(settingsBtns[0]).toHaveAttribute(
|
|
||||||
"data-testid",
|
|
||||||
"settings-app-bar",
|
|
||||||
);
|
|
||||||
expect(settingsBtns[0]).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("audioOutputSwitcher", () => {
|
describe("audioOutputSwitcher", () => {
|
||||||
it("is visible and can be clicked", async () => {
|
it("is visible and can be clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -533,12 +533,19 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// If edge-to-edge, compute new safe area insets that account for the
|
// If edge-to-edge, compute new safe area insets that account for the
|
||||||
// header and footer.
|
// header and footer.
|
||||||
"--call-view-safe-area-inset-top":
|
"--call-view-safe-area-inset-top":
|
||||||
edgeToEdge && header && showHeader
|
edgeToEdge && headerStyle !== HeaderStyle.None && showHeader
|
||||||
? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
? // Header has two relevant cases: if it's an app bar, it lives
|
||||||
|
// outside the InCallView and consumes the safe area insets
|
||||||
|
// itself. Otherwise account for the safe area and header size
|
||||||
|
// as part of the InCallView.
|
||||||
|
headerStyle === HeaderStyle.AppBar
|
||||||
|
? `${bounds.top}px`
|
||||||
|
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
||||||
: undefined,
|
: undefined,
|
||||||
"--call-view-safe-area-inset-bottom":
|
"--call-view-safe-area-inset-bottom":
|
||||||
edgeToEdge && showFooter
|
edgeToEdge && showFooter
|
||||||
? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
|
? // Footer always lives inside the InCallView.
|
||||||
|
`calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
model={layout}
|
model={layout}
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider");
|
|||||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||||
|
|
||||||
|
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
|
vi.mock("../../Platform", () => ({
|
||||||
|
get platform(): string {
|
||||||
|
return getPlatform();
|
||||||
|
},
|
||||||
|
isFirefox: (): boolean => false,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock(
|
vi.mock(
|
||||||
"../state/CallViewModel/localMember/localTransport",
|
"../state/CallViewModel/localMember/localTransport",
|
||||||
async (importOriginal) => ({
|
async (importOriginal) => ({
|
||||||
@@ -838,6 +846,59 @@ describe.each([
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test cases for footer visibility in PIP mode across different platforms
|
||||||
|
const PIP_FOOTER_VISIBILITY_TEST_CASES: Array<{
|
||||||
|
platform: "ios" | "android" | "desktop";
|
||||||
|
expectedMarbles: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
platform: "ios",
|
||||||
|
expectedMarbles: "tf",
|
||||||
|
description: "hidden on iOS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "android",
|
||||||
|
expectedMarbles: "tf",
|
||||||
|
description: "hidden on Android",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
platform: "desktop",
|
||||||
|
expectedMarbles: "t",
|
||||||
|
description: "visible on desktop",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(PIP_FOOTER_VISIBILITY_TEST_CASES)(
|
||||||
|
"footer is $description in PIP mode",
|
||||||
|
({ platform: testPlatform, expectedMarbles }) => {
|
||||||
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
|
// Set platform for this test case
|
||||||
|
getPlatform.mockReturnValue(testPlatform);
|
||||||
|
|
||||||
|
// Enable PIP mode after initial render
|
||||||
|
const pipControlInputMarbles = "-e";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
schedule(pipControlInputMarbles, {
|
||||||
|
e: () => window.controls.enablePip(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(vm.showFooter$).toBe(expectedMarbles, {
|
||||||
|
t: true,
|
||||||
|
f: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
|
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
// Switch to spotlight immediately
|
// Switch to spotlight immediately
|
||||||
|
|||||||
@@ -1344,6 +1344,10 @@ export function createCallViewModel$(
|
|||||||
// Layout is edge-to-edge; show/hide the footer in response to interactions
|
// Layout is edge-to-edge; show/hide the footer in response to interactions
|
||||||
return windowMode$.pipe(
|
return windowMode$.pipe(
|
||||||
switchMap((mode) => {
|
switchMap((mode) => {
|
||||||
|
if (mode === "pip" && platform !== "desktop") {
|
||||||
|
// No controls are shown in mobile pip as interactions are disabled
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
const showInitially = mode !== "flat";
|
const showInitially = mode !== "flat";
|
||||||
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
|
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineConfig((configEnv) =>
|
|||||||
vitePluginsConfig(configEnv),
|
vitePluginsConfig(configEnv),
|
||||||
defineConfig({
|
defineConfig({
|
||||||
test: {
|
test: {
|
||||||
fileParallelism: false,
|
fileParallelism: true,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
extends: true,
|
extends: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user