diff --git a/component/index.tsx b/component/index.tsx index 1d9969a2d..41c584830 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -44,7 +44,6 @@ import { } from "react"; import { type MatrixClient } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; -import { MemoryRouter } from "react-router-dom"; import { I18nextProvider } from "react-i18next"; import { TooltipProvider } from "@vector-im/compound-web"; import { ErrorBoundary } from "@sentry/react"; @@ -266,44 +265,40 @@ export const ElementCall: FC = ({ - {/* Element Call's own navigation stays in memory, so that the - component cannot disturb the host's URL. */} - -
- {container !== null && - rtcSession !== null && - mediaDevices !== null && ( - - {/* Whatever goes wrong in here is shown in here. Left to +
+ {container !== null && + rtcSession !== null && + mediaDevices !== null && ( + + {/* Whatever goes wrong in here is shown in here. Left to propagate, an error would unmount the host's own tree. */} - } - // A broken call should not hold the host on screen - onError={() => void hostBridge.setAlwaysOnScreen(false)} - > - - - - - - - - - - - - - - )} -
- + } + // A broken call should not hold the host on screen + onError={() => void hostBridge.setAlwaysOnScreen(false)} + > + + + + + + + + + + + + +
+ )} +
diff --git a/src/App.tsx b/src/App.tsx index 8d272989e..c07553afc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, Suspense, useEffect, useState } from "react"; -import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; +import { + type FC, + type JSX, + Suspense, + useCallback, + useEffect, + useState, +} from "react"; +import { + BrowserRouter, + Route, + useLocation, + useNavigate, + Routes, +} from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -40,6 +53,7 @@ import { nullHostBridge, } from "./HostBridge"; import { useInitial } from "./useInitial"; +import { LeaveToHomeProvider } from "./LeaveToHomeContext"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -57,6 +71,20 @@ const LocationUrlParamsProvider: FC = ({ children }) => { return {children}; }; +/** + * Supplies the way home. Only the app has one — its home page, with the list + * of recent calls — so this, too, lives in the app shell. + */ +const HomeProvider: FC = ({ children }) => { + const navigate = useNavigate(); + const leaveToHome = useCallback(() => { + navigate("/")?.catch((e) => logger.error("Failed to navigate home", e)); + }, [navigate]); + return ( + {children} + ); +}; + const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); @@ -143,15 +171,17 @@ export const App: FC = ({ vm, widget }) => { - - - - - {content} - - - - + + + + + + {content} + + + + + diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 193755665..526581996 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -16,13 +16,13 @@ import { useMemo, type JSX, } from "react"; -import { useNavigate } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; import { ErrorPage } from "./FullScreenView"; import { useHostBridge } from "./HostBridge"; +import { useLeaveToHome } from "./LeaveToHomeContext"; import { PosthogAnalytics, RegistrationType, @@ -146,7 +146,7 @@ interface Props { } export const ClientProvider: FC = ({ children, client }) => { - const navigate = useNavigate(); + const leaveToHome = useLeaveToHome(); const hostBridge = useHostBridge(); // null = signed out, undefined = loading @@ -249,10 +249,10 @@ export const ClientProvider: FC = ({ children, client }) => { await client.clearStores(); clearSession(); setInitClientState(null); - await navigate("/"); + leaveToHome?.(); PosthogAnalytics.instance.logout(); PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest); - }, [navigate, initClientState?.client]); + }, [leaveToHome, initClientState?.client]); // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index 519089831..00ffba372 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { RageshakeButton } from "./settings/RageshakeButton"; import styles from "./ErrorView.module.css"; import { useUrlParams } from "./UrlParams"; -import { LinkButton } from "./button"; +import { useLeaveToHome } from "./LeaveToHomeContext"; import { useHostBridge } from "./HostBridge.ts"; interface Props { @@ -50,6 +50,7 @@ export const ErrorView: FC = ({ const { t } = useTranslation(); const { confineToRoom } = useUrlParams(); const hostBridge = useHostBridge(); + const leaveToHome = useLeaveToHome(); const onReload = useCallback(() => { window.location.href = "/"; @@ -73,21 +74,19 @@ export const ErrorView: FC = ({ }; // Whether the error is considered fatal or pathname is `/` then reload the all app. - // If not then navigate to home page. - const ReturnToHomeButton = (): ReactElement => { - if (fatal || location.pathname === "/") { - return ( - - ); - } else { - return ( - - {t("return_home_button")} - - ); - } + // If not then navigate to home page. Neither applies when there is no home + // to go to. + const ReturnToHomeButton = (): ReactElement | null => { + if (leaveToHome === null) return null; + return ( + + ); }; return ( diff --git a/src/Header.module.css b/src/Header.module.css index 07a3e7242..58a710a79 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -30,6 +30,11 @@ Please see LICENSE in the repository root for full details. display: none; align-items: center; text-decoration: none; + /* A button when it leads home, so undo the browser's button styling */ + background: none; + border: none; + padding: 0; + cursor: pointer; } .leftNav.hideMobile { diff --git a/src/Header.tsx b/src/Header.tsx index a963f16bb..e3bda1659 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -6,8 +6,13 @@ Please see LICENSE in the repository root for full details. */ import classNames from "classnames"; -import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { + type Ref, + type FC, + type HTMLAttributes, + type ReactNode, + useCallback, +} from "react"; import { useTranslation } from "react-i18next"; import { Heading, Text } from "@vector-im/compound-web"; import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -17,6 +22,7 @@ import Logo from "./icons/Logo.svg?react"; import { Avatar, Size } from "./Avatar"; import { EncryptionLock } from "./room/EncryptionLock"; import { useRootSizeMatches } from "./useRootSize"; +import { useLeaveToHome } from "./LeaveToHomeContext"; import { DisconnectedBanner } from "./DisconnectedBanner"; interface HeaderProps extends HTMLAttributes { @@ -112,17 +118,30 @@ interface HeaderLogoProps { className?: string; } +/** + * The logo, which is also the way home — when there is a home to go to. As a + * component there is not, and it is just the logo. + */ export const HeaderLogo: FC = ({ className }) => { const { t } = useTranslation(); + const leaveToHome = useLeaveToHome(); + const onClick = useCallback(() => leaveToHome?.(), [leaveToHome]); + if (leaveToHome === null) + return ( +
+ +
+ ); return ( - - + ); }; diff --git a/src/LeaveToHomeContext.ts b/src/LeaveToHomeContext.ts new file mode 100644 index 000000000..4af56c216 --- /dev/null +++ b/src/LeaveToHomeContext.ts @@ -0,0 +1,29 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; + +/** + * How the user leaves the call for wherever they came from: the standalone + * app's home page, with its list of recent calls. + * + * The call itself has no idea where that is, or whether there is such a place + * at all. Standalone there is, and the shell navigates to it; as a component + * there is not — the host decides what happens after a call — so nothing is + * supplied, and the call offers no way out of its own. This is what lets the + * call be rendered without a router. + */ +const LeaveToHomeContext = createContext<(() => void) | null>(null); + +export const LeaveToHomeProvider = LeaveToHomeContext.Provider; + +/** + * The way out of the call, or null when there is nowhere to go and the call + * should not offer one. + */ +export const useLeaveToHome = (): (() => void) | null => + use(LeaveToHomeContext); diff --git a/src/button/LeaveToHomeLink.tsx b/src/button/LeaveToHomeLink.tsx new file mode 100644 index 000000000..50385a5d3 --- /dev/null +++ b/src/button/LeaveToHomeLink.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type FC, type MouseEvent, type ReactNode, useCallback } from "react"; +import { Link } from "@vector-im/compound-web"; + +import { useLeaveToHome } from "../LeaveToHomeContext"; + +interface Props { + className?: string; + children: ReactNode; +} + +/** + * A link out of the call, to wherever the user came from. Renders nothing when + * there is nowhere to go (see {@link useLeaveToHome}). + */ +export const LeaveToHomeLink: FC = ({ className, children }) => { + const leaveToHome = useLeaveToHome(); + const onClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + leaveToHome?.(); + }, + [leaveToHome], + ); + + if (leaveToHome === null) return null; + // Where this leads is the shell's business, so the link has no address of + // its own to offer + return ( + + {children} + + ); +}; diff --git a/src/room/CallEndedView.tsx b/src/room/CallEndedView.tsx index 4df3f297b..58417c38a 100644 --- a/src/room/CallEndedView.tsx +++ b/src/room/CallEndedView.tsx @@ -9,8 +9,6 @@ import { type FC, type FormEventHandler, useCallback, useState } from "react"; import { type MatrixClient } from "matrix-js-sdk"; import { Trans, useTranslation } from "react-i18next"; import { Button, Heading, Text } from "@vector-im/compound-web"; -import { useNavigate } from "react-router-dom"; -import { logger } from "matrix-js-sdk/lib/logger"; import styles from "./CallEndedView.module.css"; import feedbackStyle from "../input/FeedbackInput.module.css"; @@ -19,8 +17,9 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { FieldRow, InputField } from "../input/Input"; import { StarRatingInput } from "../input/StarRatingInput"; -import { Link } from "../button/Link"; import { LinkButton } from "../button"; +import { LeaveToHomeLink } from "../button/LeaveToHomeLink"; +import { useLeaveToHome } from "../LeaveToHomeContext"; interface Props { client: MatrixClient; @@ -38,7 +37,7 @@ export const CallEndedView: FC = ({ endedCallId, }) => { const { t } = useTranslation(); - const navigate = useNavigate(); + const leaveToHome = useLeaveToHome(); const { displayName } = useProfile(client); const [surveySubmitted, setSurveySubmitted] = useState(false); @@ -68,14 +67,12 @@ export const CallEndedView: FC = ({ setSurveySubmitted(true); } else if (!confineToRoom) { // if the user already has an account immediately go back to the home screen - navigate("/")?.catch((error) => { - logger.error("Failed to navigate to /", error); - }); + leaveToHome?.(); } }, 1000); }, 1000); }, - [endedCallId, navigate, isPasswordlessUser, confineToRoom, starRating], + [endedCallId, leaveToHome, isPasswordlessUser, confineToRoom, starRating], ); const createAccountDialog = isPasswordlessUser && ( @@ -87,6 +84,8 @@ export const CallEndedView: FC = ({ calls

+ {/* Only guests of the standalone app are ever passwordless, so this + route is always the standalone app's own. */} {t("call_ended_view.create_account_button")} @@ -157,7 +156,10 @@ export const CallEndedView: FC = ({ {!confineToRoom && ( - {t("call_ended_view.not_now_button")} + + {" "} + {t("call_ended_view.not_now_button")}{" "} + )} diff --git a/src/room/CallView.tsx b/src/room/CallView.tsx index a88ab7b2d..3c34dc845 100644 --- a/src/room/CallView.tsx +++ b/src/room/CallView.tsx @@ -28,7 +28,6 @@ import { MatrixRTCSessionEvent, type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; -import { useNavigate } from "react-router-dom"; import { type JoinCallData } from "../widget"; import { LobbyView } from "./LobbyView"; @@ -72,6 +71,7 @@ import { useBehavior } from "../useBehavior.ts"; import { useRootElement } from "../RootElementContext.ts"; import { useHostBridge } from "../HostBridge.ts"; import { useMuteStates } from "../state/useMuteStates.ts"; +import { useLeaveToHome } from "../LeaveToHomeContext.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -374,7 +374,7 @@ const LoadedCallView: FC = ({ // TODO refactor this + "joined" to just one callState const [left, setLeft] = useState(false); - const navigate = useNavigate(); + const leaveToHome = useLeaveToHome(); // TODO split this into leave and onDisconnect const onLeft = useCallback( @@ -433,7 +433,7 @@ const LoadedCallView: FC = ({ !confineToRoom && !PosthogAnalytics.instance.isEnabled() ) - void navigate("/"); + leaveToHome?.(); // After this point the host could dispose of us at any moment! try { @@ -462,7 +462,7 @@ const LoadedCallView: FC = ({ isPasswordlessUser, confineToRoom, returnToLobby, - navigate, + leaveToHome, ], ); diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index a70b31805..b6d903d8a 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, onTestFinished, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type FC, @@ -14,7 +14,7 @@ import { useCallback, useState, } from "react"; -import { BrowserRouter } from "react-router-dom"; +import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import userEvent from "@testing-library/user-event"; import { ConnectionError } from "livekit-client"; import { MatrixError } from "matrix-js-sdk"; @@ -42,6 +42,9 @@ import { nullHostBridge, } from "../HostBridge.ts"; +// Somewhere to go home to, so that the error pages offer the way +const leaveToHome = vi.fn(); + test.each([ { error: new MatrixRTCTransportMissingError("example.com"), @@ -79,14 +82,14 @@ test.each([ const onErrorMock = vi.fn(); const { asFragment } = render( - + - , + , ); await screen.findByText(expectedTitle); @@ -106,15 +109,19 @@ test("should render the error page with link back to home", async () => { }; const onErrorMock = vi.fn(); + // From the home page itself the home button reloads instead of navigating, + // so be somewhere else for this test + window.history.pushState({}, "", "/room/somewhere"); + onTestFinished(() => window.history.pushState({}, "", "/")); const { asFragment } = render( - + - , + , ); await screen.findByText("Call is not supported"); @@ -123,7 +130,11 @@ test("should render the error page with link back to home", async () => { screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i), ).toBeInTheDocument(); - await screen.findByRole("button", { name: "Return to home screen" }); + // The way home is whatever the shell supplied, not a route of the call's own + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Return to home screen" })); + expect(leaveToHome).toHaveBeenCalledOnce(); expect(onErrorMock).toHaveBeenCalledOnce(); expect(onErrorMock).toHaveBeenCalledWith(error); @@ -155,11 +166,11 @@ test("ConnectionLostError: Action handling should reset error state", async () = ); return ( - + - + ); }; @@ -194,14 +205,14 @@ describe("Rageshake button", () => { }; render( - + - , + , ); } @@ -234,7 +245,7 @@ test("should have a close button when the host can dismiss us", async () => { const user = userEvent.setup(); const onErrorMock = vi.fn(); const { asFragment } = render( - + { - , + , ); await screen.findByText("Call is not supported"); @@ -273,11 +284,11 @@ test("should show technical details when error has a matrixError cause", async ( }; render( - + - , + , ); await screen.findByText("Something went wrong"); @@ -302,11 +313,11 @@ test("should not show technical details when error has no matrix error cause", a }; render( - + - , + , ); await screen.findByText("Connection lost"); @@ -356,14 +367,14 @@ describe("LiveKit ConnectionError variants", () => { }; const { asFragment } = render( - + - , + , ); // Check title @@ -385,14 +396,14 @@ describe("LiveKit ConnectionError variants", () => { }; const { asFragment } = render( - + - , + , ); await screen.findByText("Connection timeout"); diff --git a/src/room/LobbyView.test.tsx b/src/room/LobbyView.test.tsx index 8cbe6be14..7f03f2d29 100644 --- a/src/room/LobbyView.test.tsx +++ b/src/room/LobbyView.test.tsx @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it, vi } from "vitest"; import { render } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; +import { LeaveToHomeProvider } from "../LeaveToHomeContext"; import { TooltipProvider } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; import { axe } from "vitest-axe"; @@ -26,6 +26,9 @@ import lobbyStyles from "./LobbyView.module.css"; import headerStyles from "../Header.module.css"; import { AppBar } from "../AppBar"; +// Somewhere to go home to, so that the lobby offers the way back +const leaveToHome = vi.fn(); + vi.mock("@livekit/components-react", () => ({ usePreviewTracks: (): unknown[] => [], })); @@ -93,14 +96,14 @@ function renderLobbyView( /> ); return render( - + {withAppBar && {lobbyView}} {!withAppBar && lobbyView} - , + , ); } diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 89018691b..9e6e0ed99 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -25,7 +25,6 @@ import { Track, } from "livekit-client"; import { useObservableEagerState } from "observable-hooks"; -import { useNavigate } from "react-router-dom"; import inCallStyles from "./InCallView.module.css"; import styles from "./LobbyView.module.css"; @@ -36,7 +35,8 @@ import { InviteButton } from "../button/InviteButton"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; import { useRootSizeMatches } from "../useRootSize"; import { E2eeType } from "../e2ee/e2eeType"; -import { Link } from "../button/Link"; +import { LeaveToHomeLink } from "../button/LeaveToHomeLink"; +import { useLeaveToHome } from "../LeaveToHomeContext"; import { useMediaDevices } from "../MediaDevicesContext"; import { ObservableScope } from "../state/ObservableScope"; import { useInitial } from "../useInitial"; @@ -109,21 +109,19 @@ export const LobbyView: FC = ({ [setSettingsModalOpen], ); - const navigate = useNavigate(); - const onLeaveClick = useCallback(() => { - navigate("/")?.catch((error) => { - logger.error("Failed to navigate to /", error); - }); - }, [navigate]); - const hangup = confineToRoom ? undefined : onLeaveClick; + // Leaving the lobby means going back to wherever the user came from, if + // there is such a place + const leaveToHome = useLeaveToHome(); + const hangup = + confineToRoom || leaveToHome === null ? undefined : leaveToHome; const recentsButtonInFooter = useRootSizeMatches( ({ height }) => height <= 500, ); const recentsButton = !confineToRoom && ( - + {t("lobby.leave_button")} - + ); const devices = useMediaDevices(); @@ -209,7 +207,7 @@ export const LobbyView: FC = ({ return (): void => { footerScope.end(); }; - }, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]); + }, [devices, hangup, hideHeader, muteStates, openSettings]); // TODO: Unify this component with InCallView, so we can get slick joining // animations and don't have to feel bad about reusing its CSS diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 0194dfc4d..7a1fc0457 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -11,11 +11,10 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
- - +
should display LiveKit 'internal' er
should display LiveKit 'notAllowed'
should display LiveKit 'serverUnreac
should display LiveKit 'serviceNotFo
should display LiveKit 'timeout' err
should link to troubleshoot guide wh
- - +
- - +
- - +
- - +
- - +
- - +
- - +
renders with AppBar android 1`] = ` class="_link_13esb_8" data-kind="primary" data-size="md" - href="/" + href="#" rel="noreferrer noopener" > Back to recents @@ -320,7 +320,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = ` class="_link_13esb_8" data-kind="primary" data-size="md" - href="/" + href="#" rel="noreferrer noopener" > Back to recents @@ -598,7 +598,7 @@ exports[`LobbyView > renders with header and participant count 1`] = ` class="_link_13esb_8" data-kind="primary" data-size="md" - href="/" + href="#" rel="noreferrer noopener" > Back to recents