mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ca4d7e0f | ||
|
|
a2e3a2c13f | ||
|
|
8d5d482771 | ||
|
|
87f2726dd8 | ||
|
|
5fb9d6a81e | ||
|
|
ba6bcdc4eb | ||
|
|
56d73d1dfc | ||
|
|
89ad1ec936 | ||
|
|
b9a3cc6678 |
+13
-7
@@ -73,6 +73,7 @@ import {
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { hostClientSession } from "../src/state/ClientSession";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
@@ -189,20 +190,25 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
setVm(
|
||||
new AppViewModel(
|
||||
scope,
|
||||
{ controlledAudioDevices, callIntent },
|
||||
// The host owns the client. We create a clientSession wrapping the host client.
|
||||
hostClientSession(client),
|
||||
// The host owns the session too, so there is nothing to manage here
|
||||
null,
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
setVm(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
}, [controlledAudioDevices, callIntent, client]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
@@ -250,7 +256,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
Generated
+10
-10
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -1195,8 +1195,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2':
|
||||
resolution: {integrity: sha512-GHeuSeb5Fj6Wg2+NE/WoLJJs9U3nOzttO+2sj7lOaS5gNBpGIksc48N/Q/kK/Z+Lxh7YMhopB7M8b6HyASXnGQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -4734,9 +4734,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
|
||||
resolution: {gitHosted: true, integrity: sha512-7cbPN96wGt93B73jZieW2HBtTnasgKJ7wzTgiV0SjwkNyb50EK3vDa17LdSJpyOwEjFP5edyPRprk865HQJqww==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9}
|
||||
version: 45.1.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
|
||||
version: 45.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.20.0:
|
||||
@@ -7178,7 +7178,7 @@ snapshots:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.23.0(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.3.1)':
|
||||
dependencies:
|
||||
@@ -10413,10 +10413,10 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.2
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
content-type: 3.1.1
|
||||
|
||||
@@ -140,7 +140,7 @@ function setup(
|
||||
getDeviceId: () => deviceId,
|
||||
getDomain: () => "example.org",
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: publisherJWT }),
|
||||
} as unknown as MatrixClient;
|
||||
const members$ = new BehaviorSubject<RTCMember[]>([]);
|
||||
|
||||
@@ -31,7 +31,7 @@ function client(
|
||||
): Parameters<typeof discoverLocalTransport>[0] {
|
||||
return {
|
||||
getDomain: () => "example.org",
|
||||
getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("discoverLocalTransport", () => {
|
||||
logger,
|
||||
),
|
||||
).resolves.toBe(hostTransport);
|
||||
expect(c.getRTCTransports).not.toHaveBeenCalled();
|
||||
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the homeserver's transport over the host's fallback", async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
* @throws {MatrixRTCTransportMissingError} where none of these exists.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "getRTCTransports">,
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
transportTypes: readonly string[],
|
||||
{
|
||||
transport: override,
|
||||
|
||||
@@ -36,7 +36,11 @@ const configTransport: Transport = {
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
return {
|
||||
getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"prefers backend transport other app config $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -67,7 +71,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -77,7 +81,10 @@ describe("TransportAutoDiscovery", () => {
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
const client = makeClient();
|
||||
client.getRTCTransports.mockResolvedValue([backendTransport, mLivekit]);
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
backendTransport,
|
||||
mLivekit,
|
||||
]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -93,7 +100,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client.getRTCTransports
|
||||
client._unstable_getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
new MatrixError(
|
||||
{
|
||||
@@ -117,7 +124,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
backendTransport,
|
||||
);
|
||||
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
@@ -128,7 +135,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -145,7 +152,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("returns null when backend and config are all unavailable", async () => {
|
||||
const client = makeClient();
|
||||
client.getRTCTransports.mockResolvedValue([]);
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
|
||||
@@ -10,7 +10,10 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||
|
||||
type TransportDiscoveryClient = Pick<MatrixClient, "getRTCTransports">;
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
@@ -71,7 +74,7 @@ export class TransportAutoDiscovery {
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
try {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client.getRTCTransports(),
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
for (const type of this.transportTypes) {
|
||||
const first = transportList.find(
|
||||
|
||||
+9
-32
@@ -23,14 +23,13 @@ import {
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { type ClientLoader, ClientProvider } from "./ClientContext";
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -57,16 +56,6 @@ import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
// Element Call's own session, for when no host supplies a client.
|
||||
// - Passed in here, not imported by ClientProvider: this module is only in the
|
||||
// app builds, so the client setup and the store worker behind it stay out of
|
||||
// the component package (see the `loadClient` prop).
|
||||
// - Imported on demand, as it was before, so that a widget, which gets its
|
||||
// client from the host, never downloads it.
|
||||
// - Module-level, so that the provider's effect sees one stable loader.
|
||||
const loadStandaloneClient: ClientLoader = async (...args) =>
|
||||
(await import("./utils/spa")).initSPA(...args);
|
||||
|
||||
interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
@@ -141,22 +130,9 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
|
||||
const content = loaded ? (
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientManagerProvider value={vm.clientManager}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
@@ -171,10 +147,11 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
</Sentry.ErrorBoundary>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
|
||||
+2
-16
@@ -12,6 +12,7 @@ import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import {
|
||||
type HostBridge,
|
||||
@@ -26,22 +27,7 @@ const TestComponent: FC<
|
||||
}>
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
|
||||
+6
-10
@@ -15,7 +15,7 @@ import {
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { useClient } from "./ClientContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const { client } = useClient();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
@@ -87,7 +87,7 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// In theory, a change in `client` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
@@ -99,12 +99,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else if (client && sizePx) {
|
||||
blob = getAvatarFromServer(client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
@@ -133,7 +129,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
}, [client, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
|
||||
+86
-72
@@ -6,16 +6,25 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type ClientLoader,
|
||||
ClientManagerProvider,
|
||||
ClientProvider,
|
||||
useClientState,
|
||||
useClientManager,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -28,67 +37,47 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
const state = useClientSessionState();
|
||||
switch (state.state) {
|
||||
case "signedIn":
|
||||
return <span>{state.client.getUserId()}</span>;
|
||||
case "signedOut":
|
||||
return <span>unauthenticated</span>;
|
||||
default:
|
||||
return <span>{state.state}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
const renderProvider = (
|
||||
session: ClientSession,
|
||||
children = <ShowClientState />,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
<ClientProvider session={session}>{children}</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const { container } = renderProvider(hostClientSession(mockClient()));
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
const { container, rerender } = renderProvider(
|
||||
hostClientSession(mockClient()),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={second}>
|
||||
<ClientProvider
|
||||
session={hostClientSession(mockClient("@bob:example.org"))}
|
||||
>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -97,38 +86,63 @@ test("follows the client when the host swaps it", () => {
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
const loadClient = vi
|
||||
.fn<ClientLoader>()
|
||||
.mockResolvedValue({ client, passwordlessUser: true });
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider loadClient={loadClient}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
test("follows a session of its own as it is restored", () => {
|
||||
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
|
||||
const { container } = renderProvider({ state$ });
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
// Given the session store to restore from and to clear on a stale session
|
||||
expect(loadClient).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
act(() =>
|
||||
state$.next({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: true,
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("has no one signed in when given neither a client nor a way to load one", async () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
test("has no one signed in when the session says so", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({ state: "signedOut" }),
|
||||
});
|
||||
expect(container.textContent).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
test("shows the session's error in place of its children", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "error",
|
||||
error: new Error("Opened elsewhere"),
|
||||
}),
|
||||
});
|
||||
// The error page, not the children
|
||||
expect(container.textContent).not.toContain("error");
|
||||
expect(container.textContent).toContain("Something went wrong");
|
||||
});
|
||||
|
||||
test("hands the session's manager to views that need one", () => {
|
||||
const manager: ClientManager = {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(
|
||||
() => [useClientManager(), useClientManagerIfAny()],
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ClientManagerProvider value={manager}>
|
||||
<>{children}</>
|
||||
</ClientManagerProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current).toEqual([manager, manager]);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
|
||||
test("has no manager for a host's session", () => {
|
||||
// The component and the widget provide none; a view that insists on one is
|
||||
// in the wrong build
|
||||
expect(renderHook(() => useClientManagerIfAny()).result.current).toBeNull();
|
||||
expect(() => renderHook(() => useClientManager())).toThrow();
|
||||
});
|
||||
|
||||
+52
-374
@@ -5,30 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
use,
|
||||
useRef,
|
||||
useMemo,
|
||||
type JSX,
|
||||
} from "react";
|
||||
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 { type FC, useEffect, createContext, use, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
import { constant } from "./state/Behavior";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { OpenElsewhereError } from "./RichError";
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
} from "./state/ClientSession";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -37,380 +25,70 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientState = ValidClientState | ErrorState;
|
||||
|
||||
export type ValidClientState = {
|
||||
state: "valid";
|
||||
authenticated?: AuthenticatedClient;
|
||||
// 'Disconnected' rather than 'connected' because it tracks specifically
|
||||
// whether the client is supposed to be connected but is not
|
||||
disconnected: boolean;
|
||||
supportedFeatures: {
|
||||
reactions: boolean;
|
||||
};
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
// Without a provider, no one is signed in and there is no session to act on
|
||||
const noClientSession: ClientSession = {
|
||||
state$: constant({ state: "signedOut" }),
|
||||
};
|
||||
|
||||
export type AuthenticatedClient = {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
changePassword: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export type ErrorState = {
|
||||
state: "error";
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientState | undefined>(undefined);
|
||||
const ClientContext = createContext<ClientSession>(noClientSession);
|
||||
|
||||
export const ClientContextProvider = ClientContext.Provider;
|
||||
|
||||
export const useClientState = (): ClientState | undefined => use(ClientContext);
|
||||
export const useClientSessionState = (): ClientSessionState =>
|
||||
useBehavior(use(ClientContext).state$);
|
||||
|
||||
export function useClient(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
} {
|
||||
let client;
|
||||
let setClient;
|
||||
|
||||
const clientState = useClientState();
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
}
|
||||
|
||||
return { client, setClient };
|
||||
export function useClient(): { client?: MatrixClient } {
|
||||
const state = useClientSessionState();
|
||||
return { client: state.state === "signedIn" ? state.client : undefined };
|
||||
}
|
||||
|
||||
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
||||
export function useClientLegacy(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
passwordlessUser: boolean;
|
||||
loading: boolean;
|
||||
authenticated: boolean;
|
||||
logout?: () => void;
|
||||
error?: Error;
|
||||
} {
|
||||
const clientState = useClientState();
|
||||
// Null where the session is a host's: the component, and the widget
|
||||
const ClientManagerContext = createContext<ClientManager | null>(null);
|
||||
|
||||
let client;
|
||||
let setClient;
|
||||
let passwordlessUser = false;
|
||||
let loading = true;
|
||||
let error;
|
||||
let authenticated = false;
|
||||
let logout;
|
||||
export const ClientManagerProvider = ClientManagerContext.Provider;
|
||||
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
|
||||
loading = false;
|
||||
authenticated = client !== undefined;
|
||||
logout = clientState.authenticated?.logout;
|
||||
} else if (clientState?.state === "error") {
|
||||
error = clientState.error;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
setClient,
|
||||
passwordlessUser,
|
||||
loading,
|
||||
authenticated,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const loadChannel =
|
||||
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* An application hosting Element Call as a component already has a client,
|
||||
* and owns the user's session; supplying it here means Element Call neither
|
||||
* authenticates anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
/**
|
||||
* How Element Call finds a client when none is supplied: the standalone app
|
||||
* restores or creates a session of its own. Stable, as a hook dependency.
|
||||
* Left out along with the client, no one is signed in.
|
||||
*
|
||||
* A prop, rather than this module importing `utils/spa` itself, because:
|
||||
* - This module is in the component build, and so is everything it imports,
|
||||
* dynamically or not: a dynamic import becomes a chunk in the package,
|
||||
* not a module left out of it.
|
||||
* - Behind `utils/spa` sits `utils/initClient` and the client store worker,
|
||||
* which the bundler emits as soon as it loads the import (see there).
|
||||
* - `App.tsx`, which the component never imports, is where the standalone
|
||||
* app passes the loader in; the component passes nothing, having the
|
||||
* host's client.
|
||||
*/
|
||||
loadClient?: ClientLoader;
|
||||
/**
|
||||
* The manager of a session that is Element Call's own, for views that only
|
||||
* the standalone app mounts: the login and registration pages. Throws
|
||||
* elsewhere, as those views have no business being there.
|
||||
*/
|
||||
export function useClientManager(): ClientManager {
|
||||
const manager = use(ClientManagerContext);
|
||||
if (manager === null)
|
||||
throw new Error("This view needs a session of Element Call's own");
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own, kept where the given
|
||||
* functions keep it. Resolves to null when no one is signed in.
|
||||
* The session's manager, or null where the session is a host's. For views
|
||||
* shared with the widget, which can offer to sign out only when that is ours
|
||||
* to do.
|
||||
*/
|
||||
export type ClientLoader = (
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
) => Promise<InitResult | null>;
|
||||
export const useClientManagerIfAny = (): ClientManager | null =>
|
||||
use(ClientManagerContext);
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/** a session from Element Call SPA, from a widget or the host (EW) */
|
||||
session: ClientSession;
|
||||
}
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
if (loadClient === undefined) {
|
||||
setInitClientState(null);
|
||||
return;
|
||||
}
|
||||
initializing.current = true;
|
||||
|
||||
loadClient(loadSession, clearSession)
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client, loadClient]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
const session = loadSession();
|
||||
if (!initClientState?.client || !session) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initClientState.client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: session.user_id,
|
||||
},
|
||||
user: session.user_id,
|
||||
password: session.tempPassword,
|
||||
},
|
||||
password,
|
||||
);
|
||||
|
||||
saveSession({ ...session, passwordlessUser: false });
|
||||
|
||||
setInitClientState({
|
||||
...initClientState,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
},
|
||||
[initClientState],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(client: MatrixClient, session: Session) => {
|
||||
const oldClient = initClientState?.client;
|
||||
if (oldClient && oldClient !== client) {
|
||||
oldClient.stopClient();
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
},
|
||||
[initClientState?.client],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const client = initClientState?.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.logout(true);
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
setInitClientState(null);
|
||||
leaveToHome?.();
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
}, [leaveToHome, initClientState?.client]);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEventTarget(
|
||||
loadChannel,
|
||||
"message",
|
||||
useCallback(() => {
|
||||
initClientState?.client.stopClient();
|
||||
setAlreadyOpenedErr(new OpenElsewhereError());
|
||||
}, [initClientState?.client, setAlreadyOpenedErr]),
|
||||
);
|
||||
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
return { state: "error", error: alreadyOpenedErr };
|
||||
}
|
||||
|
||||
if (initClientState === undefined) return undefined;
|
||||
|
||||
const authenticated =
|
||||
initClientState === null
|
||||
? undefined
|
||||
: {
|
||||
client: initClientState.client,
|
||||
isPasswordlessUser: initClientState.passwordlessUser,
|
||||
changePassword,
|
||||
logout,
|
||||
};
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
alreadyOpenedErr,
|
||||
changePassword,
|
||||
initClientState,
|
||||
logout,
|
||||
setClient,
|
||||
isDisconnected,
|
||||
supportsReactions,
|
||||
]);
|
||||
|
||||
const onSync = useCallback(
|
||||
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
|
||||
setIsDisconnected(clientIsDisconnected(state, data));
|
||||
},
|
||||
[],
|
||||
);
|
||||
export const ClientProvider: FC<Props> = ({ children, session }) => {
|
||||
const state = useBehavior(session.state$);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initClientState) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.matrixclient = initClientState.client;
|
||||
window.passwordlessUser = initClientState.passwordlessUser;
|
||||
if (state.state !== "signedIn") return;
|
||||
window.matrixclient = state.client;
|
||||
window.passwordlessUser = state.passwordlessUser;
|
||||
|
||||
if (PosthogAnalytics.hasInstance())
|
||||
PosthogAnalytics.instance.onLoginStatusChanged();
|
||||
// Analytics follow the user's choices whoever the client belongs to
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
}, [state]);
|
||||
|
||||
if (initClientState.client) {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
if (state.state === "error") return <ErrorPage error={state.error} />;
|
||||
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
return <ClientContext value={session}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
tempPassword?: string;
|
||||
}
|
||||
|
||||
const clearSession = (): void => localStorage.removeItem("matrix-auth-store");
|
||||
const saveSession = (s: Session): void =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
|
||||
const loadSession = (): Session | undefined => {
|
||||
const data = localStorage.getItem("matrix-auth-store");
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const clientIsDisconnected = (
|
||||
syncState: SyncState,
|
||||
syncData?: ISyncStateData,
|
||||
): boolean =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 { expect, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
|
||||
test("shows while the client's sync fails to connect", () => {
|
||||
const client = {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const { container } = render(
|
||||
<ClientProvider session={hostClientSession(client)}>
|
||||
<DisconnectedBanner />
|
||||
</ClientProvider>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
const [, onSync] = vi.mocked(client.on).mock.calls[0] as [
|
||||
unknown,
|
||||
(state: string, old: null, data?: { error?: Error }) => void,
|
||||
];
|
||||
const connectionError = Object.assign(new Error("offline"), {
|
||||
name: "ConnectionError",
|
||||
});
|
||||
act(() => onSync("ERROR", null, { error: connectionError }));
|
||||
expect(container.textContent).not.toBe("");
|
||||
// Any other failure is not a connection problem
|
||||
act(() => onSync("ERROR", null, { error: new Error("rate limited") }));
|
||||
expect(container.textContent).toBe("");
|
||||
act(() => onSync("SYNCING", null));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
@@ -6,30 +6,60 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
|
||||
import styles from "./DisconnectedBanner.module.css";
|
||||
import { type ValidClientState, useClientState } from "./ClientContext";
|
||||
import { useClient } from "./ClientContext";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown while the signed-in client is supposed to be connected but is not.
|
||||
*/
|
||||
export const DisconnectedBanner: FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const clientState = useClientState();
|
||||
let shouldShowBanner = false;
|
||||
const { client } = useClient();
|
||||
// Tagged with the client it describes, so that a swapped client does not
|
||||
// inherit the old one's verdict before its first sync
|
||||
const [disconnected, setDisconnected] = useState<
|
||||
[MatrixClient, boolean] | null
|
||||
>(null);
|
||||
|
||||
if (clientState?.state === "valid") {
|
||||
const validClientState = clientState as ValidClientState;
|
||||
shouldShowBanner = validClientState.disconnected;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (client === undefined) return;
|
||||
const onSync = (
|
||||
syncState: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
const clientIsDisconnected =
|
||||
syncState === "ERROR" && data?.error?.name === "ConnectionError";
|
||||
setDisconnected([client, clientIsDisconnected]);
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
return (): void => {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const shouldShowBanner =
|
||||
disconnected !== null && disconnected[0] === client && disconnected[1];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
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 { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { UserMenuContainer } from "./UserMenuContainer";
|
||||
import { flushPromises } from "./utils/test";
|
||||
|
||||
vi.mock("./settings/SettingsModal", () => ({
|
||||
defaultSettingsTab: "audio",
|
||||
SettingsModal: (): null => null,
|
||||
}));
|
||||
|
||||
const mockClient = (): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
getUserIdLocalpart: () => "alice",
|
||||
getDeviceId: () => "AAAA",
|
||||
getUser: () => null,
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
test("leaves for home after logging out of the session", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(
|
||||
{
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: false,
|
||||
}),
|
||||
},
|
||||
{ login: vi.fn(), register: vi.fn(), logout },
|
||||
leaveToHome,
|
||||
);
|
||||
|
||||
await selectSignOut();
|
||||
expect(logout).toHaveBeenCalled();
|
||||
expect(leaveToHome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot end a session that is not ours", async () => {
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(hostClientSession(mockClient()), null, leaveToHome);
|
||||
|
||||
await selectSignOut();
|
||||
expect(leaveToHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function selectSignOut(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("usermenu_open"));
|
||||
await user.click(await screen.findByTestId("usermenu_logout"));
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function renderMenu(
|
||||
session: ClientSession,
|
||||
manager: ClientManager | null,
|
||||
leaveToHome: (() => void) | null = null,
|
||||
): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<LeaveToHomeProvider value={leaveToHome}>
|
||||
<ClientProvider session={session}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UserMenuContainer />
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
</LeaveToHomeProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { type FC, useCallback, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useClientLegacy } from "./ClientContext";
|
||||
import {
|
||||
useClient,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { useProfile } from "./profile/useProfile";
|
||||
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
@@ -21,7 +26,13 @@ interface Props {
|
||||
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
// Absent when the session is a host's, which Element Call cannot end
|
||||
const logout = useClientManagerIfAny()?.logout;
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const onDismissSettingsModal = useCallback(
|
||||
@@ -43,7 +54,9 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
setSettingsModalOpen(true);
|
||||
break;
|
||||
case "logout":
|
||||
logout?.();
|
||||
logout?.()
|
||||
.then(() => leaveToHome?.())
|
||||
.catch((e) => logger.error("Failed to log out", e));
|
||||
break;
|
||||
case "login":
|
||||
navigate("/login", { state: { from: location } })?.catch((error) =>
|
||||
@@ -52,7 +65,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate, location, logout, setSettingsModalOpen],
|
||||
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const userName = client?.getUserIdLocalpart() ?? "";
|
||||
@@ -60,7 +73,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
<>
|
||||
<UserMenu
|
||||
preventNavigation={preventNavigation}
|
||||
isAuthenticated={authenticated}
|
||||
isAuthenticated={client !== undefined}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
avatarUrl={avatarUrl}
|
||||
onAction={onAction}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("signs in with the session's manager and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockResolvedValue({} as MatrixClient);
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.login).toHaveBeenCalledWith(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
);
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why signing in failed", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockRejectedValue(new Error("Wrong password"));
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Wrong password")).toBeVisible();
|
||||
expect(screen.getByText("at /login")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("login_username"), username);
|
||||
await user.type(screen.getByTestId("login_password"), password);
|
||||
await user.click(screen.getByTestId("login_login"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderLogin(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<LoginPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+4
-13
@@ -11,10 +11,9 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -24,8 +23,7 @@ export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("login_title"));
|
||||
|
||||
const { client, setClient } = useClient();
|
||||
const login = useInteractiveLogin(client);
|
||||
const { login } = useClientManager();
|
||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
@@ -46,15 +44,8 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async ([client, session]) => {
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
|
||||
.then(async () => {
|
||||
const locationState = location.state;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -72,7 +63,7 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[login, location, navigate, homeserver, setClient],
|
||||
[login, location, navigate, homeserver],
|
||||
);
|
||||
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
|
||||
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RegisterPage } from "./RegisterPage";
|
||||
import { ClientContextProvider, ClientManagerProvider } from "../ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSessionState,
|
||||
} from "../state/ClientSession";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRegister({ state: "loading" }, mockManager());
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("registers an account, signs in and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
false,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("upgrades a passwordless user to a full account", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn: vi.fn(),
|
||||
});
|
||||
renderRegister(
|
||||
{
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@guest:example.org" } as MatrixClient,
|
||||
passwordlessUser: true,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Name taken"));
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Name taken")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(screen.getByText("at /register")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("register_username"), username);
|
||||
await user.type(screen.getByTestId("register_password"), password);
|
||||
await user.type(screen.getByTestId("register_confirm_password"), password);
|
||||
await user.click(screen.getByTestId("register_register"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderRegister(
|
||||
state: ClientSessionState,
|
||||
manager: ClientManager,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/register"]}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<RegisterPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</ClientContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+17
-17
@@ -22,8 +22,12 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "./useInteractiveRegistration";
|
||||
import {
|
||||
useClient,
|
||||
useClientManager,
|
||||
useClientSessionState,
|
||||
} from "../ClientContext";
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { LoadingPage } from "../FullScreenView";
|
||||
@@ -37,8 +41,13 @@ export const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("action.register"));
|
||||
|
||||
const { loading, authenticated, passwordlessUser, client, setClient } =
|
||||
useClientLegacy();
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const { client } = useClient();
|
||||
const authenticated = client !== undefined;
|
||||
const { register } = useClientManager();
|
||||
|
||||
const confirmPasswordRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -47,7 +56,7 @@ export const RegisterPage: FC = () => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const { recaptchaKey, register } = useInteractiveRegistration(client);
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const onSubmitRegisterForm = useCallback(
|
||||
@@ -64,7 +73,7 @@ export const RegisterPage: FC = () => {
|
||||
setRegistering(true);
|
||||
|
||||
const recaptchaResponse = await execute();
|
||||
const [newClient, session] = await register(
|
||||
const { client: newClient, setSignedIn } = await register(
|
||||
userName,
|
||||
password,
|
||||
userName,
|
||||
@@ -95,7 +104,7 @@ export const RegisterPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setClient?.(newClient, session);
|
||||
setSignedIn();
|
||||
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
|
||||
};
|
||||
|
||||
@@ -117,16 +126,7 @@ export const RegisterPage: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[
|
||||
register,
|
||||
location,
|
||||
navigate,
|
||||
passwordlessUser,
|
||||
reset,
|
||||
execute,
|
||||
client,
|
||||
setClient,
|
||||
],
|
||||
[register, location, navigate, passwordlessUser, reset, execute, client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
|
||||
import { loginWithPassword, registerWithPassword } from "./interactiveAuth";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const credentials = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
};
|
||||
|
||||
// What interactive auth asks of the client besides the request itself
|
||||
const authClient = { generateClientSecret: () => "client-secret" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs in with a password", async () => {
|
||||
const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" });
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
login,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
loginWithPassword("https://hs.example.org", "alice", "hunter2"),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
expect(login).toHaveBeenCalledWith("m.login.password", {
|
||||
identifier: { type: "m.id.user", user: "alice" },
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
test("registers, agreeing to the terms and answering the recaptcha", async () => {
|
||||
// The homeserver turns the first request away with the stages to complete
|
||||
// No error in it: that is the shape of a challenge rather than a refusal
|
||||
const challenge = new MatrixError(
|
||||
{
|
||||
session: "session",
|
||||
flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }],
|
||||
params: {},
|
||||
completed: [],
|
||||
},
|
||||
401,
|
||||
);
|
||||
const registerRequest = vi
|
||||
.fn()
|
||||
.mockImplementation(({ auth }: { auth?: { type: string } }) => {
|
||||
if (auth === undefined) throw challenge;
|
||||
challenge.data.completed = [
|
||||
...(challenge.data.completed as string[]),
|
||||
auth.type,
|
||||
];
|
||||
if (challenge.data.completed.length < 2) throw challenge;
|
||||
return credentials;
|
||||
});
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
registerWithPassword(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
"captcha",
|
||||
),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: "alice",
|
||||
password: "hunter2",
|
||||
auth: expect.objectContaining({ type: "m.login.terms" }),
|
||||
}),
|
||||
);
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
type: "m.login.recaptcha",
|
||||
response: "captcha",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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 {
|
||||
createClient,
|
||||
InteractiveAuth,
|
||||
type LoginResponse,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type StoredSession } from "../state/ClientSession";
|
||||
|
||||
/** What the homeserver hands out for a new session. */
|
||||
export type Credentials = Pick<
|
||||
StoredSession,
|
||||
"user_id" | "device_id" | "access_token"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Logs in to the given homeserver with a password.
|
||||
*/
|
||||
export async function loginWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an account on the given homeserver, accepting its terms and
|
||||
* answering its recaptcha challenge.
|
||||
*/
|
||||
export async function registerWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
recaptchaResponse: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
// XXX: attemptAuth claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
function credentials(authData: unknown): Credentials {
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, access_token, device_id } = authData as Credentials;
|
||||
return { user_id, access_token, device_id };
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 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 { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type LoginResponse,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
* @param oldClient If there is an already authenticated client it should be passed to this hook
|
||||
* this allows the interactive login to sign out the client before logging in.
|
||||
* @returns A async method that can be called/awaited to log in with the provided credentials.
|
||||
*/
|
||||
export function useInteractiveLogin(
|
||||
oldClient?: MatrixClient,
|
||||
): (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]> {
|
||||
return useCallback<
|
||||
(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
>(
|
||||
async (homeserver: string, username: string, password: string) => {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
const session = {
|
||||
user_id,
|
||||
access_token,
|
||||
device_id,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
|
||||
// To not confuse the rust crypto sessions we need to logout the old client before initializing the new one.
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: homeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 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 { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const authClient = useRef<MatrixClient | undefined>(undefined);
|
||||
if (!authClient.current) {
|
||||
authClient.current = createClient({
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session: Session = {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
const user = client.getUser(client.getUserId()!)!;
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey, register };
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useRegisterPasswordlessUser } from "./useRegisterPasswordlessUser";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a user under a random name and signs them in", async () => {
|
||||
const setSignedIn = vi.fn();
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
await result.current.registerPasswordlessUser("Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(result.current.privacyPolicyUrl).toBe(
|
||||
"https://hs.example.org/privacy",
|
||||
);
|
||||
expect(result.current.recaptchaId).toBe("recaptcha");
|
||||
});
|
||||
|
||||
test("resets the captcha when registration fails", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Nope"));
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("Nope");
|
||||
expect(reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot register on a session that is not ours", async () => {
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(null, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("This session cannot register");
|
||||
});
|
||||
|
||||
test("never registers as a widget", async () => {
|
||||
const manager = mockManager();
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow(/embedded mode/);
|
||||
expect(manager.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
manager: ClientManager | null,
|
||||
isWidget: boolean,
|
||||
): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>
|
||||
<ClientManagerProvider value={manager}>{children}</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useClientManagerIfAny } from "../ClientContext";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
|
||||
}
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
const { setClient } = useClient();
|
||||
// Null in the widget, which never registers anyone
|
||||
const register = useClientManagerIfAny()?.register;
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const registerPasswordlessUser = useCallback(
|
||||
async (displayName: string) => {
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
if (!register) {
|
||||
throw new Error("This session cannot register");
|
||||
}
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
try {
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const [client, session] = await register(
|
||||
const { setSignedIn } = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
true,
|
||||
);
|
||||
setClient(client, session);
|
||||
setSignedIn();
|
||||
} catch (e) {
|
||||
reset();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
[execute, reset, register, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
import { createClient, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const registerRequest = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("reads the terms the homeserver asks for from a registration attempt", async () => {
|
||||
registerRequest.mockRejectedValue({
|
||||
data: {
|
||||
params: {
|
||||
"m.login.terms": {
|
||||
policies: {
|
||||
privacy_policy: { en: { url: "https://hs.example.org/privacy" } },
|
||||
},
|
||||
},
|
||||
"m.login.recaptcha": { public_key: "site-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("has nothing to agree to where the homeserver asks for nothing", async () => {
|
||||
registerRequest.mockRejectedValue({ data: { params: {} } });
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(registerRequest).toHaveBeenCalled());
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("asks the homeserver nothing as a widget", () => {
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(true),
|
||||
});
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper(isWidget: boolean): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>{children}</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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 { useEffect, useState } from "react";
|
||||
import { createClient } from "matrix-js-sdk";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
/**
|
||||
* What the homeserver asks of a registration: its privacy policy to agree to
|
||||
* and the recaptcha key to answer a challenge with.
|
||||
*/
|
||||
export function useRegistrationTerms(): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
} {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
createClient({ baseUrl: Config.defaultHomeserverUrl()! })
|
||||
.registerRequest({})
|
||||
.catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en
|
||||
?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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 { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { HomePage } from "./HomePage";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { type ClientSessionState } from "../state/ClientSession";
|
||||
|
||||
vi.mock("./RegisteredView", () => ({
|
||||
RegisteredView: ({ client }: { client: MatrixClient }) => (
|
||||
<span>registered as {client.getUserId()}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("./UnauthenticatedView", () => ({
|
||||
UnauthenticatedView: () => <span>unauthenticated</span>,
|
||||
}));
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderHome({ state: "loading" });
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the session's error", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
renderHome({ state: "error", error: new Error("Opened elsewhere") });
|
||||
expect(screen.getByText("Something went wrong")).toBeVisible();
|
||||
});
|
||||
|
||||
test("welcomes a signed-in user", () => {
|
||||
renderHome({
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
expect(screen.getByText("registered as @alice:example.org")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invites a signed-out user to start a call", () => {
|
||||
renderHome({ state: "signedOut" });
|
||||
expect(screen.getByText("unauthenticated")).toBeVisible();
|
||||
});
|
||||
|
||||
function renderHome(state: ClientSessionState): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<HomePage />
|
||||
</ClientContextProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
+11
-12
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { useClientSessionState } from "../ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
@@ -18,17 +18,16 @@ export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("common.home"));
|
||||
|
||||
const clientState = useClientState();
|
||||
const session = useClientSessionState();
|
||||
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
) : (
|
||||
<UnauthenticatedView />
|
||||
);
|
||||
switch (session.state) {
|
||||
case "loading":
|
||||
return <LoadingPage />;
|
||||
case "error":
|
||||
return <ErrorPage error={session.error} />;
|
||||
case "signedIn":
|
||||
return <RegisteredView client={session.client} />;
|
||||
case "signedOut":
|
||||
return <UnauthenticatedView />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import type * as matrixUtils from "../utils/matrix";
|
||||
import { createRoom, getRelativeRoomUrl } from "../utils/matrix";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
vi.mock("../auth/useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("../auth/useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
vi.mock("../auth/generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
vi.mock("../utils/matrix", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof matrixUtils>()),
|
||||
createRoom: vi.fn(),
|
||||
}));
|
||||
vi.mock("../UserMenuContainer", () => ({ UserMenuContainer: () => null }));
|
||||
|
||||
const reset = vi.fn();
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a guest, creates their call and signs them in", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
|
||||
vi.mocked(createRoom).mockResolvedValue({
|
||||
roomId: "!call:example.org",
|
||||
alias: "#my-call:example.org",
|
||||
password: "secret",
|
||||
});
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(createRoom).toHaveBeenCalledWith(
|
||||
client,
|
||||
"My Call",
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
const url = getRelativeRoomUrl(
|
||||
"!call:example.org",
|
||||
{ kind: E2eeType.SHARED_KEY, secret: "secret" },
|
||||
"My Call",
|
||||
);
|
||||
expect(await screen.findByText(`at ${url}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("signs in only once the user chooses to join a call that exists", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
|
||||
vi.mocked(createRoom).mockRejectedValue({ errcode: "M_ROOM_IN_USE" });
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
const join = await screen.findByRole("button", { name: "Yes, join call" });
|
||||
expect(setSignedIn).not.toHaveBeenCalled();
|
||||
await userEvent.click(join);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /my-call")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Rate limited"));
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
expect(await screen.findByText("Rate limited")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(createRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function submit(callName: string, displayName: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("home_callName"), callName);
|
||||
await user.type(screen.getByTestId("home_displayName"), displayName);
|
||||
await user.click(screen.getByTestId("home_go"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => {
|
||||
const { pathname, search, hash } = useLocation();
|
||||
return <span>{`at ${pathname}${search}${hash}`}</span>;
|
||||
};
|
||||
|
||||
function renderView(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<UrlParamsProvider value={computeUrlParams()}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UnauthenticatedView />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../utils/matrix";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { Form } from "../form/Form";
|
||||
@@ -37,12 +37,12 @@ import { ExternalLink, Link } from "../button/Link";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const { register } = useClientManager();
|
||||
const { header } = useUrlParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||
@@ -67,7 +67,9 @@ export const UnauthenticatedView: FC = () => {
|
||||
setLoading(true);
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const [client, session] = await register(
|
||||
// Signed in only once the call exists too: this view is replaced the
|
||||
// moment the session has a user, and the call is what they came for
|
||||
const { client, setSignedIn } = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
@@ -83,15 +85,12 @@ export const UnauthenticatedView: FC = () => {
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!setClient) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setOnFinished(() => {
|
||||
setClient(client, session);
|
||||
// Wrapped once more, as a bare function would be taken for an updater
|
||||
setOnFinished(() => () => {
|
||||
setSignedIn();
|
||||
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
|
||||
navigate(`/${aliasLocalpart}`)?.catch((error) => {
|
||||
logger.error("Failed to navigate to alias localpart", error);
|
||||
@@ -106,14 +105,10 @@ export const UnauthenticatedView: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Only consider the registration successful if we managed to create the room, too
|
||||
if (!setClient) {
|
||||
throw new Error("setClient is undefined");
|
||||
}
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
setClient(client, session);
|
||||
setSignedIn();
|
||||
await navigate(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
@@ -130,14 +125,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[
|
||||
register,
|
||||
reset,
|
||||
execute,
|
||||
navigate,
|
||||
setJoinExistingCallModalOpen,
|
||||
setClient,
|
||||
],
|
||||
[register, reset, execute, navigate, setJoinExistingCallModalOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+19
-9
@@ -20,7 +20,9 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { pendingHostClientSession } from "./state/ClientSession";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
import { StandaloneClientSession } from "./state/StandaloneClientSession";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
@@ -51,17 +53,25 @@ if (fatalError !== null) {
|
||||
Initializer.initBeforeReact()
|
||||
.then((widget) => {
|
||||
const { controlledAudioDevices, callIntent } = getUrlParams();
|
||||
// As a widget, the client comes from the host over the widget API, and
|
||||
// the session is the host's. Standalone, Element Call restores or creates
|
||||
// a session of its own, which it also manages.
|
||||
const options = { controlledAudioDevices, callIntent };
|
||||
let vm: AppViewModel;
|
||||
if (widget === null) {
|
||||
const session = new StandaloneClientSession(globalScope);
|
||||
vm = new AppViewModel(globalScope, options, session, session);
|
||||
} else {
|
||||
vm = new AppViewModel(
|
||||
globalScope,
|
||||
options,
|
||||
pendingHostClientSession(globalScope, widget.client),
|
||||
null,
|
||||
);
|
||||
}
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App
|
||||
vm={
|
||||
new AppViewModel(globalScope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
})
|
||||
}
|
||||
widget={widget}
|
||||
/>
|
||||
<App vm={vm} widget={widget} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { useHostBridge } from "../HostBridge";
|
||||
import { ElementCallReactionEventType, type ReactionOption } from ".";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -54,9 +54,7 @@ export const ReactionsSenderProvider = ({
|
||||
vm: CallViewModel;
|
||||
}): JSX.Element => {
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const clientState = useClientState();
|
||||
const supportsReactions =
|
||||
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
|
||||
const { supportsReactions } = useHostBridge();
|
||||
const room = rtcSession.room;
|
||||
const myUserId = room.client.getUserId();
|
||||
const myDeviceId = room.client.getDeviceId();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RoomPage } from "./RoomPage";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { type ClientSessionState } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
|
||||
vi.mock("./useLoadGroupCall", () => ({
|
||||
useLoadGroupCall: vi.fn(() => ({ kind: "loading" })),
|
||||
CallTerminatedMessage: class extends Error {},
|
||||
}));
|
||||
vi.mock("./useRoomName", () => ({ useRoomName: () => null }));
|
||||
vi.mock("../profile/useProfile", () => ({ useProfile: () => ({}) }));
|
||||
vi.mock("../auth/useRegisterPasswordlessUser", () => ({
|
||||
useRegisterPasswordlessUser: () => ({ registerPasswordlessUser }),
|
||||
}));
|
||||
vi.mock("./RoomAuthView", () => ({
|
||||
RoomAuthView: () => <span>room auth view</span>,
|
||||
}));
|
||||
vi.mock("../home/HomePage", () => ({
|
||||
HomePage: () => <span>home page</span>,
|
||||
}));
|
||||
vi.mock("./CallView", () => ({
|
||||
CallView: ({ isPasswordlessUser }: { isPasswordlessUser: boolean }) => (
|
||||
<span>call view, passwordless: {String(isPasswordlessUser)}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("./KnockLobbyView", () => ({ KnockLobbyView: () => null }));
|
||||
|
||||
const registerPasswordlessUser = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRoom({ state: "loading" }, "/call");
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks a signed-out user to join as a guest", () => {
|
||||
renderRoom({ state: "signedOut" }, "/call");
|
||||
expect(screen.getByText("room auth view")).toBeVisible();
|
||||
expect(registerPasswordlessUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("registers a passwordless user when the URL names one", () => {
|
||||
renderRoom({ state: "signedOut" }, "/call", "Alice");
|
||||
expect(registerPasswordlessUser).toHaveBeenCalledWith("Alice");
|
||||
});
|
||||
|
||||
test("falls back to the home page without a room", () => {
|
||||
renderRoom(signedIn(false), "/");
|
||||
expect(screen.getByText("home page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("tells the call whether its user is passwordless", () => {
|
||||
vi.mocked(useLoadGroupCall).mockReturnValue({
|
||||
kind: "loaded",
|
||||
rtcSession: { room: {} },
|
||||
} as unknown as ReturnType<typeof useLoadGroupCall>);
|
||||
|
||||
renderRoom(signedIn(true), "/call");
|
||||
expect(screen.getByText("call view, passwordless: true")).toBeVisible();
|
||||
});
|
||||
|
||||
function signedIn(passwordlessUser: boolean): ClientSessionState {
|
||||
return {
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
|
||||
passwordlessUser,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRoom(
|
||||
state: ClientSessionState,
|
||||
path: string,
|
||||
displayName: string | null = null,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<UrlParamsProvider value={{ ...computeUrlParams(), displayName }}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<RoomPage />
|
||||
</ClientContextProvider>
|
||||
</UrlParamsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useClient, useClientSessionState } from "../ClientContext";
|
||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { CallView } from "./CallView";
|
||||
@@ -42,8 +42,12 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const { loading, authenticated, client, error, passwordlessUser } =
|
||||
useClientLegacy();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const authenticated = client !== undefined;
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
@@ -184,7 +188,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
};
|
||||
|
||||
if (loading || isRegistering) return <LoadingPage />;
|
||||
if (error) return <ErrorPage error={error} />;
|
||||
if (!client) return <RoomAuthView />;
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
if (!roomIdOrAlias) return <HomePage />;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { type MatrixClient } from "matrix-js-sdk/lib/client";
|
||||
|
||||
import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { hostClientSession } from "../state/ClientSession";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
|
||||
@@ -74,22 +75,7 @@ function renderWithMockClient(
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
render(
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<TestComponent
|
||||
sendLogs={sendLogs}
|
||||
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
|
||||
|
||||
@@ -5,6 +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 ClientManager, type ClientSession } from "./ClientSession";
|
||||
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { createAppBackgroundEffects } from "../tracks/BackgroundEffectsContext";
|
||||
@@ -22,8 +23,14 @@ export class AppViewModel {
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
/**
|
||||
* @param clientSession Where the client comes from. SPA js-sdk, EW host, widget
|
||||
*/
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly audioOutputOptions: AudioOutputOptions,
|
||||
public readonly clientSession: ClientSession,
|
||||
/** Null where the session is a host's and not ours to manage. */
|
||||
public readonly clientManager: ClientManager | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { expect, test } from "vitest";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { hostClientSession, pendingHostClientSession } from "./ClientSession";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
test("a host's client is signed in from the start", () => {
|
||||
const session = hostClientSession(client);
|
||||
expect(session.state$.value).toEqual({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a pending host client is loading until it arrives", async () => {
|
||||
const session = pendingHostClientSession(
|
||||
testScope(),
|
||||
Promise.resolve(client),
|
||||
);
|
||||
expect(session.state$.value).toEqual({ state: "loading" });
|
||||
await flushPromises();
|
||||
expect(session.state$.value).toEqual({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a pending host client that never arrives is an error", async () => {
|
||||
const error = new Error("no widget API");
|
||||
const session = pendingHostClientSession(testScope(), Promise.reject(error));
|
||||
await flushPromises();
|
||||
// An error page rather than a spinner that never ends
|
||||
expect(session.state$.value).toEqual({ state: "error", error });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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 MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { catchError, from, map, of } from "rxjs";
|
||||
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* A session of Element Call's own, as the standalone app stores it.
|
||||
*/
|
||||
export interface StoredSession {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
}
|
||||
|
||||
export type ClientSessionState =
|
||||
| { state: "loading" }
|
||||
| { state: "signedOut" }
|
||||
| { state: "signedIn"; client: MatrixClient; passwordlessUser: boolean }
|
||||
| { state: "error"; error: Error };
|
||||
|
||||
/**
|
||||
* A newly registered account, with its client started. Registering and signing
|
||||
* in are separate steps, as a caller may have more to do with the account
|
||||
* before the session changes hands — creating the call it was registered for,
|
||||
* say.
|
||||
*/
|
||||
export interface Registration {
|
||||
client: MatrixClient;
|
||||
setSignedIn: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Element Call's client comes from. Every build has one: a session of
|
||||
* Element Call's own, or one a host has supplied.
|
||||
*/
|
||||
export interface ClientSession {
|
||||
state$: Behavior<ClientSessionState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be done with a client created in EC (SPA). Widget and host client
|
||||
* wont have this, see useClientManager and useClientManagerIfAny
|
||||
*/
|
||||
export interface ClientManager {
|
||||
/** Signs in to the homeserver with a password, ending any current session. */
|
||||
login: (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<MatrixClient>;
|
||||
/** Registers an account on the default homeserver. */
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<Registration>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client supplied by the host, which owns the user's session: signed in from
|
||||
* the start, nothing to restore. Used for the component target.
|
||||
*/
|
||||
export function hostClientSession(client: MatrixClient): ClientSession {
|
||||
return { state$: constant(signedIn(client)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A client the host is still handing over.
|
||||
* Used for the embedded target, widget API.
|
||||
*/
|
||||
export function pendingHostClientSession(
|
||||
scope: ObservableScope,
|
||||
client: Promise<MatrixClient>,
|
||||
): ClientSession {
|
||||
return {
|
||||
state$: scope.behavior<ClientSessionState>(
|
||||
from(client).pipe(
|
||||
map(signedIn),
|
||||
catchError((e) => {
|
||||
logger.error("Failed to obtain the host's client", e);
|
||||
return of<ClientSessionState>({ state: "error", error: e });
|
||||
}),
|
||||
),
|
||||
{ state: "loading" },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function signedIn(client: MatrixClient): ClientSessionState {
|
||||
return { state: "signedIn", client, passwordlessUser: false };
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { type MatrixClient, MatrixError, type User } from "matrix-js-sdk";
|
||||
|
||||
import { StandaloneClientSession } from "./StandaloneClientSession";
|
||||
import { type StoredSession } from "./ClientSession";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import {
|
||||
loginWithPassword,
|
||||
registerWithPassword,
|
||||
} from "../auth/interactiveAuth";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { OpenElsewhereError } from "../RichError";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
vi.mock("../utils/initClient", () => ({ initClient: vi.fn() }));
|
||||
vi.mock("../auth/interactiveAuth", () => ({
|
||||
loginWithPassword: vi.fn(),
|
||||
registerWithPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
const SESSION_KEY = "matrix-auth-store";
|
||||
|
||||
const session: StoredSession = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
passwordlessUser: true,
|
||||
};
|
||||
|
||||
const user = { setRawDisplayName: vi.fn(), setDisplayName: vi.fn() };
|
||||
const mockClient = (): MatrixClient =>
|
||||
({
|
||||
stopClient: vi.fn(),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
clearStores: vi.fn().mockResolvedValue(undefined),
|
||||
setDisplayName: vi.fn().mockResolvedValue(undefined),
|
||||
getUserId: () => session.user_id,
|
||||
getUser: () => user as unknown as User,
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
const credentials = {
|
||||
user_id: "@bob:example.org",
|
||||
device_id: "BBBB",
|
||||
access_token: "fresh",
|
||||
};
|
||||
|
||||
const storeSession = (): void =>
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
const storedSession = (): StoredSession | null =>
|
||||
JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "init").mockResolvedValue(undefined);
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("restores the stored session once the config is loaded", async () => {
|
||||
storeSession();
|
||||
const client = mockClient();
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
expect(clientSession.state$.value).toEqual({ state: "loading" });
|
||||
|
||||
await flushPromises();
|
||||
expect(Config.init).toHaveBeenCalled();
|
||||
expect(initClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
accessToken: "secret",
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "AAAA",
|
||||
}),
|
||||
// Restoring: the crypto store is the session's, not to be wiped
|
||||
true,
|
||||
);
|
||||
expect(clientSession.state$.value).toEqual({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("has no one signed in when there is no session to restore", async () => {
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
expect(initClient).not.toHaveBeenCalled();
|
||||
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
|
||||
});
|
||||
|
||||
test("drops a session the homeserver no longer knows", async () => {
|
||||
storeSession();
|
||||
vi.mocked(initClient).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
|
||||
);
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
expect(storedSession()).toBeNull();
|
||||
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
|
||||
});
|
||||
|
||||
test("drops the session and reports any other error", async () => {
|
||||
storeSession();
|
||||
const error = new Error("offline");
|
||||
vi.mocked(initClient).mockRejectedValue(error);
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
expect(storedSession()).toBeNull();
|
||||
// An error page rather than a spinner that never ends
|
||||
expect(clientSession.state$.value).toEqual({ state: "error", error });
|
||||
});
|
||||
|
||||
test("logs in with a password, ending the session it had", async () => {
|
||||
storeSession();
|
||||
const restored = mockClient();
|
||||
vi.mocked(initClient).mockResolvedValueOnce(restored);
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
|
||||
const client = mockClient();
|
||||
vi.mocked(loginWithPassword).mockResolvedValue(credentials);
|
||||
vi.mocked(initClient).mockResolvedValueOnce(client);
|
||||
await expect(
|
||||
clientSession.login("https://other.example.org", "bob", "hunter2"),
|
||||
).resolves.toBe(client);
|
||||
|
||||
expect(loginWithPassword).toHaveBeenCalledWith(
|
||||
"https://other.example.org",
|
||||
"bob",
|
||||
"hunter2",
|
||||
);
|
||||
// The old session is logged out before the new client starts, so that the
|
||||
// crypto stores are not shared between the two
|
||||
expect(restored.logout).toHaveBeenCalledWith(true);
|
||||
expect(initClient).toHaveBeenLastCalledWith(
|
||||
{
|
||||
baseUrl: "https://other.example.org",
|
||||
accessToken: "fresh",
|
||||
userId: "@bob:example.org",
|
||||
deviceId: "BBBB",
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(clientSession.state$.value).toEqual({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: false });
|
||||
});
|
||||
|
||||
test("registers a passwordless account, signing in only when asked", async () => {
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
|
||||
const client = mockClient();
|
||||
vi.mocked(registerWithPassword).mockResolvedValue(credentials);
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
const registration = await clientSession.register(
|
||||
"bob",
|
||||
"temporary",
|
||||
"Bob",
|
||||
"captcha",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(registerWithPassword).toHaveBeenCalledWith(
|
||||
"https://hs.example.org",
|
||||
"bob",
|
||||
"temporary",
|
||||
"captcha",
|
||||
);
|
||||
expect(registration.client).toBe(client);
|
||||
expect(client.setDisplayName).toHaveBeenCalledWith("Bob");
|
||||
expect(user.setDisplayName).toHaveBeenCalledWith("Bob");
|
||||
// Registered, but not yet signed in: the caller may still be setting up
|
||||
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
|
||||
expect(storedSession()).toBeNull();
|
||||
|
||||
registration.setSignedIn();
|
||||
expect(clientSession.state$.value).toEqual({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: true,
|
||||
});
|
||||
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: true });
|
||||
});
|
||||
|
||||
test("registers an account with a password of its own", async () => {
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
vi.mocked(registerWithPassword).mockResolvedValue(credentials);
|
||||
vi.mocked(initClient).mockResolvedValue(mockClient());
|
||||
|
||||
const { setSignedIn } = await clientSession.register(
|
||||
"bob",
|
||||
"hunter2",
|
||||
"Bob",
|
||||
"captcha",
|
||||
false,
|
||||
);
|
||||
setSignedIn();
|
||||
|
||||
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: false });
|
||||
});
|
||||
|
||||
test("logs out, clearing the client's stores and the stored session", async () => {
|
||||
storeSession();
|
||||
const client = mockClient();
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
|
||||
const analyticsLogout = vi.spyOn(PosthogAnalytics.instance, "logout");
|
||||
|
||||
await clientSession.logout();
|
||||
|
||||
expect(client.logout).toHaveBeenCalledWith(true);
|
||||
expect(client.clearStores).toHaveBeenCalled();
|
||||
expect(storedSession()).toBeNull();
|
||||
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
|
||||
// Analytics forget the user along with the session
|
||||
expect(analyticsLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("claims exclusive use of storage from other instances of the app", () => {
|
||||
// The channel is created with the session, so spy on the prototype rather
|
||||
// than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
new StandaloneClientSession(testScope());
|
||||
expect(postMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("stops when another instance of the app claims storage", async () => {
|
||||
storeSession();
|
||||
const client = mockClient();
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
const clientSession = new StandaloneClientSession(testScope());
|
||||
await flushPromises();
|
||||
|
||||
new BroadcastChannel("load").postMessage({});
|
||||
await vi.waitFor(() =>
|
||||
expect(clientSession.state$.value).toMatchObject({ state: "error" }),
|
||||
);
|
||||
expect(client.stopClient).toHaveBeenCalled();
|
||||
const { value } = clientSession.state$;
|
||||
expect(value.state === "error" && value.error).toBeInstanceOf(
|
||||
OpenElsewhereError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
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 MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
import { OpenElsewhereError } from "../RichError";
|
||||
import {
|
||||
loginWithPassword,
|
||||
registerWithPassword,
|
||||
} from "../auth/interactiveAuth";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { fallbackICEServerAllowed } from "../utils/matrix";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
type Registration,
|
||||
type StoredSession,
|
||||
} from "./ClientSession";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
const SESSION_KEY = "matrix-auth-store";
|
||||
|
||||
/**
|
||||
* A client session for the SPA: restored from storage on construction, and
|
||||
* signed in to, out of, or re-authenticated by the login, registration and
|
||||
* account flows.
|
||||
*
|
||||
* This class is responsible for restoring from store and logout and change Pwd.
|
||||
*
|
||||
* To make sure we do not load/bundle the client store worker it is important
|
||||
* that this is a separate file: only this module reaches `utils/initClient`,
|
||||
* and therefore the client store worker, so it may only be imported by the
|
||||
* standalone app's entry point.
|
||||
*/
|
||||
export class StandaloneClientSession implements ClientSession, ClientManager {
|
||||
private readonly state = new BehaviorSubject<ClientSessionState>({
|
||||
state: "loading",
|
||||
});
|
||||
public readonly state$: Behavior<ClientSessionState> = this.scope.behavior(
|
||||
this.state,
|
||||
);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app.
|
||||
private readonly loadChannel =
|
||||
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {
|
||||
this.loadChannel?.postMessage({});
|
||||
const onOpenedElsewhere = (): void => {
|
||||
this.client?.stopClient();
|
||||
this.state.next({ state: "error", error: new OpenElsewhereError() });
|
||||
};
|
||||
this.loadChannel?.addEventListener("message", onOpenedElsewhere);
|
||||
scope.onEnd(() =>
|
||||
this.loadChannel?.removeEventListener("message", onOpenedElsewhere),
|
||||
);
|
||||
|
||||
this.restore().catch((e) => {
|
||||
logger.error("Failed to restore the session", e);
|
||||
this.state.next({ state: "error", error: e });
|
||||
});
|
||||
}
|
||||
|
||||
private get client(): MatrixClient | undefined {
|
||||
const { value } = this.state;
|
||||
return value.state === "signedIn" ? value.client : undefined;
|
||||
}
|
||||
|
||||
private async restore(): Promise<void> {
|
||||
// The homeserver to restore against is in the config, which is loaded
|
||||
// concurrently with this
|
||||
await Config.init();
|
||||
try {
|
||||
const session = loadSession();
|
||||
if (session === undefined) {
|
||||
logger.log("No session stored; continuing without a client");
|
||||
this.state.next({ state: "signedOut" });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("Using a standalone client");
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: session.access_token,
|
||||
userId: session.user_id,
|
||||
deviceId: session.device_id,
|
||||
fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
},
|
||||
true,
|
||||
);
|
||||
this.state.next({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
} catch (e) {
|
||||
// Whatever went wrong, the stored session is not to be tried again
|
||||
clearSession();
|
||||
if (e instanceof MatrixError && e.errcode === "M_UNKNOWN_TOKEN") {
|
||||
logger.log(
|
||||
"The session from local store is invalid; continuing without a client",
|
||||
);
|
||||
this.state.next({ state: "signedOut" });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly login = async (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<MatrixClient> => {
|
||||
const session: StoredSession = {
|
||||
...(await loginWithPassword(homeserver, username, password)),
|
||||
passwordlessUser: false,
|
||||
};
|
||||
const client = await this.createClient(homeserver, session);
|
||||
this.signIn(client, session);
|
||||
return client;
|
||||
};
|
||||
|
||||
public readonly register = async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
): Promise<Registration> => {
|
||||
const homeserver = Config.defaultHomeserverUrl()!;
|
||||
const session: StoredSession = {
|
||||
...(await registerWithPassword(
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
recaptchaResponse,
|
||||
)),
|
||||
passwordlessUser,
|
||||
};
|
||||
const client = await this.createClient(homeserver, session);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
const user = client.getUser(client.getUserId()!)!;
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return { client, setSignedIn: (): void => this.signIn(client, session) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a client for a new session, ending the current one first.
|
||||
*/
|
||||
private async createClient(
|
||||
homeserver: string,
|
||||
session: StoredSession,
|
||||
): Promise<MatrixClient> {
|
||||
// To not confuse the rust crypto sessions we need to log out the old
|
||||
// client before initializing the new one
|
||||
await this.client?.logout(true);
|
||||
return initClient(
|
||||
{
|
||||
baseUrl: homeserver,
|
||||
accessToken: session.access_token,
|
||||
userId: session.user_id,
|
||||
deviceId: session.device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private signIn(client: MatrixClient, session: StoredSession): void {
|
||||
saveSession(session);
|
||||
this.state.next({
|
||||
state: "signedIn",
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
}
|
||||
|
||||
public readonly logout = async (): Promise<void> => {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
await client.logout(true);
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
this.state.next({ state: "signedOut" });
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
};
|
||||
}
|
||||
|
||||
function loadSession(): StoredSession | undefined {
|
||||
const data = localStorage.getItem(SESSION_KEY);
|
||||
return data ? JSON.parse(data) : undefined;
|
||||
}
|
||||
|
||||
function saveSession(session: StoredSession): void {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
function clearSession(): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
@@ -17,13 +17,8 @@ import {
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// This import is why `initClient` has a module of its own:
|
||||
// - The bundler emits the worker file (11 MB: the Matrix SDK and its crypto
|
||||
// wasm) the moment it loads this import — whether or not anything ever calls
|
||||
// `new IndexedDBWorker()`. Tree shaking drops the call, not the file.
|
||||
// - So it may only sit in a module the component build never loads. It used
|
||||
// to be in `./matrix`, which the component reaches for the room URL helpers,
|
||||
// and the worker shipped in the package, unused and unusable by any host.
|
||||
// This import pulls in many MBs of SDK code and crypto WASM which should only
|
||||
// be included in full SPA builds, which is why `initClient` gets its own module.
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
+2
-5
@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
// *Do not* import any client stores in this module, to keep the component
|
||||
// build lean.
|
||||
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
|
||||
@@ -18,11 +20,6 @@ import {
|
||||
saveKeyForRoom,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
|
||||
// Shared with the component, which reaches this module for the room URLs. So
|
||||
// nothing in here may import the client store worker: the bundler emits the
|
||||
// worker file on loading the import, used or not. The one function that needs
|
||||
// it, `initClient`, lives in `./initClient` for that reason.
|
||||
|
||||
export const fallbackICEServerAllowed =
|
||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { initSPA } from "./spa";
|
||||
import { Config } from "../config/Config";
|
||||
import { type Session } from "../ClientContext";
|
||||
|
||||
vi.mock("./initClient", () => ({ initClient: vi.fn() }));
|
||||
|
||||
const session: Session = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
passwordlessUser: true,
|
||||
};
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
describe("initSPA", () => {
|
||||
const clearSession = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("continues without a client when no session is stored", async () => {
|
||||
await expect(initSPA(() => undefined, clearSession)).resolves.toBeNull();
|
||||
|
||||
expect(initClient).not.toHaveBeenCalled();
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the stored session into a client", async () => {
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toEqual({
|
||||
client,
|
||||
passwordlessUser: true,
|
||||
});
|
||||
|
||||
expect(initClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
accessToken: "secret",
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "AAAA",
|
||||
}),
|
||||
// Restoring: the crypto store is the session's, not to be wiped
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a session the homeserver no longer knows", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
|
||||
);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toBeNull();
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the session and fails on any other error", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(new Error("offline"));
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).rejects.toThrow(
|
||||
"offline",
|
||||
);
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 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 { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
import { initClient } from "./initClient";
|
||||
import type { InitResult, Session } from "../ClientContext";
|
||||
|
||||
export async function initSPA(
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
): Promise<InitResult | null> {
|
||||
// We're running as a standalone application
|
||||
try {
|
||||
const session = loadSession();
|
||||
if (!session) {
|
||||
logger.log("No session stored; continuing without a client");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.log("Using a standalone client");
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, device_id, access_token, passwordlessUser } = session;
|
||||
const initClientParams: ICreateClientOpts = {
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
fallbackICEServerAllowed,
|
||||
livekitServiceURL: Config.get().livekit?.livekit_service_url,
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await initClient(initClientParams, true);
|
||||
return {
|
||||
client,
|
||||
passwordlessUser,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
|
||||
// We can't use this session anymore, so let's log it out
|
||||
logger.log(
|
||||
"The session from local store is invalid; continuing without a client",
|
||||
);
|
||||
clearSession();
|
||||
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
clearSession();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user