mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88d8d5830 | ||
|
|
6c66f9a3e7 | ||
|
|
99f48ff987 | ||
|
|
6846c8dd3e | ||
|
|
28ee707ace | ||
|
|
3100c1c410 | ||
|
|
fc1e91f54c |
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: handoff
|
||||
description: Implement the first cut of a task and stop for a direction check before the quality pass.
|
||||
argument-hint: <the task>
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Task: $ARGUMENTS
|
||||
|
||||
Stop after the first implementation and hand it back before the quality pass.
|
||||
|
||||
## Before the hand-off
|
||||
|
||||
- Run the narrowest check that rules out an obviously broken hand-off: the test
|
||||
file for the touched code, `tsc` on its own, a single story. Not the full
|
||||
`pnpm lint`, not the whole suite.
|
||||
- If a wider check is needed before feedback can be useful, keep it to the touched
|
||||
code and say why it was needed.
|
||||
- Leave wider refactors, extra tests and documentation polish out. They are
|
||||
follow-up work, not part of the first hand-off.
|
||||
|
||||
## The hand-off
|
||||
|
||||
Stop and ask whether the direction is right. The message says:
|
||||
|
||||
- what was built and where, in a few lines,
|
||||
- which check ran and what it returned, verbatim if it failed,
|
||||
- what was left out on purpose,
|
||||
- the decisions that could have gone another way.
|
||||
|
||||
Do not continue past this point on your own.
|
||||
|
||||
## After the user confirms
|
||||
|
||||
The skill ends here. Continue with the quality pass as usual.
|
||||
@@ -44,7 +44,3 @@ storybook-static
|
||||
|
||||
# Nested checkout made by scripts/layered.sh
|
||||
/matrix-js-sdk/
|
||||
|
||||
# Claude
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
|
||||
@@ -16,35 +16,19 @@ host's page. It is the MatrixRTC reference implementation.
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
| … | … |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
|
||||
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
|
||||
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
|
||||
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
|
||||
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
|
||||
## Scratch files live in `agent-workspace/`
|
||||
|
||||
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
`agent-workspace/<slug>/`.
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
|
||||
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
|
||||
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
|
||||
| `implementation-summary.md` | What was built, decisions, trade-offs |
|
||||
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
|
||||
|
||||
- Nothing here is durable. The folder is ignored, so anything worth keeping is
|
||||
promoted into the PR body or a doc before the task ends.
|
||||
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
|
||||
`config.json_` files are what this folder prevents.
|
||||
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
|
||||
section.
|
||||
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
|
||||
|
||||
## Gates
|
||||
|
||||
@@ -58,11 +42,6 @@ pnpm i18n:check
|
||||
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
|
||||
`knip.ts` `ignoreFiles` with a reason.
|
||||
|
||||
Ready means every gate is green and the change is covered at the layers
|
||||
[testing.md](docs/agents/testing.md) asks for. Read the diff against
|
||||
[code-style.md](docs/agents/code-style.md) first. Re-run the whole checklist after
|
||||
any fix.
|
||||
|
||||
## A good PR
|
||||
|
||||
- One slice, under ~400 changed lines, green on its own, linked to its issue.
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
+1
-5
@@ -14,11 +14,7 @@ This folder contains documentation for setup, usage, and development of Element
|
||||
|
||||
Entry point: [AGENTS.md](../AGENTS.md).
|
||||
|
||||
- [Agent workflow](./agents/workflow.md)
|
||||
- [Architecture](./agents/architecture.md)
|
||||
- [Code style](./agents/code-style.md)
|
||||
- [Testing](./agents/testing.md)
|
||||
|
||||
Opt-in skills in [`.claude/skills/`](../.claude/skills/):
|
||||
|
||||
- `/handoff <task>` — get the first implementation back for a direction check
|
||||
before lint, the full suite and polish.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent workflow
|
||||
|
||||
## Scratch files live in `agent-workspace/`
|
||||
|
||||
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
`agent-workspace/<slug>/`.
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
|
||||
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
|
||||
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
|
||||
| `implementation-summary.md` | What was built, decisions, trade-offs |
|
||||
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
|
||||
|
||||
- Nothing here is durable. The folder is ignored, so anything worth keeping is
|
||||
promoted into the PR body, a doc, or a feature spec before the task ends.
|
||||
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
|
||||
`config.json_` files are what this folder prevents.
|
||||
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
|
||||
section.
|
||||
- Commit subjects are plain imperative English. No conventional-commits prefixes.
|
||||
- A `plan.md` and a feature spec sit at different altitudes; neither replaces the
|
||||
other. A spec in `FEATURES_SPEC/` is durable and deliberately abstract —
|
||||
behaviour, decisions, acceptance criteria — so the feature can be rebuilt against
|
||||
a `main` nobody has seen yet. A plan is one slice of it landing on today's
|
||||
`main`: the paths, symbols and edit order the spec's decisions and criteria must
|
||||
not name. `FEATURES_SPEC/AGENTS.md` wins wherever the two genuinely overlap.
|
||||
|
||||
## Hand off before the quality pass
|
||||
|
||||
- Implement the change, run the narrowest check that rules out an obviously broken
|
||||
handoff, then stop and ask whether the direction is right.
|
||||
- Full `pnpm lint`, the whole suite, coverage and benchmarks come after the
|
||||
direction is confirmed.
|
||||
- Wider refactors, extra tests and documentation polish are follow-up work, not
|
||||
part of the first handoff.
|
||||
- If a check is needed before feedback, keep it to the touched code and say why.
|
||||
|
||||
## Commit and PR readiness
|
||||
|
||||
- Commit once the user confirms direction, or asks for one. Not before. Where the
|
||||
setup has no git identity, a sandbox included, write the message to
|
||||
`commit-msg.txt` and hand it over instead.
|
||||
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
|
||||
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
|
||||
against [code-style.md](./code-style.md) first.
|
||||
- Re-run the whole checklist after any fix. Only on green, either way.
|
||||
- Once a human has started reviewing, fix forward — never force-push a
|
||||
regeneration over a review in progress.
|
||||
+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#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9",
|
||||
"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#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
specifier: github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9
|
||||
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.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2':
|
||||
resolution: {integrity: sha512-GHeuSeb5Fj6Wg2+NE/WoLJJs9U3nOzttO+2sj7lOaS5gNBpGIksc48N/Q/kK/Z+Lxh7YMhopB7M8b6HyASXnGQ==}
|
||||
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/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
|
||||
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
|
||||
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.0': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2': {}
|
||||
|
||||
'@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/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.2
|
||||
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,
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
|
||||
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",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("discoverLocalTransport", () => {
|
||||
logger,
|
||||
),
|
||||
).resolves.toBe(hostTransport);
|
||||
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
|
||||
expect(c.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" | "_unstable_getRTCTransports">,
|
||||
client: Pick<MatrixClient, "getDomain" | "getRTCTransports">,
|
||||
transportTypes: readonly string[],
|
||||
{
|
||||
transport: override,
|
||||
|
||||
@@ -36,11 +36,7 @@ const configTransport: Transport = {
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
return {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
@@ -57,7 +53,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"prefers backend transport other app config $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -71,7 +67,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -81,10 +77,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([
|
||||
backendTransport,
|
||||
mLivekit,
|
||||
]);
|
||||
client.getRTCTransports.mockResolvedValue([backendTransport, mLivekit]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -100,7 +93,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
client.getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
new MatrixError(
|
||||
{
|
||||
@@ -124,7 +117,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
backendTransport,
|
||||
);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
expect(client.getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
@@ -135,7 +128,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
client.getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
@@ -152,7 +145,7 @@ describe("TransportAutoDiscovery", () => {
|
||||
|
||||
it("returns null when backend and config are all unavailable", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
client.getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
|
||||
@@ -10,10 +10,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
type TransportDiscoveryClient = Pick<MatrixClient, "getRTCTransports">;
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
@@ -74,7 +71,7 @@ export class TransportAutoDiscovery {
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
try {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
client.getRTCTransports(),
|
||||
);
|
||||
for (const type of this.transportTypes) {
|
||||
const first = transportList.find(
|
||||
|
||||
+12
-2
@@ -30,7 +30,7 @@ import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { type ClientLoader, ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -57,6 +57,16 @@ 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;
|
||||
}
|
||||
@@ -146,7 +156,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
|
||||
@@ -11,7 +11,11 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
import {
|
||||
type ClientLoader,
|
||||
ClientProvider,
|
||||
useClientState,
|
||||
} from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -95,13 +99,13 @@ test("follows the client when the host swaps it", () => {
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
const loadClient = vi
|
||||
.fn<ClientLoader>()
|
||||
.mockResolvedValue({ client, passwordlessUser: true });
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ClientProvider loadClient={loadClient}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -110,6 +114,21 @@ test("finds a client of its own when the host supplies none", async () => {
|
||||
// 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),
|
||||
);
|
||||
});
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
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>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
|
||||
});
|
||||
|
||||
+32
-12
@@ -143,9 +143,34 @@ interface Props {
|
||||
* 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;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type ClientLoader = (
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
) => Promise<InitResult | null>;
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
@@ -179,9 +204,13 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
// 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()
|
||||
loadClient(loadSession, clearSession)
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
@@ -189,7 +218,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
}, [client, loadClient]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -360,15 +389,6 @@ export type InitResult = {
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
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 { EventEmitter } from "events";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
import type * as MatrixSdk from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { flushPromises } from "./test";
|
||||
|
||||
interface Store {
|
||||
startup: Mock<() => Promise<void>>;
|
||||
getSavedSyncToken: Mock<() => Promise<string | null>>;
|
||||
}
|
||||
|
||||
const store = (): Store => ({
|
||||
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
getSavedSyncToken: vi
|
||||
.fn<() => Promise<string | null>>()
|
||||
.mockResolvedValue(null),
|
||||
});
|
||||
|
||||
// The client is the SDK's business; what these tests are about is how it is
|
||||
// set up: its stores, its crypto, and when it counts as ready.
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixSdk>()),
|
||||
createClient: vi.fn(),
|
||||
IndexedDBStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
MemoryStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
}));
|
||||
|
||||
interface FakeClient extends EventEmitter {
|
||||
store: Store;
|
||||
clearStores: Mock<() => Promise<void>>;
|
||||
initRustCrypto: Mock<() => Promise<void>>;
|
||||
startClient: Mock<() => Promise<void>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client that, unless told otherwise, reports its first sync a tick after
|
||||
* being started, as the real one does once the network answers.
|
||||
*/
|
||||
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
store: opts.store as unknown as Store,
|
||||
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
startClient: vi.fn(async () => {
|
||||
if (!syncs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
}),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
|
||||
const clients = vi.mocked(createClient) as unknown as Mock<
|
||||
(opts: ICreateClientOpts) => FakeClient
|
||||
>;
|
||||
const indexedDBStores = vi.mocked(IndexedDBStore);
|
||||
const memoryStores = vi.mocked(MemoryStore);
|
||||
|
||||
describe("initClient", () => {
|
||||
beforeEach(() => {
|
||||
clients.mockImplementation((opts) => fakeClient(opts));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("hands back the client once its first sync is done", async () => {
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client).toBe(clients.mock.results[0].value);
|
||||
expect(client.startClient).toHaveBeenCalledWith({
|
||||
clientWellKnownPollPeriod: 600,
|
||||
});
|
||||
expect(clients).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
useAuthorizationHeader: true,
|
||||
localTimeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stores when restoring a session", async () => {
|
||||
const client = (await initClient(options, true)) as unknown as FakeClient;
|
||||
expect(client.clearStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the stores before starting them when not restoring", async () => {
|
||||
const client = (await initClient(options, false)) as unknown as FakeClient;
|
||||
|
||||
expect(client.clearStores).toHaveBeenCalled();
|
||||
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
client.store.startup.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs into memory where there is no IndexedDB", async () => {
|
||||
// jsdom has no indexedDB
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(indexedDBStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", false);
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
|
||||
const [storeOptions] = indexedDBStores.mock.calls[0];
|
||||
expect(storeOptions.dbName).toBe("element-call-sync");
|
||||
expect(storeOptions.workerFactory).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB without a worker in development", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", true);
|
||||
|
||||
await initClient(options, true);
|
||||
|
||||
// The dev server does not bundle the worker
|
||||
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to memory when the IndexedDB store cannot start", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
const failing = store();
|
||||
failing.startup.mockRejectedValue(new Error("quota exceeded"));
|
||||
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
|
||||
Object.assign(this, failing);
|
||||
});
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(client.store.startup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries on without crypto when it cannot be initialised", async () => {
|
||||
clients.mockImplementation((opts) => {
|
||||
const client = fakeClient(opts);
|
||||
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
|
||||
return client;
|
||||
});
|
||||
|
||||
await expect(initClient(options, true)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("waits for a sync from the network, past one restored from the store", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => {
|
||||
client = fakeClient(opts, false);
|
||||
client.store.getSavedSyncToken.mockResolvedValue("saved");
|
||||
return client;
|
||||
});
|
||||
let ready = false;
|
||||
const result = initClient(options, true).then(() => (ready = true));
|
||||
await flushPromises();
|
||||
|
||||
// The first sync the client reports is the one restored from the store
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await flushPromises();
|
||||
expect(ready).toBe(false);
|
||||
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await result;
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when the first sync does", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
|
||||
const result = initClient(options, true);
|
||||
await flushPromises();
|
||||
|
||||
client.emit(ClientEvent.Sync, "ERROR", null, {
|
||||
error: new Error("offline"),
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
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 {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
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.
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
+8
-152
@@ -5,21 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
Preset,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
|
||||
import type { MatrixClient, Room } from "matrix-js-sdk";
|
||||
import { generateUrlSearchParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -27,149 +18,14 @@ 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";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function roomAliasLocalpartFromRoomName(roomName: string): string {
|
||||
return roomName
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
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();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -9,7 +9,8 @@ import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed, initClient } from "./matrix";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
import { initClient } from "./initClient";
|
||||
import type { InitResult, Session } from "../ClientContext";
|
||||
|
||||
export async function initSPA(
|
||||
|
||||
Reference in New Issue
Block a user