From 7b32135328061c53363c700fe03fd50429898ebd Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 20 May 2026 11:55:27 +0200 Subject: [PATCH 1/6] Ensure that foreground elements of media tile do not overlap app bar Because the type of header that we use in Element X (an 'app bar') lives in a different place in the document than the other headers, it needs a special branch to propagate the right insets. --- src/room/InCallView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 49e7abfc..e20cf508 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -522,12 +522,19 @@ export const InCallView: FC = ({ // If edge-to-edge, compute new safe area insets that account for the // header and footer. "--call-view-safe-area-inset-top": - edgeToEdge && header && showHeader - ? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)` + edgeToEdge && headerStyle !== HeaderStyle.None && showHeader + ? // 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, "--call-view-safe-area-inset-bottom": 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, }} model={layout} From 43f1b89535b184ad4cbc8677bf0b795c75b1fab3 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 20 May 2026 12:38:18 +0200 Subject: [PATCH 2/6] Stop the settings button from appearing while footer is fading out The designs actually never want us to show the settings button in the footer if an app bar is in use (as in Element X mobile apps), so just avoid showing it at all in that case. --- src/room/InCallView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 49e7abfc..87db59bf 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -570,8 +570,6 @@ export const InCallView: FC = ({ matrixRoom.roomId, ); - const settingsButtonInAppBar = - headerStyle === HeaderStyle.AppBar && showHeader; useAppBarSecondaryButton( = ({ audioOutputSwitcher={audioOutputSwitcher ?? undefined} // Only pass the openSettings function if the settings button is not in the app bar. // If there is no fn the button will be hidden in the footer. - openSettings={settingsButtonInAppBar ? undefined : openSettings} + openSettings={ + headerStyle === HeaderStyle.AppBar ? undefined : openSettings + } hangup={vm.hangup} //Debug props debugTileLayout={debugTileLayout} From 18651104922168bf381502f7e6228a00c1c6222a Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 20 May 2026 13:08:11 +0200 Subject: [PATCH 3/6] reproduce bug internal #578 Regression: Controls are shown in Mobile PIP --- src/state/CallViewModel/CallViewModel.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 02a0a351..fc25df48 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -85,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider"); const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); + vi.mock( "../state/CallViewModel/localMember/localTransport", 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", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Switch to spotlight immediately From 265781ea5e25190479484eff252748af91f44bb6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 20 May 2026 13:08:51 +0200 Subject: [PATCH 4/6] fix(regression): control buttons should be hidden on mobile PIP --- src/state/CallViewModel/CallViewModel.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 504875d2..63214a4a 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1341,6 +1341,10 @@ export function createCallViewModel$( // Layout is edge-to-edge; show/hide the footer in response to interactions return windowMode$.pipe( 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 timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER; From 4b175b814ee0239c71be3aa65a848ed100664dc0 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 20 May 2026 13:43:33 +0200 Subject: [PATCH 5/6] Update and simplify tests --- src/room/InCallView.test.tsx | 55 +++++++++++++----------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index c23a9dcb..23642711 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -14,7 +14,12 @@ import { type MockedFunction, vi, } from "vitest"; -import { render, type RenderResult } from "@testing-library/react"; +import { + getByRole, + render, + screen, + type RenderResult, +} from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; import { BrowserRouter, MemoryRouter } from "react-router-dom"; @@ -189,57 +194,35 @@ describe("InCallView", () => { expect(container).toMatchSnapshot(); }); }); + describe("settings button with AppBar header", () => { - it("mobile landscape, is accessible when showHeader is false", () => { - // windowSize with height <= 600 results in "flat" windowMode, - // which means showHeader$ emits false. - const { getAllByRole } = createInCallView({ + it("mobile portrait, is visible in the header", () => { + createInCallView({ initialRoute: "/?header=app_bar", withAppBar: true, callViewModelOptions: { - // Set windowMode$ to "flat" (height <= 600) - windowSize$: constant({ width: 1000, height: 500 }), + // Narrow like a mobile phone in portrait orientation + windowSize$: constant({ width: 400, height: 700 }), }, }); - // When showHeader is false, hideSettingsButton is false, - // so the settings button is visible in the footer. - const settingsBtn = getAllByRole("button", { name: "Settings" }); - // here we check for two settings buttons because there are two buttons in the bottom bar. One for the - // the narrow layout and another one for the wide layout. - // Their visibility uses @media css queries, which cannot be tested in JSDOM, - // but we can at least check that both buttons are rendered and have the correct classes. - expect(settingsBtn.length).toBe(2); - expect(settingsBtn[0]).toHaveAttribute( - "data-testid", - "settings-bottom-left", - ); - expect(settingsBtn[0]).toBeVisible(); + + getByRole(screen.getByRole("banner"), "button", { name: "Settings" }); }); - it("mobile portrait, is accessible when showHeader is true", () => { - // windowSize with height > 600 and width > 600 results in "normal" windowMode, - // which means showHeader$ emits true. - const { getAllByRole } = createInCallView({ + it("mobile landscape, is not visible anywhere", () => { + const { queryByRole } = createInCallView({ initialRoute: "/?header=app_bar", withAppBar: true, callViewModelOptions: { - // Set windowMode$ to "normal" (height >= 600) - windowSize$: constant({ width: 1000, height: 800 }), + // Flat like a mobile phone in landscape orientation + windowSize$: constant({ width: 700, height: 400 }), }, }); - // When showHeader is true and headerStyle is AppBar, - // hideSettingsButton is true in the footer, but the settings - // button is rendered in the AppBar via useAppBarSecondaryButton. - const settingsBtns = getAllByRole("button", { name: "Settings" }); - expect(settingsBtns.length).toBe(1); - expect(settingsBtns[0]).toHaveAttribute( - "data-testid", - "settings-app-bar", - ); - expect(settingsBtns[0]).toBeVisible(); + expect(queryByRole("button", { name: "Settings" })).toBe(null); }); }); + describe("audioOutputSwitcher", () => { it("is visible and can be clicked", async () => { const user = userEvent.setup(); From 2fb5de3ba28d0936f25ea99feb16afe66aa53597 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Wed, 20 May 2026 14:50:56 +0200 Subject: [PATCH 6/6] review: eqeqe Co-authored-by: Robin --- src/state/CallViewModel/CallViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 63214a4a..3a3e57d7 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1341,7 +1341,7 @@ export function createCallViewModel$( // Layout is edge-to-edge; show/hide the footer in response to interactions return windowMode$.pipe( switchMap((mode) => { - if (mode == "pip" && platform != "desktop") { + if (mode === "pip" && platform !== "desktop") { // No controls are shown in mobile pip as interactions are disabled return of(false); }