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`] = `