diff --git a/docs/controls.md b/docs/controls.md index 332e98f8..e5e0746d 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -27,5 +27,5 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting Callbacks for buttons in EC that are handled by the native application - `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu. - This button is only shown on iOS. (`userAgent.includes("iPhone")`) + This button is only shown on iOS. (`/iPad|iPhone|iPod|Mac/.test(navigator.userAgent)`) - `onBackButtonPressed: (() => void) | undefined`. Callback when the webview detects a tab on the header's back button. diff --git a/playwright/sfu-reconnect-bug.spec.ts b/playwright/sfu-reconnect-bug.spec.ts new file mode 100644 index 00000000..c756570a --- /dev/null +++ b/playwright/sfu-reconnect-bug.spec.ts @@ -0,0 +1,104 @@ +/* +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"; + +test("When creator left, avoid reconnect to the same SFU", async ({ + browser, +}) => { + // Use reduce motion to disable animations that are making the tests a bit flaky + const creatorContext = await browser.newContext({ reducedMotion: "reduce" }); + const creatorPage = await creatorContext.newPage(); + + await creatorPage.goto("/"); + + // ======== + // ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link + // ======== + await creatorPage.getByTestId("home_callName").click(); + await creatorPage.getByTestId("home_callName").fill("Welcome"); + await creatorPage.getByTestId("home_displayName").click(); + await creatorPage.getByTestId("home_displayName").fill("Inviter"); + await creatorPage.getByTestId("home_go").click(); + await expect(creatorPage.locator("video")).toBeVisible(); + + // join + await creatorPage.getByTestId("lobby_joinCall").click(); + // Spotlight mode to make checking the test visually clearer + await creatorPage.getByRole("radio", { name: "Spotlight" }).check(); + + // Get the invite link + await creatorPage.getByRole("button", { name: "Invite" }).click(); + await expect( + creatorPage.getByRole("heading", { name: "Invite to this call" }), + ).toBeVisible(); + await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await creatorPage.getByTestId("modal_inviteLink").click(); + + const inviteLink = (await creatorPage.evaluate( + "navigator.clipboard.readText()", + )) as string; + expect(inviteLink).toContain("room/#/"); + + // ======== + // ACT: The other user use the invite link to join the call as a guest + // ======== + const guestB = await browser.newContext({ + reducedMotion: "reduce", + }); + const guestBPage = await guestB.newPage(); + + await guestBPage.goto(inviteLink); + await guestBPage.getByTestId("joincall_displayName").fill("Invitee"); + await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible(); + await guestBPage.getByTestId("joincall_joincall").click(); + await guestBPage.getByTestId("lobby_joinCall").click(); + await guestBPage.getByRole("radio", { name: "Spotlight" }).check(); + + // ======== + // ACT: add a third user to the call to reproduce the bug + // ======== + const guestC = await browser.newContext({ + reducedMotion: "reduce", + }); + const guestCPage = await guestC.newPage(); + let sfuGetCallCount = 0; + await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => { + sfuGetCallCount++; + await route.continue(); + }); + // Track WebSocket connections + let wsConnectionCount = 0; + await guestCPage.routeWebSocket("**", (ws) => { + // For some reason the interception is not working with the ** + if (ws.url().includes("livekit/sfu/rtc")) { + wsConnectionCount++; + } + ws.connectToServer(); + }); + + await guestCPage.goto(inviteLink); + await guestCPage.getByTestId("joincall_displayName").fill("Invitee"); + await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible(); + await guestCPage.getByTestId("joincall_joincall").click(); + await guestCPage.getByTestId("lobby_joinCall").click(); + await guestCPage.getByRole("radio", { name: "Spotlight" }).check(); + + await guestCPage.waitForTimeout(1000); + + // ======== + // the creator leaves the call + await creatorPage.getByTestId("incall_leave").click(); + + await guestCPage.waitForTimeout(2000); + // https://github.com/element-hq/element-call/issues/3344 + // The app used to request a new jwt token then to reconnect to the SFU + expect(wsConnectionCount).toBe(1); + expect(sfuGetCallCount).toBe(1); +}); diff --git a/src/Header.tsx b/src/Header.tsx index 89455411..577410f8 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details. */ import classNames from "classnames"; -import { - type FC, - type HTMLAttributes, - type ReactNode, - forwardRef, -} from "react"; +import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Heading, Text } from "@vector-im/compound-web"; @@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock"; import { useMediaQuery } from "./useMediaQuery"; interface HeaderProps extends HTMLAttributes { + ref?: Ref; children: ReactNode; className?: string; } -export const Header = forwardRef( - ({ children, className, ...rest }, ref) => { - return ( -
- {children} -
- ); - }, -); +export const Header: FC = ({ + ref, + children, + className, + ...rest +}) => { + return ( +
+ {children} +
+ ); +}; Header.displayName = "Header"; diff --git a/src/button/Link.tsx b/src/button/Link.tsx index 987bcea3..17e62a69 100644 --- a/src/button/Link.tsx +++ b/src/button/Link.tsx @@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type ComponentPropsWithoutRef, - forwardRef, - type MouseEvent, -} from "react"; +import { type ComponentProps, type FC, type MouseEvent } from "react"; import { Link as CpdLink } from "@vector-im/compound-web"; import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom"; import classNames from "classnames"; @@ -26,31 +22,30 @@ export function useLink( return [href, onClick]; } -type Props = Omit< - ComponentPropsWithoutRef, - "href" | "onClick" -> & { to: LinkProps["to"]; state?: unknown }; +type Props = Omit, "href" | "onClick"> & { + to: LinkProps["to"]; + state?: unknown; +}; /** * A version of Compound's link component that integrates with our router setup. * This is only for app-internal links. */ -export const Link = forwardRef(function Link( - { to, state, ...props }, - ref, -) { +export const Link: FC = ({ ref, to, state, ...props }) => { const [path, onClick] = useLink(to, state); return ; -}); +}; /** * A link to an external web page, made to fit into blocks of text more subtly * than the normal Compound link component. */ -export const ExternalLink = forwardRef< - HTMLAnchorElement, - ComponentPropsWithoutRef<"a"> ->(function ExternalLink({ className, children, ...props }, ref) { +export const ExternalLink: FC> = ({ + ref, + className, + children, + ...props +}) => { return ( ); -}); +}; diff --git a/src/button/LinkButton.tsx b/src/button/LinkButton.tsx index a4fc3211..f904c9d0 100644 --- a/src/button/LinkButton.tsx +++ b/src/button/LinkButton.tsx @@ -5,24 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type ComponentPropsWithoutRef, forwardRef } from "react"; +import { type ComponentProps, type FC } from "react"; import { Button } from "@vector-im/compound-web"; import type { LinkProps } from "react-router-dom"; import { useLink } from "./Link"; -type Props = Omit< - ComponentPropsWithoutRef>, - "as" | "href" -> & { to: LinkProps["to"]; state?: unknown }; +type Props = Omit>, "as" | "href"> & { + to: LinkProps["to"]; + state?: unknown; +}; /** * A version of Compound's button component that acts as a link and integrates * with our router setup. */ -export const LinkButton = forwardRef( - function LinkButton({ to, state, ...props }, ref) { - const [path, onClick] = useLink(to, state); - return