diff --git a/backend/dev_nginx.conf b/backend/dev_nginx.conf index a29b06d7..aadb612c 100644 --- a/backend/dev_nginx.conf +++ b/backend/dev_nginx.conf @@ -59,6 +59,8 @@ server { ssl_certificate /root/ssl/cert.pem; ssl_certificate_key /root/ssl/key.pem; + http2 on; + location ^~ /livekit/jwt/ { diff --git a/backend/playwright_homeserver.yaml b/backend/playwright_homeserver.yaml index ca45cf3f..38350a3c 100644 --- a/backend/playwright_homeserver.yaml +++ b/backend/playwright_homeserver.yaml @@ -44,6 +44,10 @@ rc_message: per_second: 10000 burst_count: 10000 +rc_delayed_event_mgmt: + per_second: 10000 + burst_count: 10000 + rc_login: address: per_second: 10000 diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index 3ccb2ab2..8089c9de 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -159,8 +159,8 @@ export const widgetTest = test.extend({ } = await registerUser(browser, userB); // Invite the second user - await ewPage1.getByRole("button", { name: "Add room" }).click(); - await ewPage1.getByText("New room").click(); + await ewPage1.getByRole("button", { name: "Add", exact: true }).click(); + await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); await ewPage1.getByRole("button", { name: "Create room" }).click(); await expect(ewPage1.getByText("You created this room.")).toBeVisible(); @@ -184,9 +184,9 @@ export const widgetTest = test.extend({ // Accept the invite await expect( - ewPage2.getByRole("treeitem", { name: "Welcome Room" }), + ewPage2.getByRole("option", { name: "Welcome Room" }), ).toBeVisible(); - await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click(); + await ewPage2.getByRole("option", { name: "Welcome Room" }).click(); await ewPage2.getByRole("button", { name: "Accept" }).click(); await expect( ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts new file mode 100644 index 00000000..3b419af4 --- /dev/null +++ b/playwright/reconnect.spec.ts @@ -0,0 +1,60 @@ +/* +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 { expect, test } from "@playwright/test"; + +// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode +test.skip( + ({ browserName }) => browserName === "firefox", + 'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode', +); + +test("can only interact with header and footer while reconnecting", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("Test call"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("Test user"); + // If we do not call fastForward here, we end up with Date.now() returning an actual timestamp + // but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0 + // and we will never get into probablyDisconnected state? + await page.clock.fastForward(10); + await page.getByTestId("home_go").click(); + + await expect(page.locator("video")).toBeVisible(); + await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // The media tile for the local user should become visible + await new Promise((resolve) => setTimeout(resolve, 1500)); + await expect(page.getByTestId("name_tag")).toContainText("Test user"); + + // Now disconnect from the internet + await page.route("https://synapse.m.localhost/**/*", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 10000)); + await route.continue(); + }); + await page.clock.fastForward(20000); + + await expect( + page.getByRole("dialog", { name: "Reconnecting…" }), + ).toBeVisible(); + + // Tab order should jump directly from header to footer, skipping media tiles + await page.getByRole("button", { name: "Mute microphone" }).focus(); + await expect( + page.getByRole("button", { name: "Mute microphone" }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused(); + // Most critically, we should be able to press the hangup button + await page.getByRole("button", { name: "End call" }).click(); +}); diff --git a/playwright/widget/simple-create.spec.ts b/playwright/widget/simple-create.spec.ts index 00d5c658..8c889892 100644 --- a/playwright/widget/simple-create.spec.ts +++ b/playwright/widget/simple-create.spec.ts @@ -49,7 +49,10 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { // Check the join indicator on the room list await expect( - brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }), + brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByRole("button", { name: "End call" }), ).toBeVisible(); // Join from the other side @@ -59,26 +62,28 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { ).toBeVisible(); await whistler.page.getByRole("button", { name: "Join" }).click(); - await expect( - whistler.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall"), - ).toBeVisible(); + // Currently disabled due to recent Element Web is bypassing Lobby + // await expect( + // whistler.page + // .locator('iframe[title="Element Call"]') + // .contentFrame() + // .getByTestId("lobby_joinCall"), + // ).toBeVisible(); + // + // await whistler.page + // .locator('iframe[title="Element Call"]') + // .contentFrame() + // .getByTestId("lobby_joinCall") + // .click(); - await whistler.page - .locator('iframe[title="Element Call"]') - .contentFrame() - .getByTestId("lobby_joinCall") - .click(); + // Currrenty disabled due to recent Element Web not indicating the number of participants + // await expect( + // whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + // ).toBeVisible(); - await expect( - whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }), - ).toBeVisible(); - - await expect( - brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }), - ).toBeVisible(); + // await expect( + // brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }), + // ).toBeVisible(); // Whistler leaves await whistler.page.waitForTimeout(1000); diff --git a/src/Overlay.module.css b/src/Overlay.module.css index fa972e6f..d711d05b 100644 --- a/src/Overlay.module.css +++ b/src/Overlay.module.css @@ -35,6 +35,8 @@ Please see LICENSE in the repository root for full details. .bg.animate[data-state="closed"] { animation: fade-out 130ms; + opacity: 0; + pointer-events: none; } .overlay { diff --git a/src/Toast.tsx b/src/Toast.tsx index 105572c8..83e220bc 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -46,11 +46,11 @@ interface Props { */ Icon?: ComponentType>; /** - * Whether the toast should be portaled into the root of the document (rather - * than rendered in-place within the component tree). + * Whether the toast should be modal, making it fill the screen (by portalling + * it into the root of the document) and trap focus until dismissed. * @default true */ - portal?: boolean; + modal?: boolean; } /** @@ -62,7 +62,7 @@ export const Toast: FC = ({ autoDismiss, children, Icon, - portal = true, + modal = true, }) => { const onOpenChange = useCallback( (open: boolean) => { @@ -103,8 +103,8 @@ export const Toast: FC = ({ ); return ( - - {portal ? {content} : content} + + {modal ? {content} : content} ); }; diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index 12974849..67d9cf16 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -22,11 +22,13 @@ export function RaisedHandIndicator({ miniature, showTimer, onClick, + tabIndex, }: { raisedHandTime?: Date; miniature?: boolean; showTimer?: boolean; onClick?: () => void; + tabIndex?: number; }): ReactNode { const { t } = useTranslation(); const [raisedHandDuration, setRaisedHandDuration] = useState(""); @@ -94,6 +96,7 @@ export function RaisedHandIndicator({ background: "none", }} onClick={clickCallback} + tabIndex={tabIndex} > {content} diff --git a/src/room/EarpieceOverlay.module.css b/src/room/EarpieceOverlay.module.css index fd46d0e2..d0757cdb 100644 --- a/src/room/EarpieceOverlay.module.css +++ b/src/room/EarpieceOverlay.module.css @@ -34,7 +34,6 @@ .overlay[data-show="false"] { animation: fade-out 130ms forwards; - content-visibility: hidden; pointer-events: none; } diff --git a/src/room/EarpieceOverlay.tsx b/src/room/EarpieceOverlay.tsx index 14ce33cc..6835bdd7 100644 --- a/src/room/EarpieceOverlay.tsx +++ b/src/room/EarpieceOverlay.tsx @@ -20,7 +20,7 @@ interface Props { export const EarpieceOverlay: FC = ({ show, onBackToVideoPressed }) => { const { t } = useTranslation(); return ( -
+
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 350af973..348a2c44 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -54,7 +54,6 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; -import overlayStyles from "../Overlay.module.css"; import { GridTile } from "../tile/GridTile"; import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -119,6 +118,7 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { Toast } from "../Toast.tsx"; +import overlayStyles from "../Overlay.module.css"; import { Avatar, Size as AvatarSize } from "../Avatar"; import waitingStyles from "./WaitingForJoin.module.css"; import { prefetchSounds } from "../soundUtils"; @@ -641,6 +641,38 @@ export const InCallView: FC = ({ } } + // The reconnecting toast cannot be dismissed + const onDismissReconnectingToast = useCallback(() => {}, []); + // We need to use a non-modal toast to avoid trapping focus within the toast. + // However, a non-modal toast will not render any background overlay on its + // own, so we must render one manually. + const reconnectingToast = ( + <> +
+ + {t("common.reconnecting")} + + + ); + + const earpieceOverlay = ( + + ); + + // If the reconnecting toast or earpiece overlay obscures the media tiles, we + // need to remove them from the accessibility tree and block focus. + const contentObscured = reconnecting || earpieceMode; + const Tile = useMemo( () => function Tile({ @@ -670,6 +702,7 @@ export const InCallView: FC = ({ className={classNames(className, styles.tile)} style={style} showSpeakingIndicators={showSpeakingIndicatorsValue} + focusable={!contentObscured} /> ) : ( = ({ targetWidth={targetWidth} targetHeight={targetHeight} showIndicators={showSpotlightIndicatorsValue} + focusable={!contentObscured} className={classNames(className, styles.tile)} style={style} /> ); }, - [vm, openProfile], + [vm, openProfile, contentObscured], ); const layouts = useMemo(() => { @@ -714,6 +748,8 @@ export const InCallView: FC = ({ targetWidth={gridBounds.height} targetHeight={gridBounds.width} showIndicators={false} + focusable={!contentObscured} + aria-hidden={contentObscured} /> ); } @@ -731,6 +767,7 @@ export const InCallView: FC = ({ model={layout} Layout={layers.fixed} Tile={Tile} + aria-hidden={contentObscured} /> ); const scrollingGrid = ( @@ -740,6 +777,7 @@ export const InCallView: FC = ({ model={layout} Layout={layers.scrolling} Tile={Tile} + aria-hidden={contentObscured} /> ); // The grid tiles go *under* the spotlight in the portrait layout, but @@ -869,9 +907,6 @@ export const InCallView: FC = ({
); - // The reconnecting toast cannot be dismissed - const onDismissReconnectingToast = useCallback(() => {}, []); - return (
= ({ {renderContent()} - - {t("common.reconnecting")} - - + {reconnectingToast} + {earpieceOverlay} {waitingOverlay} {footer} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index bfbef499..8a2ef37e 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -111,6 +111,11 @@ exports[`InCallView > rendering > renders 1`] = `
+