diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 1c1111f7..3dad74ae 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -5,7 +5,7 @@
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
-> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
+> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -39,7 +39,7 @@ Uncomment the markdown table below and fill in the last line:
## Checklist
- [ ] A linked, pre-approved issue exists for this feature or UI change.
-- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
+- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml
index f4071975..b35948d7 100644
--- a/.github/workflows/build-element-call.yaml
+++ b/.github/workflows/build-element-call.yaml
@@ -44,7 +44,9 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
- run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+ run: "scripts/layered.sh --ignore-pnpmfile"
+ env:
+ PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index ba7cde51..3fe10723 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -6,7 +6,7 @@ on:
- opened
- labeled
push:
- branches: [livekit, full-mesh]
+ branches: [main]
jobs:
build_full_element_call:
# Use the full package vite build
@@ -22,8 +22,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
- # Deploy livekit branch to call.element.dev after build completes
- if: github.ref == 'refs/heads/livekit'
+ # Deploy main branch to call.element.dev after build completes
+ if: github.ref == 'refs/heads/main'
needs: build_full_element_call
runs-on: ubuntu-latest
steps:
@@ -43,8 +43,8 @@ jobs:
}
})
docker_for_develop:
- # Build docker and publish docker for livekit branch after build completes
- if: github.ref == 'refs/heads/livekit'
+ # Build docker and publish docker for main branch after build completes
+ if: github.ref == 'refs/heads/main'
needs: build_full_element_call
permissions:
contents: write
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index ad92e7d1..c59ba402 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -1,6 +1,12 @@
name: Lint, format & type check
on:
pull_request: {}
+ workflow_call:
+ inputs:
+ matrix-js-sdk-sha:
+ type: string
+ required: false
+ description: The matrix-js-sdk commit to build against instead of the locked one
jobs:
lint:
name: Lint, format & type check
@@ -9,6 +15,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
+ # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
+ repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -19,7 +27,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
- run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+ run: "scripts/layered.sh --ignore-pnpmfile"
+ env:
+ PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
+ JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Formatting
run: "pnpm run format:check"
- name: i18n
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 3f86f093..5e2703f6 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -3,6 +3,25 @@ on:
pull_request: {}
push:
branches: [main]
+ workflow_call:
+ inputs:
+ matrix-js-sdk-sha:
+ type: string
+ required: false
+ description: The matrix-js-sdk commit to build against instead of the locked one
+ disable_coverage:
+ type: boolean
+ required: false
+ default: false
+ description: Skip generating and uploading coverage reports
+ disable_e2e_tests:
+ type: boolean
+ required: false
+ default: false
+ description: Skip the end-to-end tests
+ secrets:
+ CODECOV_TOKEN:
+ required: false
jobs:
vitest:
name: Run unit tests
@@ -11,6 +30,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
+ # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
+ repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -21,7 +42,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
- run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+ run: "scripts/layered.sh --ignore-pnpmfile"
+ env:
+ PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
+ JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
@@ -35,8 +59,11 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
- run: "pnpm run test:coverage"
+ run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
+ env:
+ ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
- name: Upload to codecov
+ if: ${{ !inputs.disable_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -45,11 +72,14 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
+ if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
+ # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
+ repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -59,7 +89,10 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
- run: pnpm install --frozen-lockfile --ignore-pnpmfile
+ run: scripts/layered.sh --ignore-pnpmfile
+ env:
+ PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
+ JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
diff --git a/.gitignore b/.gitignore
index 5cbed94e..17cc34ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@ public/config.json
backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage
+
+# Transient agent scratch files
+/agent-workspace/
config.json
# Yarn
@@ -39,3 +42,6 @@ yarn-error.log
*storybook.log
storybook-static
.pnpm-store/
+
+# Nested checkout made by scripts/layered.sh
+/matrix-js-sdk/
diff --git a/.oxlintrc.json b/.oxlintrc.json
index d06f2a47..c89ab8a2 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -39,6 +39,17 @@
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
+ // TODO: These React Compiler-based rules joined oxlint's correctness
+ // category in v1.81 and flag many pre-existing patterns, including hooks
+ // like useLatest/useInitial/useReactiveState that intentionally access
+ // refs during render. Re-evaluate them when adopting the React Compiler.
+ "react/immutability": "off",
+ "react/no-deriving-state-in-effects": "off",
+ "react/preserve-manual-memoization": "off",
+ "react/refs": "off",
+ "react/set-state-in-effect": "off",
+ "react/static-components": "off",
+ "react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 757c1f8a..5b841b82 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
import type { Preview } from "@storybook/react-vite";
import { TooltipProvider } from "@vector-im/compound-web";
-import i18n from "i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { initReactI18next } from "react-i18next";
+import { i18n } from "../src/utils/i18n";
import "../src/index.css";
-// Bare-minimum i18n config
+// Bare-minimum i18n config.
+// Unlike the app, stories register the instance as react-i18next's default
+// rather than wrapping every story in an
{session.client.getDeviceId()}
+
+
+
+ This should cover the calls completely.
+ +{state.roomId}
+
+
+
+ {entry.at} {entry.pane}{" "}
+ {entry.message}
+
{t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts
new file mode 100644
index 00000000..3eda39f5
--- /dev/null
+++ b/src/RootElementContext.ts
@@ -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 { createContext, use } from "react";
+
+/**
+ * The element that Element Call treats as the root of its own interface.
+ *
+ * Element Call decorates this element with the theme, layout and background
+ * attributes its stylesheets key off, and portals its modals into it. When
+ * Element Call owns the page this is simply the document body; as a component
+ * it is the container the host mounted it into, so that Element Call does not
+ * reach outside its own subtree.
+ *
+ * The stylesheets find this element by its `data-element-call-root` attribute,
+ * which {@link useTheme} sets along with the platform and theme, so they no
+ * longer depend on it being the body.
+ *
+ * What remains body-specific is the standalone page's own furniture: the
+ * `body` rule in `index.css` still sets the page background and margin, and
+ * `index.html` starts the body hidden with `no-theme` until the theme lands.
+ * Neither applies when a host mounts Element Call into a container of its own.
+ */
+const RootElementContext = createContext
{error.localisedMessageKey ? (
@@ -188,14 +185,12 @@ interface BoundaryProps {
children: ReactNode | (() => ReactNode);
recoveryActionHandler: RecoveryActionHandler;
onError?: (error: unknown) => void;
- widget: WidgetHelpers | null;
}
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,
children,
- widget,
}: BoundaryProps): ReactElement => {
const fallbackRenderer: FallbackRender = useCallback(
({ error, resetError }): ReactElement => {
@@ -205,7 +200,6 @@ export const GroupCallErrorBoundary = ({
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
@@ -216,7 +163,6 @@ export const RoomPage: FC = (): ReactNode => {
{groupCallState.error.messageBody} {t("developer_mode.environment_variables")} {t("developer_mode.url_params")}
Local Participant
@@ -417,7 +421,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
remote
)
Local Participant
@@ -674,7 +682,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
Environment variables
URL parameters
{JSON.stringify(env, null, 2)}
+ {JSON.stringify(env, null, 2)}
{JSON.stringify(urlParams, null, 2)}
+ {JSON.stringify(urlParams, null, 2)}
>
);
};
diff --git a/src/settings/DeviceSelection.test.tsx b/src/settings/DeviceSelection.test.tsx
new file mode 100644
index 00000000..1e57d011
--- /dev/null
+++ b/src/settings/DeviceSelection.test.tsx
@@ -0,0 +1,82 @@
+/*
+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 { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Form } from "@vector-im/compound-web";
+import { describe, expect, it, vi } from "vitest";
+
+import { DeviceSelection } from "./DeviceSelection";
+import { constant } from "../state/Behavior";
+import {
+ type AudioOutputDeviceLabel,
+ type DeviceLabel,
+ type MediaDevice,
+ type SelectedDevice,
+} from "../state/MediaDevices";
+
+type Device = MediaDevice
+
{
"region": "local",
"version": "1.2.3"
@@ -390,7 +392,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
localParticipantIdentity
+
{
"region": "remote",
"version": "4.5.6"
@@ -427,7 +433,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
localParticipantIdentity
+
{
"MY_MOCK_ENV": 10,
"ENV": "test"
@@ -683,7 +693,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
+
{
"mocked": true,
"answer": 42
diff --git a/src/settings/rageshake.flush.test.ts b/src/settings/rageshake.flush.test.ts
new file mode 100644
index 00000000..2bbe120c
--- /dev/null
+++ b/src/settings/rageshake.flush.test.ts
@@ -0,0 +1,52 @@
+/*
+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, it, vi } from "vitest";
+
+import { init as initRageshake } from "./rageshake";
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+it("flushes logs to IndexedDB periodically without an explicit flush", async () => {
+ vi.useFakeTimers();
+ const add = vi.fn();
+ const txn = {
+ oncomplete: undefined as (() => void) | undefined,
+ onerror: undefined,
+ objectStore: (name: string) =>
+ name === "logs"
+ ? {
+ add: (entry: unknown): void => {
+ add(entry);
+ queueMicrotask(() => txn.oncomplete?.());
+ },
+ }
+ : { put: vi.fn() },
+ };
+ const open = (): unknown => {
+ const req = {
+ result: { transaction: () => txn },
+ onsuccess: undefined as (() => void) | undefined,
+ };
+ queueMicrotask(() => req.onsuccess?.());
+ return req;
+ };
+ vi.stubGlobal("indexedDB", { open });
+
+ await initRageshake();
+ global.mx_rage_logger.log(1, "test", "hello from the buffer");
+ expect(add).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(add).toHaveBeenCalledOnce();
+ expect(add.mock.calls[0][0]).toMatchObject({
+ lines: expect.stringContaining("hello from the buffer"),
+ });
+});
diff --git a/src/settings/rageshake.ts b/src/settings/rageshake.ts
index b9ecbff5..8fe759a8 100644
--- a/src/settings/rageshake.ts
+++ b/src/settings/rageshake.ts
@@ -204,10 +204,13 @@ class IndexedDBLogStore {
// Throttled function to flush logs. We use throttle rather
// than debounce as we want logs to be written regularly, otherwise
// if there's a constant stream of logging, we'd never write anything.
- private throttledFlush = throttle(() => this.flush, MAX_FLUSH_INTERVAL_MS, {
- leading: false,
- trailing: true,
- });
+ private throttledFlush = throttle(
+ () => {
+ this.flush().catch((e) => logger.error("Failed to flush logs", e));
+ },
+ MAX_FLUSH_INTERVAL_MS,
+ { leading: false, trailing: true },
+ );
/**
* Flush logs to disk.
diff --git a/src/sound/LICENCE.md b/src/sound/LICENCE.md
index c4f81363..2ed79912 100644
--- a/src/sound/LICENCE.md
+++ b/src/sound/LICENCE.md
@@ -10,6 +10,7 @@ The following sound effects have been licensed from Pixabay, under https://pixab
- `reactions/generic`
- `reactions/lightbulb`
- `reactions/party`
+- `reactions/bell`
### Other
diff --git a/src/sound/reactions/bell.mp3 b/src/sound/reactions/bell.mp3
new file mode 100644
index 00000000..108c773c
Binary files /dev/null and b/src/sound/reactions/bell.mp3 differ
diff --git a/src/sound/reactions/bell.ogg b/src/sound/reactions/bell.ogg
new file mode 100644
index 00000000..ef01eec4
Binary files /dev/null and b/src/sound/reactions/bell.ogg differ
diff --git a/src/soundUtils.test.ts b/src/soundUtils.test.ts
new file mode 100644
index 00000000..3d9810fe
--- /dev/null
+++ b/src/soundUtils.test.ts
@@ -0,0 +1,77 @@
+/*
+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 { prefetchSounds } from "./soundUtils";
+
+const bytesOf = (buffer: ArrayBuffer): number[] => [...new Uint8Array(buffer)];
+
+describe("prefetchSounds", () => {
+ const fetchSpy = vi.fn