Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes Marbach
05176b10b1 Switch to jest-style assertions to please oxlint
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-29 14:37:36 +02:00
Johannes Marbach
e7397a41db Don't redact null or undefined 2026-06-29 14:26:49 +02:00
Johannes Marbach
a47c1536fc Restrain logging of URL properties 2026-06-29 14:26:47 +02:00
71 changed files with 4773 additions and 3194 deletions

116
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,116 @@
const COPYRIGHT_HEADER = `/*
Copyright %%CURRENT_YEAR%% New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
`;
module.exports = {
plugins: ["matrix-org", "rxjs", "jsdoc"],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:matrix-org/typescript",
"prettier",
"plugin:rxjs/recommended",
"plugin:storybook/recommended",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
},
rules: {
"jsdoc/no-types": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-values": "error",
"jsdoc/check-param-names": "warn",
// "jsdoc/require-param": "warn",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
"react/display-name": "error",
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk):
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
// To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
"no-restricted-imports": [
"error",
{
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
.map((path) => `${lib}/${path}`)
.map((name) => ({ name, message: `Please use ${lib} instead` })),
),
patterns: [
...["matrix-widget-api"].map((lib) => ({
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
(path) => `${lib}/${path}`,
),
message: `Please use ${lib} instead`,
})),
// XXX: We use /lib in lots of places, so allow for now.
...["matrix-js-sdk"].map((lib) => ({
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
message: `Please use ${lib} instead`,
})),
],
},
],
},
overrides: [
{
files: ["src/*/**"],
rules: {
// In application code we should use the js-sdk logger, never console directly.
"no-console": ["error"],
},
},
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**",
],
rules: {
"jsdoc/no-types": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-values": "off",
"jsdoc/check-param-names": "off",
"jsdoc/require-param-description": "off",
},
},
{
files: ["playwright/**"],
rules: {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off",
},
},
],
settings: {
react: {
version: "detect",
},
},
};

253
.github/labels.yml vendored
View File

@@ -1,253 +0,0 @@
- name: "A-1:1"
description: "Calls between two people"
color: "bfd4f2"
- name: "A-Big-Grid"
description: "The freedom layout system used for >12 participants"
color: "bfd4f2"
- name: "A-Developer-Experience"
description: "Workflow of developing: building, linting, debugging, profiling, etc."
color: "c5def5"
- name: "A-E2EE"
description: "End-to-end encryption"
color: "bfd4f2"
- name: "A-Embedded"
description: "Using the app embedded within other Matrix clients (as a widget)"
color: "bfd4f2"
- name: "A-Feedback-Reporting"
description: "Reporting process for bugs, debug logs (rageshakes), suggestions"
color: "bfd4f2"
- name: "A-Freedom"
description: "Freedom layout, where participants can be rearranged and resized"
color: "bfd4f2"
- name: "A-Handset"
description: "Audio playback through the earpiece of a phone. Also known as 'earpiece mode' or 'handset mode'."
color: "bfd4f2"
- name: "A-Huddle"
description: "Ad-hoc calls in a room notifying others"
color: "bfd4f2"
- name: "A-Lobby"
description: "The page before joining a call"
color: "bfd4f2"
- name: "A-Login"
color: "bfd4f2"
- name: "A-Matrix2.0"
description: "Issues relating to the Matrix 2.0 / MSC4143 work, such as sticky events and multi-sfu"
color: "bfd4f2"
- name: "A-Media-Devices"
color: "BFD4F2"
- name: "A-Media-Quality"
description: "Distortions or glitches in audio/video"
color: "bfd4f2"
- name: "A-Meeting"
description: "Scheduled call on the calendar"
color: "bfd4f2"
- name: "A-Mobile"
description: "Using the app on a mobile device"
color: "bfd4f2"
- name: "A-Moderation"
description: "Access to calls and powers within calls"
color: "bfd4f2"
- name: "A-Performance"
color: "bfd4f2"
- name: "A-Reactions"
color: "bfd4f2"
- name: "A-Registration"
color: "bfd4f2"
- name: "A-Screen-Sharing"
color: "bfd4f2"
- name: "A-SDK"
description: "SDK for building MatrixRTC + LiveKit widgets"
color: "c5def5"
- name: "A-Settings"
color: "bfd4f2"
- name: "A-SFU"
description: "Routing calls through a selective forwarding unit"
color: "bfd4f2"
- name: "A-Signaling"
description: "Call signaling"
color: "bfd4f2"
- name: "A-Simulcast"
description: "Automatic selection of variable video resolutions"
color: "bfd4f2"
- name: "A-SPA"
description: "Standalone application accessed via call links"
color: "bfd4f2"
- name: "A-Spatial-Audio"
description: "Directional audio based on where a speaker appears on screen"
color: "bfd4f2"
- name: "A-Speech-Enhancement"
description: "Techniques to enhance the intelligibility of speech in calls"
color: "c5def5"
- name: "A-Split-Grid"
description: "The freedom layout system used for ≤12 participants"
color: "bfd4f2"
- name: "A-Spotlight"
description: "Spotlight layout, where the active speaker is foregrounded"
color: "bfd4f2"
- name: "A-Telemetry-Posthog"
description: "Share opt in usage data for optimizing the app via posthog"
color: "bfd4f2"
- name: "A-Testing"
description: "Integration tests, unit tests, etc."
color: "bfd4f2"
- name: "A-Video-Rooms"
description: "Rooms reserved exclusively for calling"
color: "bfd4f2"
- name: "A-Walkie-Talkie"
description: "Walkie-talkie / PTT (push-to-talk) mode"
color: "bfd4f2"
- name: "A11y"
description: "Accessibility"
color: "4ADEC0"
- name: "backport-candidate"
description: "Something that is a candidate for backport to a particular release branch"
color: "0B8D85"
- name: "customer-retainer"
color: "F44A5F"
- name: "dependencies"
description: "Pull requests that update a dependency file"
color: "0366d6"
- name: "development build"
description: "runs yarn build process in development mode"
color: "1d76db"
- name: "Discord"
description: "Use case familiar to Discord users"
color: "3670d2"
- name: "docker build"
description: "Creates a docker image for this PR"
color: "0e8a16"
- name: "EPIC"
color: "5319E7"
- name: "good first issue"
description: "Good for newcomers"
color: "7057ff"
- name: "Help Wanted"
description: "Community contributions are welcome!"
color: "159818"
- name: "I18n"
description: "Internationalisation"
color: "d4c5f9"
- name: "O-Frequent"
description: "Affects or can be seen by most users regularly or impacts most users' first experience"
color: "0052CC"
- name: "O-Occasional"
description: "Affects or can be seen by some users regularly or most users rarely"
color: "1D76DB"
- name: "O-Uncommon"
description: "Most users are unlikely to come across this or unexpected workflow"
color: "C5DEF5"
- name: "p1"
description: "Must fix/implement before this is usable as a product"
color: "D93F0B"
- name: "p2"
description: "Should fix/implement, but not at the expense of p1s"
color: "FBCA04"
- name: "p3"
description: "Could fix/implement when time allows"
color: "0E8A16"
- name: "PR-Breaking-Change"
description: "A Pull request that changes EC in a way that is incompatible to the previous version."
color: "D93F0B"
- name: "PR-Bug-Fix"
description: "Release note category. A PR that fixes a bug."
color: "C2E0C6"
- name: "PR-Developer-Experience"
description: "Release note category. A PR that does not change EC but improves working with the repository."
color: "C2E0C6"
- name: "PR-Documentation"
description: "Release note category. A PR that improves the documentation."
color: "C2E0C6"
- name: "PR-Feature"
description: "Release note category. A PR that introduces a new user facing feature."
color: "C2E0C6"
- name: "PR-Improvement"
description: "Release note category. A PR that improves EC's performance or stability."
color: "C2E0C6"
- name: "PR-Task"
description: "Release note category. A PR that is hidden from release note."
color: "C2E0C6"
- name: "Privacy"
color: "f41192"
- name: "Roadmap"
color: "57457E"
- name: "S-Critical"
description: "Prevents work, causes data loss and/or has no workaround"
color: "bd0026"
- name: "S-Major"
description: "Severely degrades major functionality or product features, with no satisfactory workaround"
color: "fc4e2a"
- name: "S-Minor"
description: "Impairs non-critical functionality or suitable workarounds exist"
color: "feb24c"
- name: "S-Tolerable"
description: "Low/no impact on users"
color: "ffeda0"
- name: "Security"
color: "b3e5fc"
- name: "storybook build"
description: "Build and deploy the storybook frontend to netlify."
color: "45cd61"
- name: "T-Defect"
description: "Something isn't working: bugs, crashes, hangs, vulnerabilities, or other reported problems"
color: "98e6ae"
- name: "T-Enhancement"
description: "New features, changes in functionality, performance boosts, user-facing improvements"
color: "98e6ae"
- name: "T-Other"
description: "Questions, user support, anything else"
color: "98e6ae"
- name: "T-Task"
description: "Refactoring, enabling or disabling functionality, other engineering tasks"
color: "98e6ae"
- name: "X-Blocked"
description: "Cannot be merged due to external dependencies"
color: "ff7979"
- name: "X-Cannot-Reproduce"
description: "Needs reproduction steps"
color: "ff7979"
- name: "X-Needs-Design"
description: "May require input from the design team"
color: "ff7979"
- name: "X-Needs-Info"
description: "This issue is blocked awaiting information from the reporter"
color: "ff7979"
- name: "X-Needs-Investigation"
color: "ff7979"
- name: "X-Needs-Product"
description: "More input needed from the Product team"
color: "ff7979"
- name: "X-Regression"
color: "ff7979"
- name: "X-Release-Blocker"
color: "ff7979"
- name: "X-Spec-Changes"
description: "May require spec changes"
color: "ff7979"
- name: "X-Won't-Fix"
description: "This will not be worked on"
color: "ff7979"
- name: "Z-Community-Testing"
description: "Issues found during the community testing sessions"
color: "efefef"
- name: "Z-Could"
color: "ededed"
- name: "Z-Design"
color: "ededed"
- name: "Z-Flaky-Test"
color: "aaaaaa"
- name: "Z-Media-Failure"
description: "Someone's audio or video isn't coming through"
color: "ededed"
- name: "Z-Must"
color: "ededed"
- name: "Z-Platform-Specific"
color: "ededed"
- name: "Z-Power-Users"
color: "ededed"
- name: "Z-ProductPolish"
color: "aaaaaa"
- name: "Z-Should"
color: "ededed"
- name: "Z-Splitbrain"
description: "Someone who should be on the call isn't showing up"
color: "ededed"

View File

@@ -2,7 +2,7 @@ name: Lint, format & type check
on:
pull_request: {}
jobs:
lint:
prettier:
name: Lint, format & type check
runs-on: ubuntu-latest
steps:
@@ -20,12 +20,12 @@ jobs:
- 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"
- name: Formatting
run: "pnpm run format:check"
- name: Prettier
run: "pnpm run prettier:check"
- name: i18n
run: "pnpm run i18n:check"
- name: Lint
run: "pnpm run lint:oxlint"
- name: ESLint
run: "pnpm run lint:eslint"
- name: Type check
run: "pnpm run lint:types"
- name: Dead code analysis

View File

@@ -1,23 +0,0 @@
name: Sync labels
on:
workflow_dispatch: {}
push:
branches:
- livekit
paths:
- .github/labels.yml
- .github/workflows/sync-labels.yml
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
sync-labels:
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
with:
LABELS: |
.github/labels.yml
DELETE: true
WET: true
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

View File

@@ -40,8 +40,8 @@ jobs:
- name: Fix the owner of the downloaded files
run: "sudo chown runner:docker -R locales"
- name: Formatting
run: pnpm format
- name: Prettier
run: pnpm prettier:format
- name: Create Pull Request
id: cpr

View File

@@ -1,6 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
}

View File

@@ -1,153 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"import",
"jsdoc",
"jsx-a11y",
"promise",
"react",
"typescript",
"unicorn",
"vitest"
],
"jsPlugins": [
"eslint-plugin-storybook",
"eslint-plugin-element-call"
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "eslint-plugin-rxjs"
],
"categories": {
"correctness": "error",
"perf": "error"
},
"options": {
"denyWarnings": true,
"typeAware": true
},
"env": {
"builtin": true
},
"rules": {
"element-call/copyright-header": [
"error",
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
],
"element-call/no-observablescope-leak": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
// "error",
// {
// "names": {
// "^this$": false
// }
// }
// ],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"matrix-widget-api/src",
"matrix-widget-api/src/",
"matrix-widget-api/src/**",
"matrix-widget-api/lib",
"matrix-widget-api/lib/",
"matrix-widget-api/lib/**"
],
"message": "Please use matrix-widget-api instead"
},
{
"group": [
"matrix-js-sdk/src",
"matrix-js-sdk/src/",
"matrix-js-sdk/src/**",
"matrix-js-sdk/lib",
"matrix-js-sdk/lib/",
"matrix-js-sdk/lib/index"
],
"message": "Please use matrix-js-sdk instead"
}
]
}
],
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/promise-function-async": "error",
"typescript/require-await": "error",
"typescript/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk).
"typescript/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
// TODO: These had to be disabled in the eslint -> oxlint migration. Would be nice to
// enable them in future or at least document why we're disabling them.
"eslint/no-await-in-loop": "off",
"eslint/no-unused-vars": ["error", { "args": "none" }],
"import/default": "off",
"jsdoc/check-tag-names": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"promise/no-callback-in-promise": "off",
"react/jsx-key": "off",
"react/jsx-no-constructed-context-values": "off",
"react/no-array-index-key": "off",
"react/no-children-prop": "off",
"react/no-object-type-as-default-prop": "off",
"typescript/no-misused-spread": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/require-to-throw-message": "off"
},
"overrides": [
{
"files": ["src/*/**"],
"rules": {
// In application code we should use the js-sdk logger, never console directly.
"no-console": "error"
}
},
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**"
],
"rules": {
// Tests often initialize an ObservableScope in an outer scope in
// beforeEach, which is not actually a problem
"element-call/no-observablescope-leak": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/require-param-description": "off",
"jsx-a11y/media-has-caption": "off"
// TODO: Enable once oxlint supports them.
// "jsdoc/check-values": "off",
// "jsdoc/check-param-names": "off",
// "jsdoc/no-types": "off",
}
},
{
"files": ["playwright/**"],
"rules": {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off"
}
}
]
}

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
pnpm-lock.yaml
node_modules
dist

1
.prettierrc.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -359,16 +359,6 @@ Usage and other technical details about the project can be found here:
[**Docs**](./docs/README.md)
## GitHub Labels
GitHub labels in this repository are maintained in the [`labels.yml`](.github/labels.yml) file and
automatically synced to GitHub using the [`sync-labels` workflow](.github/workflows/sync-labels.yml).
We do this so that we can reuse the labels between repositories.
> [!WARNING]
> Do not manually edit labels in the GitHub UI. Any manual changes will be overridden by the
> workflow on its next invocation.
## 📝 Copyright & License
Copyright 2021-2025 New Vector Ltd

20
babel.config.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current",
},
},
],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
"@babel/preset-typescript",
],
plugins: ["babel-plugin-transform-vite-meta-env"],
};

View File

@@ -69,7 +69,7 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }

View File

@@ -1,66 +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 { ESLintUtils } from "@typescript-eslint/utils";
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "copyright-header",
meta: {
type: "problem",
fixable: "code",
docs: {
description: "Require a copyright header in files.",
},
messages: {
noHeader: "Copyright header is required.",
},
schema: [{ type: "string" }],
},
create(context) {
const code = context.getSourceCode();
return {
Program(node) {
const firstToken = code.getFirstToken(node, { includeComments: false });
if (!firstToken) {
return;
}
const headComments = code.getCommentsBefore(firstToken);
const hasSomeCopyrightHeader = headComments?.some((comment) =>
comment?.value?.includes("Copyright"),
);
if (hasSomeCopyrightHeader) {
return;
}
const headerTemplate = context.options[0];
const fix = headerTemplate
? function (fixer) {
return fixer.insertTextBefore(
firstToken,
headerTemplate.replace(
/%%CURRENT_YEAR%%/g,
new Date().getFullYear(),
),
);
}
: undefined;
context.report({
messageId: "noHeader",
node,
fix,
});
},
};
},
});
export default rule;

View File

@@ -1,66 +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 { ESLintUtils } from "@typescript-eslint/utils";
// These ObservableScope methods will not generally cause resource leaks even if
// called from a callback
const safeScopeMethods = ["bind", "end"];
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "no-observablescope-leak",
meta: {
type: "problem",
docs: {
description:
"Require referenced ObservableScopes to be defined in the very same scope to avoid resource leaks.",
},
messages: {
scopeLeak:
"Do not reference ObservableScopes defined in an outer scope; this may create resource leaks.",
},
schema: [],
},
create(context) {
return {
Identifier(node) {
const scope = context.sourceCode.getScope(node);
if (
// Is this a reference to a variable defined in an outer ("through") scope?
scope.through.some(
({ identifier }) => identifier.name === node.name,
) &&
// Exclude calls to "safe" ObservableScope methods
node.parent?.type === "MemberExpression" &&
node.parent.object === node &&
node.parent.property.type === "Identifier" &&
!safeScopeMethods.includes(node.parent.property.name) &&
/(^s|S)cope$/.test(node.name)
) {
// TODO: Once oxlint supports lint rules that rely on TypeScript type-awareness,
// Verify that the variable is actually of type ObservableScope rather than just
// checking its name. This is expensive so we should do this last.
//
// const services = ESLintUtils.getParserServices(context);
// const type = services.getTypeAtLocation(node);
// if (type.symbol?.name === "ObservableScope") { ... }
// This ObservableScope method call may be causing resource leaks.
context.report({
messageId: "scopeLeak",
loc: node.loc,
node,
});
}
},
};
},
});
export default rule;

View File

@@ -1,6 +0,0 @@
module.exports = {
rules: {
"copyright-header": require("./CopyrightHeader").default,
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
},
};

View File

@@ -1,4 +0,0 @@
{
"name": "eslint-plugin-element-call",
"version": "0.0.0"
}

View File

@@ -11,14 +11,12 @@ export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
},
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
entry: ["src/main.tsx", "i18next.config.ts"],
ignoreBinaries: [
// This is deprecated, so Knip doesn't actually recognize it as a globally
// installed binary. TODO We should switch to Compose v2:
// https://docs.docker.com/compose/migrate/
"docker-compose",
// This is a shell built-in.
"printf",
],
ignoreFiles: ["scripts/.pnpmfile.cjs"],
ignoreDependencies: [
@@ -32,9 +30,6 @@ export default {
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
"matrix-widget-api",
// Used by oxlint
"eslint-plugin-element-call",
"eslint-plugin-storybook",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;

View File

@@ -17,11 +17,11 @@
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk",
"serve": "vite preview",
"format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip",
"lint:oxlint": "oxlint src playwright",
"lint:oxlint-fix": "oxlint --fix src playwright",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
"lint:eslint": "eslint --max-warnings 0 src playwright",
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
"lint:knip": "knip",
"lint:types": "tsc",
"i18n": "npx i18next-cli extract",
@@ -40,6 +40,10 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@babel/preset-env": "^7.29.5",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@codecov/vite-plugin": "^1.3.0",
"@fontsource/inconsolata": "^5.1.0",
"@fontsource/inter": "^5.1.0",
@@ -60,11 +64,11 @@
"@storybook/addon-docs": "^10.3.6",
"@storybook/addon-vitest": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/eslint": "^9.6.1",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12",
@@ -74,18 +78,31 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/utils": "^8.61.0",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.7",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint-plugin-element-call": "link:eslint",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.9.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
"i18next": "^25.0.0",
@@ -101,13 +118,11 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.55.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.23.0",
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.374.0",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "19",
"react-dom": "19",
@@ -118,6 +133,7 @@
"sass": "^1.42.1",
"storybook": "^10.3.6",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^14.0.0",
"vaul": "^1.0.0",
@@ -131,5 +147,19 @@
"vitest": "^4.1.5",
"vitest-axe": "^1.0.0-pre.3"
},
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
"pnpm": {
"overrides": {
"@livekit/components-core>rxjs": "^7.8.1",
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18",
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1",
"esbuild": "^0.28.0",
"flatted": "^3.4.2",
"undici": "^6.24.0"
}
},
"packageManager": "pnpm@10.33.0"
}

View File

@@ -160,7 +160,6 @@ export class TestHelpers {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];

4183
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,3 @@
allowBuilds:
"@parcel/watcher": true
"@sentry/cli": true
"@swc/core": true
"core-js": true
"esbuild": true
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
"protobufjs": true
overrides:
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
# LiveKit has pinned it to a very specific version which is now holding us back from updating.
# See livekit/components-js#1101 for a request for a proper solution.
"@livekit/components-core>rxjs": "^7.8.1"
# Dedupe Mediapipe dependencies.
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18"
# Security fix: https://security-tracker.debian.org/tracker/CVE-2026-31802
"tar": "^7.5.11"
# Security fixes:
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
"minimatch": "^10.2.3"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
"glob": "^10.5.0"
# Security fixes:
# - https://github.com/element-hq/element-call/security/dependabot/110
# - https://github.com/element-hq/element-call/security/dependabot/122
"qs": "^6.14.1"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/106
"js-yaml": "^4.1.1"
# Storybook declares support for 0.27.0 only but empirically works fine with 0.28.0.
"esbuild": "^0.28.0"
# Multiple security fixes: https://github.com/nodejs/undici/releases/tag/v6.24.0
"undici": "^6.24.0"
# Security fix: https://github.com/advisories/GHSA-rf6f-7fwh-wjgh
"flatted": "^3.4.2"
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
onlyBuiltDependencies:
- "matrix-js-sdk"

View File

@@ -64,7 +64,7 @@
}
],
"semanticCommits": "disabled",
"ignoreDeps": [],
"ignoreDeps": ["eslint-plugin-matrix-org"],
"vulnerabilityAlerts": {
"schedule": ["at any time"],
"prHourlyLimit": 0,

View File

@@ -76,9 +76,9 @@ interface MatrixRTCSdk {
stop: () => void;
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/**
* flattened list of remote members
* flattened list of members
*/
remoteMembers$: Behavior<
members$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
@@ -86,7 +86,7 @@ interface MatrixRTCSdk {
}[]
>;
/**
* flattened local member
* flattened local members
*/
localMember$: Behavior<{
connection: Connection | null;
@@ -338,8 +338,8 @@ export async function createMatrixRTCSdk(
),
),
connected$: callViewModel.connected$,
remoteMembers$: scope.behavior(
callViewModel.remoteMatrixLivekitMembers$.pipe(
members$: scope.behavior(
callViewModel.matrixLivekitMembers$.pipe(
switchMap((members) => {
const listOfMemberObservables = members.map((member) =>
combineLatest([

View File

@@ -1,12 +1,6 @@
.bar {
flex-shrink: 0;
position: relative;
z-index: var(--call-view-header-footer-layer);
padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
}
/* Pseudo-element for the gradient background */
@@ -15,7 +9,8 @@
position: absolute;
inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */
inset-block: 0 -16px;
inset-block: -24px;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
@@ -23,157 +18,21 @@
);
}
.bar.hidden {
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the bar takes up no space in the layout
when hidden. */
position: absolute;
inset-block-start: 0;
inset-inline: 0;
}
.bar:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.bar > header {
flex-shrink: 0;
position: sticky;
inset-inline: 0;
inset-block-start: 0;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-rows:
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto)
var(--cpd-space-3x);
grid-template-areas:
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
column-gap: var(--cpd-space-2x);
block-size: 64px;
z-index: var(--call-view-header-footer-layer);
}
.bar:has(.subtitle) > header {
grid-template-rows:
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto) var(--cpd-space-5x)
minmax(var(--cpd-space-8x), auto);
grid-template-areas:
". . ."
"primaryButton title secondaryButton"
". . ."
"subtitle subtitle subtitle";
.bar svg path {
fill: var(--cpd-color-icon-primary);
}
/* Hide everything but the subtitle in small windows */
@media (max-height: 450px) {
.bar {
display: none;
}
.bar:has(.subtitle) {
display: initial;
.title,
.primaryButton,
.secondaryButton {
display: none;
}
> header {
grid-template-columns: 1fr;
grid-template-rows: var(--cpd-space-5x) minmax(var(--cpd-space-5x), auto);
grid-template-areas: "." "subtitle";
}
}
}
.primaryButton {
grid-area: primaryButton;
justify-self: start;
}
.title {
grid-area: title;
}
.subtitle {
grid-area: subtitle;
svg {
color: var(--cpd-color-icon-tertiary);
margin-inline-end: var(--cpd-space-2x);
block-size: 1.2em;
inline-size: 1.2em;
vertical-align: text-bottom;
}
}
.title,
.subtitle {
.bar > header > h1 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondaryButton {
grid-area: secondaryButton;
justify-self: end;
}
.primaryButton,
.secondaryButton {
svg * {
color: var(--cpd-color-icon-primary);
}
}
body[data-platform="ios"] {
.bar > header {
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
grid-template-areas: "primaryButton title secondaryButton";
}
.bar:has(.subtitle) > header {
grid-template-rows:
minmax(var(--cpd-space-6x), auto) minmax(var(--cpd-space-5x), auto)
var(--cpd-space-4x);
grid-template-areas:
"primaryButton title secondaryButton"
"primaryButton subtitle secondaryButton";
.title {
align-self: end;
/* Nudge the title and subtitle even closer together to replicate native
iOS styles */
transform: translateY(2px);
}
.subtitle {
align-self: start;
}
}
.subtitle {
color: var(--cpd-color-text-secondary);
svg {
display: none;
}
}
/* Hide everything but the subtitle in small windows */
@media (max-height: 450px) {
.bar:has(.subtitle) > header {
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
grid-template-areas: "." "subtitle";
}
.subtitle {
color: var(--cpd-color-text-primary);
}
}
}

View File

@@ -5,33 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type ReactNode } from "react";
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar, useAppBarSubtitle, useAppBarTitle } from "./AppBar";
const content = <p>This is the content.</p>;
function snapshotAppBar(content: ReactNode): void {
const { container } = render(
<TooltipProvider>
<AppBar>{content}</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
}
import { AppBar } from "./AppBar";
describe("AppBar", () => {
it("renders", () => snapshotAppBar(content));
it("renders with title and subtitle", () => {
const TestComponent: FC = () => {
useAppBarTitle("Title");
useAppBarSubtitle("Subtitle");
return content;
};
snapshotAppBar(<TestComponent />);
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
});

View File

@@ -6,34 +6,28 @@ Please see LICENSE in the repository root for full details.
*/
import {
createContext,
type FC,
type MouseEvent,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
createContext,
type FC,
type MouseEvent,
type ReactNode,
} from "react";
import classNames from "classnames";
import { Heading, IconButton, Text, Tooltip } from "@vector-im/compound-web";
import {
ArrowLeftIcon,
ChevronLeftIcon,
CollapseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
import styles from "./AppBar.module.css";
interface AppBarContext {
setTitle: (value: string) => void;
setSubtitle: (value: ReactNode) => void;
setSecondaryButton: (value: ReactNode) => void;
setPrimaryButtonIconKind: (value: "back" | "minimise") => void;
setHidden: (value: boolean) => void;
}
@@ -55,76 +49,44 @@ export const AppBar: FC<Props> = ({ children }) => {
}, []);
const [title, setTitle] = useState<string>("");
const [subtitle, setSubtitle] = useState<ReactNode>(undefined);
const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
null,
);
const [primaryButtonIcon, setPrimaryButtonIconKind] = useState<
"back" | "minimise"
>("minimise");
const context = useMemo(
() => ({
setTitle,
setSubtitle,
setSecondaryButton,
setHidden,
setPrimaryButtonIconKind,
}),
[
setTitle,
setSubtitle,
setHidden,
setSecondaryButton,
setPrimaryButtonIconKind,
],
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
);
const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon;
return (
<>
{/* Wrap the header in a div due to annoying z-index issues with the
gradient background */}
<div className={classNames(styles.bar, { [styles.hidden]: hidden })}>
<header>
<Tooltip label={t("common.back")}>
<IconButton
className={styles.primaryButton}
// We render the back button (PrimaryButtonIcon) the same size as the native os.
// We render the minimise icon (default) smaller as per designs.
size={primaryButtonIcon === "back" ? "32px" : "24px"}
onClick={onBackClick}
>
{primaryButtonIcon === "back" ? (
<BackIcon aria-hidden />
) : (
<div
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<Header
// App bar is mainly seen in the call view, which has its own
// 'reconnecting' toast
disconnectedBanner={false}
>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton size="24px" onClick={onBackClick}>
<CollapseIcon aria-hidden />
)}
</IconButton>
</Tooltip>
</IconButton>
</Tooltip>
</LeftNav>
{title && (
<Heading
className={styles.title}
type="body"
size={platform === "ios" ? "md" : "lg"}
weight={platform === "ios" ? "semibold" : "medium"}
size="lg"
weight={platform === "android" ? "medium" : "semibold"}
>
{title}
</Heading>
)}
{subtitle && (
<Text
className={styles.subtitle}
as="span"
size={platform === "ios" ? "sm" : "lg"}
>
{subtitle}
</Text>
)}
<div className={styles.secondaryButton}>{secondaryButton}</div>
</header>
<RightNav>{secondaryButton}</RightNav>
</Header>
</div>
<AppBarContext value={context}>{children}</AppBarContext>
</>
@@ -145,36 +107,6 @@ export function useAppBarTitle(title: string): void {
}, [title, setTitle]);
}
/**
* React hook which sets the subtitle to be shown in the app bar, if present. It
* is an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarSubtitle(subtitle: ReactNode): void {
const setSubtitle = use(AppBarContext)?.setSubtitle;
useEffect(() => {
if (setSubtitle !== undefined) {
setSubtitle(subtitle);
return (): void => setSubtitle("");
}
}, [subtitle, setSubtitle]);
}
/**
* React hook which sets the primary button icon kind. Can only be "minimise" or "back"
* It is an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarPrimaryButtonIconKind(
icon: "back" | "minimise",
): void {
const setIconKind = use(AppBarContext)?.setPrimaryButtonIconKind;
useEffect(() => {
if (setIconKind !== undefined) {
setIconKind(icon);
return (): void => setIconKind("minimise");
}
}, [setIconKind, icon]);
}
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.

View File

@@ -47,7 +47,7 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
};
interface ErrorPageProps {
error: unknown;
error: Error | unknown;
widget: WidgetHelpers | null;
}

View File

@@ -19,6 +19,7 @@ import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
import { redact } from "./utils/redact";
interface RoomIdentifier {
roomAlias: string | null;
@@ -494,7 +495,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"intent:",
intent,
"\nproperties:",
properties,
redact(properties, "password"),
"configuration:",
configuration,
);

View File

@@ -4,89 +4,43 @@ exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
>
<header>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8 primaryButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
<header
class="header"
>
<div
class="secondaryButton"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;
exports[`AppBar > renders with title and subtitle 1`] = `
<div>
<div
class="bar"
>
<header>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 primaryButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
<h1
class="_typography_6v6n8_153 _font-body-lg-medium_6v6n8_79 title"
>
Title
</h1>
<span
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 subtitle"
>
Subtitle
</span>
<div
class="secondaryButton"
class="nav leftNav"
>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
</div>
<div
class="nav rightNav"
/>
</header>
</div>

View File

@@ -173,7 +173,7 @@ export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
iconOnly
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
{...props}
kind={loudspeakerModeEnabled ? "secondary" : "primary"}
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
aria-checked={loudspeakerModeEnabled}
/>
</Tooltip>

View File

@@ -173,7 +173,9 @@ export function createCallFooterViewModel(
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
!isPip &&
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
showControls
? (): void => setSettingsOpen(true)
: undefined,
),

View File

@@ -272,7 +272,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("Should close widget when all other left and play a sound", async () => {
test("Should close widget when all other left and have time to play a sound", async () => {
const user = userEvent.setup();
let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
@@ -310,6 +310,8 @@ test("Should close widget when all other left and play a sound", async () => {
expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve();
// Expect the leave sound to be played but silent (volumeOverwrite = 0)
// The allOthersLeft effect should already play a leave sound for the last user in the call.
expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise;
await flushPromises();
@@ -317,6 +319,37 @@ test("Should close widget when all other left and play a sound", async () => {
expect(widgetStopMock).toHaveBeenCalledOnce();
}, 80000);
test("Should close widget when all other left", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();

View File

@@ -553,9 +553,11 @@ export const GroupCallView: FC<Props> = ({
});
}
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
}}
onError={
(/**error*/) => {
if (rtcSession.isJoined()) onLeft("error");
}
}
>
{body}
</GroupCallErrorBoundary>

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
beforeEach,
describe,
expect,
@@ -14,7 +15,12 @@ import {
type MockedFunction,
vi,
} from "vitest";
import { render, type RenderResult } from "@testing-library/react";
import {
render,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -44,6 +50,7 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget";
@@ -188,6 +195,45 @@ describe("InCallView", () => {
});
});
describe("settings button with AppBar header", () => {
beforeEach(() => {
// getUrlParams() reads window.location directly rather than from the
// React Router context, so MemoryRouter alone is not enough to make
// it see "header=app_bar". Push the real URL so both paths agree.
window.history.pushState({}, "", "?header=app_bar");
});
afterEach(() => {
window.history.pushState({}, "", "/");
});
it("mobile portrait, is visible in the header", () => {
createInCallView({
withAppBar: true,
callViewModelOptions: {
// Narrow like a mobile phone in portrait orientation
windowSize$: constant({ width: 400, height: 700 }),
},
});
getByRole(screen.getByRole("banner"), "button", {
name: "Settings",
});
});
it("mobile landscape, is not visible anywhere", () => {
const { queryByRole } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Flat like a mobile phone in landscape orientation
windowSize$: constant({ width: 700, height: 400 }),
},
});
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();

View File

@@ -44,6 +44,7 @@ import {
createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
@@ -68,24 +69,22 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import {
useAppBarHidden,
useAppBarSecondaryButton,
useAppBarSubtitle,
} from "../AppBar.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { constant } from "../state/Behavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
import { RingingStatus } from "../tile/RingingStatus.tsx";
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
declare module "react" {
interface CSSProperties {
@@ -241,6 +240,20 @@ export const InCallView: FC<InCallViewProps> = ({
const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$);
// Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache,
latencyHint: "interactive",
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -253,7 +266,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const ringingVm = useBehavior(vm.ringingVm$);
const ringing = useBehavior(vm.ringing$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
@@ -273,6 +286,22 @@ export const InCallView: FC<InCallViewProps> = ({
throw fatalCallError;
}
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current;
if (ringing && audio) {
const endSound = audio.playSoundLooping(
"waiting",
audio.soundDuration["waiting"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [ringing, latestPickupPhaseAudio]);
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps.
@@ -334,11 +363,6 @@ export const InCallView: FC<InCallViewProps> = ({
);
useAppBarHidden(!showHeader);
useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null;
switch (headerStyle) {
@@ -433,12 +457,6 @@ export const InCallView: FC<InCallViewProps> = ({
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
const showOutline = useBehavior(
model instanceof GridTileViewModel
? model.showOutline$
: constant(false),
);
return model instanceof GridTileViewModel ? (
<GridTile
@@ -451,8 +469,6 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
showOutline={showOutline}
focusable={!contentObscured}
/>
) : (
@@ -465,7 +481,6 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
@@ -500,7 +515,6 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -612,7 +626,6 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast}
{earpieceOverlay}
<ReactionsOverlay vm={vm} />

View File

@@ -11,10 +11,6 @@ import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import {
ArrowLeftIcon,
ChevronLeftIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -24,7 +20,6 @@ import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
import { AppBar } from "../AppBar";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
@@ -52,13 +47,6 @@ const mockClient = {
getDeviceId: () => "DEVICE",
} as Partial<MatrixClient> as MatrixClient;
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
const matrixInfo = {
userId: "@user:example.org",
displayName: "Test User",
@@ -72,32 +60,25 @@ const matrixInfo = {
function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
withAppBar = false,
platform = "android",
): ReturnType<typeof render> {
platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
const hideHeader = withAppBar ? true : false;
const lobbyView = (
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={hideHeader}
participantCount={3}
onShareClick={null}
{...props}
/>
);
return render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<TooltipProvider>
{withAppBar && <AppBar>{lobbyView}</AppBar>}
{!withAppBar && lobbyView}
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={false}
participantCount={3}
onShareClick={null}
{...props}
/>
</TooltipProvider>
</MediaDevicesContext>
</BrowserRouter>,
@@ -116,10 +97,9 @@ describe("LobbyView", () => {
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
const els = container.getElementsByClassName(headerStyles.header);
for (const el of els) {
expect(el).not.toBeVisible();
}
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeFalsy();
});
it("renders with waiting for invite state", () => {
@@ -128,52 +108,4 @@ describe("LobbyView", () => {
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
it("renders with AppBar android", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"android",
);
getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render(<ArrowLeftIcon />);
const expectedSvgPath = iconContainer
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
.querySelector(".primaryButton")
?.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
it("renders with AppBar ios", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"ios",
);
getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render(<ChevronLeftIcon />);
const expectedSvgPath = iconContainer
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
.querySelector(".primaryButton")
?.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@@ -51,7 +51,6 @@ import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
client: MatrixClient;
@@ -86,9 +85,8 @@ export const LobbyView: FC<Props> = ({
}, []);
const { t } = useTranslation();
usePageTitle(matrixInfo.roomName);
useAppBarPrimaryButtonIconKind("back");
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);

View File

@@ -1,59 +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 { expect, type MockedFunction, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { useAudioContext } from "../useAudioContext";
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
import { alice, aliceId } from "../utils/test-fixtures";
import { constant } from "../state/Behavior";
import { RingingAudioRenderer } from "./RingingAudioRenderer";
import { prefetchSounds } from "../soundUtils";
vi.mock("../useAudioContext");
vi.mock("../soundUtils");
test("ringtone plays on loop while ringing", () => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound: vi.fn(),
playSoundLooping,
soundDuration: {},
});
const pickupState$ = new BehaviorSubject<"ringing" | "timeout" | "decline">(
"ringing",
);
const vm = createRingingMedia({
id: aliceId,
userId: alice.userId,
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
intent: "audio",
pickupState$,
});
// Begin ringing
render(<RingingAudioRenderer vm={vm} muted={false} />);
expect(playSoundLooping).toHaveBeenCalledExactlyOnceWith(
"ringtone",
expect.any(Number),
);
expect(endSoundLooping).not.toHaveBeenCalled();
vi.clearAllMocks();
// End ringing
act(() => pickupState$.next("decline"));
expect(playSoundLooping).not.toHaveBeenCalled();
expect(endSoundLooping).toHaveBeenCalledExactlyOnceWith();
});

View File

@@ -1,72 +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 { useEffect, type FC } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
import { useInitial } from "../useInitial";
import { prefetchSounds } from "../soundUtils";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
import { useLatest } from "../useLatest";
interface RingingAudioRendererProps {
vm: RingingMediaViewModel | null;
muted: boolean;
}
export const RingingAudioRenderer: FC<RingingAudioRendererProps> = ({
vm,
muted,
}) => {
// Preload a waiting and decline sounds
const sounds = useInitial(async () => {
return prefetchSounds({
ringtone: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const audio = useAudioContext({
sounds,
latencyHint: "interactive",
muted,
});
return vm && <ActiveRingingAudioRenderer vm={vm} audio={audio} />;
};
interface ActiveRingingAudioRendererProps {
vm: RingingMediaViewModel;
audio: UseAudioContext<"ringtone"> | null;
}
const ActiveRingingAudioRenderer: FC<ActiveRingingAudioRendererProps> = ({
vm,
audio,
}) => {
const audio_ = useLatest(audio);
const pickupState = useBehavior(vm.pickupState$);
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
if (pickupState === "ringing" && audio_.current) {
const endSound = audio_.current.playSoundLooping(
"ringtone",
audio_.current.soundDuration["ringtone"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [pickupState, audio_]);
return null;
};

View File

@@ -1,467 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LobbyView > renders with AppBar android 1`] = `
<div>
<div
class="bar"
>
<header>
<button
aria-labelledby="_r_36_"
class="_icon-button_1215g_8 primaryButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
</button>
<div
class="secondaryButton"
/>
</header>
</div>
<div
class="inRoom"
>
<div
class="content"
>
<div
class="preview"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="avatarContainer"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="buttonBar"
>
<button
aria-disabled="true"
class="_button_1nw83_8 join wait"
data-kind="primary"
data-size="md"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_k9ljz_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
>
<button
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</div>
</button>
</div>
<div
class="buttons"
>
<button
aria-labelledby="_r_3h_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3r_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_40_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
exports[`LobbyView > renders with AppBar ios 1`] = `
<div>
<div
class="bar"
>
<header>
<button
aria-labelledby="_r_4a_"
class="_icon-button_1215g_8 primaryButton"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
class="secondaryButton"
/>
</header>
</div>
<div
class="inRoom"
>
<div
class="content"
>
<div
class="preview"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="avatarContainer"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="buttonBar"
>
<button
aria-disabled="true"
class="_button_1nw83_8 join wait"
data-kind="primary"
data-size="md"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_k9ljz_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
>
<button
aria-labelledby="_r_4g_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="buttons"
>
<button
aria-labelledby="_r_4l_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4q_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4v_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_54_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
exports[`LobbyView > renders with header and participant count 1`] = `
<div>
<div
@@ -630,7 +168,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
@@ -761,7 +299,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>

View File

@@ -253,7 +253,7 @@ describe("Test mappings", () => {
});
describe("Test select a device", () => {
it(`Switch to correct device`, () => {
it(`Switch to correct device `, () => {
withTestScheduler(({ cold, schedule, expectObservable, flush }) => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
cold("a", { a: FULL_DEVICE_LIST }),

View File

@@ -5,19 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { describe, it } from "vitest";
import {
EventType,
type IEvent,
type IRoomTimelineData,
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
import { withTestScheduler } from "../../utils/test";
import {
alice,
aliceRtcMember,
local,
localRtcMember,
@@ -25,10 +23,9 @@ import {
import {
type CallNotificationWrapper,
createCallNotificationLifecycle$,
type RingAttempt,
type Props as CallNotificationLifecycleProps,
} from "./CallNotificationLifecycle";
import { Epoch, trackEpoch } from "../ObservableScope";
import { constant } from "../Behavior";
import { trackEpoch } from "../ObservableScope";
function mockRingEvent(
eventId: string,
@@ -43,272 +40,311 @@ function mockRingEvent(
} as unknown as CallNotificationWrapper;
}
const defaultProps = {
memberships$: constant(new Epoch([])),
matrixRoomMembers$: constant(new Map([[alice.userId, alice]])),
receivedDecline$: NEVER,
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
function summarizeRingAttempts$(
ringAttempts$: Observable<RingAttempt>,
): Observable<
| { intent: RTCCallIntent; recipient: string }
| { outcome: "accept" | "decline" | "timeout" }
> {
return ringAttempts$.pipe(
mergeMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
map((outcome) => ({ outcome })),
startWith({ intent, recipient }),
),
),
);
}
test("no ring attempt when waitForCallPickup=false", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
options: { ...defaultProps.options, waitForCallPickup: false },
});
expectObservable(ringAttempts$).toBe("");
});
});
test("no ring attempt when notification type is not ring", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: {
...mockRingEvent("$notif1", 30),
notification_type: "notification",
},
}),
});
expectObservable(ringAttempts$).toBe("");
});
});
test("no ring attempt if lifetime is missing", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", undefined),
}),
});
expectObservable(ringAttempts$).toBe("");
});
});
test("ring attempt times out after nobody joins", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
describe("waitForCallPickup$", () => {
it("unknown -> ringing -> timeout when notified and nobody joins", () => {
withTestScheduler(({ scope, expectObservable, behavior, hot }) => {
// No one ever joins (only local user)
memberships$: constant(new Epoch([])),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", { a: [] }).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
});
});
});
test("ring attempt is accepted once recipient joins", () => {
withTestScheduler(({ scope, expectObservable, hot, behavior }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
memberships$: scope.behavior(
behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
it("ringing -> success if someone joins before timeout is reached", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 19ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("5ms a", {
a: mockRingEvent("$notif2", 100),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
a: "unknown",
b: "ringing",
c: "success",
});
});
});
});
test("ring attempt is immediately accepted if recipient is already joined", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
memberships$: constant(new Epoch([aliceRtcMember])),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
it("success when someone joins before we notify", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 9ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("20ms a", {
a: mockRingEvent("$notif2", 50),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "success",
});
});
});
it("notify without lifetime -> immediate timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif2", undefined),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "timeout",
});
});
});
});
test("ring attempt can be declined", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
it("stays null when waitForCallPickup=false", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const validProps: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a--b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif5", 30),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const propsDeactivated = {
...validProps,
options: {
...validProps.options,
waitForCallPickup: false,
},
};
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
expectObservable(lifecycle.callPickupState$).toBe("n", {
n: null,
});
const lifecycleReference = createCallNotificationLifecycle$(validProps);
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
u: "unknown",
s: "success",
});
});
});
it("decline before timeout window ends -> decline", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl1", 50),
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl1",
},
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "decline" },
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
});
});
});
});
test("ring attempt times out if recipient declines too late", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("100ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
it("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl", 20),
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl",
},
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
"a 9ms b 19ms e",
{
a: "unknown",
b: "ringing",
e: "timeout",
},
);
});
});
});
test("decline event relating to wrong event is ignored (times out)", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$other", // <---- WRONG
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});
test("decline event from wrong sender is ignored (times out)", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: local.userId, // <---- WRONG
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
//
function testStaysRinging(
declineEvent: Partial<IEvent>,
expectDecline: boolean,
): void {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$right", 50),
}),
receivedDecline$: hot("20ms d", {
d: [
new MatrixEvent(declineEvent),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
a: "unknown",
b: "ringing",
d: "decline",
});
});
}
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
event_id: "$decline",
type: EventType.RTCDecline,
sender: sender ?? "@other:example.org",
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: refId ?? "$right",
},
},
});
it("decline reference works", () => {
testStaysRinging(reference(), true);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference("$wrong"), false);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference(undefined, local.userId), false);
});
});

View File

@@ -10,22 +10,24 @@ import {
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
concat,
endWith,
filter,
fromEvent,
ignoreElements,
map,
merge,
NEVER,
type Observable,
of,
pairwise,
startWith,
switchMap,
takeUntil,
timer,
EMPTY,
race,
take,
} from "rxjs";
import {
type EventTimelineSetHandlerMap,
@@ -33,28 +35,18 @@ import {
type Room as MatrixRoom,
RoomEvent,
} from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
export interface RingAttempt {
intent: RTCCallIntent;
/**
* The user ID of the recipient being rung.
*/
recipient: string;
/**
* The eventual outcome of the ringing attempt. (Emits a single value.)
*/
// TODO: Include a callback for attempting ringing again in case of a timeout
outcome$: Observable<"accept" | "decline" | "timeout">;
}
export type CallPickupState =
| "unknown"
| "ringing"
| "timeout"
| "decline"
| "success"
| null;
export type CallNotificationWrapper = {
event_id: string;
@@ -84,7 +76,6 @@ export function createReceivedDecline$(
export interface Props {
scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>;
matrixRoomMembers$: Behavior<RoomMemberMap>;
sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
@@ -93,85 +84,34 @@ export interface Props {
localUser: { deviceId: string; userId: string };
}
/**
* @returns two observables:
* `callPickupState$` The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*
*/
export function createCallNotificationLifecycle$({
scope,
memberships$,
matrixRoomMembers$,
sentCallNotification$,
receivedDecline$,
options,
localUser,
}: Props): {
/**
* An observable of attempts to ring the remote participant's devices.
*/
ringAttempts$: Observable<RingAttempt>;
/**
* An observable that emits when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*/
callPickupState$: Behavior<CallPickupState>;
autoLeave$: Observable<AutoLeaveReason>;
} {
let ringAttempts$: Observable<RingAttempt> = NEVER;
if (options.waitForCallPickup)
ringAttempts$ = sentCallNotification$.pipe(
filter(
(
notificationEvent: CallNotificationWrapper | null,
): notificationEvent is CallNotificationWrapper =>
// only care about new events (legacy do not have decline pattern)
notificationEvent?.notification_type === "ring" &&
notificationEvent.lifetime > 0,
),
switchMap((notificationEvent) => {
// We assume that there is only one other user in the room when ringing
// TODO: Respect io.element.functional_members
const recipient = [...matrixRoomMembers$.value.keys()].find(
(userId) => userId !== localUser.userId,
);
if (recipient === undefined) {
logger.warn("No recipient for notification event; not ringing.");
return EMPTY;
}
// Ringing times out after lifetime ms have passed
const timeout$ = timer(notificationEvent.lifetime).pipe(
map(() => "timeout" as const),
);
// Call is accepted when the recipient joins
const accept$ = memberships$.pipe(
filter((ms) => ms.value.some((m) => m.userId === recipient)),
map(() => "accept" as const),
);
// Call is declined when we receive a decline event
const decline$ = receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() === recipient,
),
map(() => "decline" as const),
);
return of({
intent: notificationEvent["m.call.intent"] ?? "audio",
recipient,
outcome$: race(timeout$, accept$, decline$).pipe(
take(1),
// Make this observable 'hot' to avoid running multiple timers. This
// is not actually a resource leak since there will be at most one
// active ring attempt at any given time.
// eslint-disable-next-line element-call/no-observablescope-leak
scope.share,
),
});
}),
scope.share,
);
const allOthersLeft$ = memberships$.pipe(
pairwise(),
filter(
@@ -182,18 +122,87 @@ export function createCallNotificationLifecycle$({
map(() => {}),
);
/**
* Whether some Matrix user other than ourself is joined to the call.
*/
const someoneElseJoined$ = memberships$.pipe(
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
) as Behavior<Epoch<boolean>>;
/**
* The state of the current ringing attempt, if the RTC session is indeed
* ringing the remote participant's devices. Otherwise `null`.
*/
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
scope.behavior(
sentCallNotification$.pipe(
filter(
(notificationEventArgs: CallNotificationWrapper | null) =>
// only care about new events (legacy do not have decline pattern)
notificationEventArgs?.notification_type === "ring",
),
map((e) => e as CallNotificationWrapper),
switchMap((notificationEvent) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
of(null)
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
// This makes sure we will not drop into the `endWith("decline" as const)` state
NEVER,
).pipe(
takeUntil(
receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== localUser.userId &&
callPickupState$.value !== "timeout",
),
),
),
endWith("decline" as const),
);
}),
),
null,
);
const callPickupState$ = scope.behavior(
options.waitForCallPickup === true
? combineLatest(
[someoneElseJoined$, remoteRingState$],
(someoneElseJoined, ring) => {
if (someoneElseJoined.value === true) {
return "success" as const;
}
// Show the ringing state of the most recent ringing attempt.
// as long as we have not yet sent an RTC notification event or noone else joined,
// ring will be null -> callPickupState$ = unknown.
return ring ?? ("unknown" as const);
},
)
: NEVER,
null,
);
const autoLeave$ = merge(
options.autoLeaveWhenOthersLeft === true
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
: NEVER,
ringAttempts$.pipe(
switchMap(({ outcome$ }) =>
outcome$.pipe(
filter((outcome) => outcome === "timeout" || outcome === "decline"),
),
),
callPickupState$.pipe(
filter((state) => state === "timeout" || state === "decline"),
),
);
return { ringAttempts$, autoLeave$ };
return { autoLeave$, callPickupState$ };
}

View File

@@ -22,7 +22,6 @@ import { SyncState } from "matrix-js-sdk";
import {
ConnectionState,
type LocalTrackPublication,
type Participant,
type RemoteParticipant,
} from "livekit-client";
import * as ComponentsCore from "@livekit/components-core";
@@ -1032,10 +1031,6 @@ describe.each([
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
videoEnabled: new Map<Participant, Behavior<boolean>>([
[localParticipant, constant(true)],
[aliceParticipant, constant(true)],
]),
},
(vm) => {
schedule(modeInputMarbles, {
@@ -1065,33 +1060,6 @@ describe.each([
});
});
test("expanded spotlight layout hides PiP tile in one-on-one voice call", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
roomMembers: [local, alice],
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
videoEnabled: new Map<Participant, Behavior<boolean>>([
[localParticipant, constant(false)],
[aliceParticipant, constant(false)],
]),
windowSize$: constant({ width: 700, height: 380 }), // Mobile phone in landscape
},
(vm) => {
// Layout should show remote tile only
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
a: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: undefined,
},
});
},
);
});
});
test("spotlight remembers whether it's expanded", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// Start in spotlight mode, then switch to grid and back to spotlight a
@@ -1128,7 +1096,7 @@ describe.each([
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: undefined,
pip: `${localId}:0`,
},
c: {
type: "grid",
@@ -1452,14 +1420,8 @@ describe.each([
},
});
expectObservable(vm.ringingVm$).toBe("(ab)", {
a: null,
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
});
// Should ring for 30ms and then time out
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo);
// Layout should show placeholder media for the participant we're
// ringing the entire time (even once timed out)
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
@@ -1498,14 +1460,7 @@ describe.each([
});
// Should ring until Alice joins
expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", {
a: null,
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
});
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo);
// Layout should show placeholder media for the participant we're
// ringing the entire time
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {

View File

@@ -29,6 +29,7 @@ import {
pairwise,
race,
scan,
skipWhile,
startWith,
Subject,
switchAll,
@@ -38,7 +39,6 @@ import {
tap,
throttleTime,
timer,
takeUntil,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
@@ -125,9 +125,10 @@ import {
createConnectionManager$,
} from "./remoteMembers/ConnectionManager.ts";
import {
createRemoteMatrixLivekitMembers$,
createMatrixLivekitMembers$,
type LocalMatrixLivekitMember,
type RemoteMatrixLivekitMember,
type MatrixLivekitMember,
} from "./remoteMembers/MatrixLivekitMembers.ts";
import {
type AutoLeaveReason,
@@ -155,7 +156,6 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
const logger = rootLogger.getChild("[CallViewModel]");
//TODO
@@ -230,13 +230,9 @@ export interface CallViewModel {
// lifecycle
autoLeave$: Observable<AutoLeaveReason>;
/**
* View model for info relating to ringing, timing out, calling back, etc.
* Whether we are ringing a call recipient.
*/
ringingVm$: Behavior<RingingMediaViewModel | null>;
/**
* Which visual element the ringing status should be shown in.
*/
ringingStatusLocation: "app_bar" | "tile";
ringing$: Behavior<boolean>;
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope
@@ -304,7 +300,7 @@ export interface CallViewModel {
/** Participants sorted by livekit room so they can be used in the audio rendering */
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
/** use the layout instead, this is just for the sdk export. */
remoteMatrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
/** List of participants raising their hand */
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
@@ -531,15 +527,13 @@ export function createCallViewModel$(
ownMembershipIdentity,
});
const remoteMatrixLivekitMembers$: Behavior<
Epoch<RemoteMatrixLivekitMember[]>
> = createRemoteMatrixLivekitMembers$({
scope: scope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager,
localUser: { userId, deviceId },
});
const matrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>> =
createMatrixLivekitMembers$({
scope: scope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager,
});
const connectOptions$ = scope.behavior(
matrixRTCMode$.pipe(
@@ -616,12 +610,19 @@ export function createCallViewModel$(
),
);
const matrixLivekitMembers$ = scope.behavior(
combineLatest(
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
),
);
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
scope: scope,
memberships$: memberships$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options: options,
localUser: { userId: userId, deviceId: deviceId },
});
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
@@ -633,26 +634,11 @@ export function createCallViewModel$(
matrixRoomMembers$,
);
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
scope,
memberships$,
matrixRoomMembers$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options,
localUser: { userId, deviceId },
});
const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior(
remoteMatrixLivekitMembers$.pipe(
matrixLivekitMembers$.pipe(
switchMap((members) => {
const a$ = combineLatest(
members.value.map((member) =>
@@ -718,20 +704,43 @@ export function createCallViewModel$(
* List of user media (camera feeds) that we want tiles for.
*/
const userMedia$ = scope.behavior<WrappedUserMediaViewModel[]>(
combineLatest([matrixLivekitMembers$, duplicateTiles.value$]).pipe(
combineLatest([
localMatrixLivekitMember$,
matrixLivekitMembers$,
duplicateTiles.value$,
]).pipe(
// Generate a collection of user media from the list of expected (whether
// present or missing) LiveKit participants.
generateItems(
"CallViewModel userMedia$",
function* ([members, duplicateTiles]) {
for (const {
userId,
participant,
connection$,
membership$,
} of members) {
const rtcId = membership$.value.rtcBackendIdentity;
const mediaId = `${userId}:${membership$.value.deviceId}`;
function* ([
localMatrixLivekitMember,
matrixLivekitMembers,
duplicateTiles,
]) {
const computeMediaId = (m: MatrixLivekitMember): string =>
`${m.userId}:${m.membership$.value.deviceId}`;
const localUserMediaId = localMatrixLivekitMember
? computeMediaId(localMatrixLivekitMember)
: undefined;
const localAsArray = localMatrixLivekitMember
? [localMatrixLivekitMember]
: [];
const remoteWithoutLocal = matrixLivekitMembers.value.filter(
(m) => computeMediaId(m) !== localUserMediaId,
);
const allMatrixLivekitMembers = [
...localAsArray,
...remoteWithoutLocal,
];
for (const matrixLivekitMember of allMatrixLivekitMembers) {
const { userId, participant, connection$, membership$ } =
matrixLivekitMember;
const rtcId = membership$.value.rtcBackendIdentity; // rtcBackendIdentity
const mediaId = computeMediaId(matrixLivekitMember);
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [dup, mediaId, userId, participant, connection$, rtcId],
@@ -775,42 +784,51 @@ export function createCallViewModel$(
),
);
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
ringAttempts$.pipe(
switchMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
startWith("ringing" as const),
generateItems(
"CallViewModel ringingMedia$",
function* (pickupState) {
if (pickupState !== "accept")
yield { keys: [intent, recipient], data: pickupState };
},
(scope, pickupState$, intent, userId) =>
createRingingMedia({
id: `ringing:${userId}`,
userId,
displayName$: scope.behavior(
matrixRoomMembers$.pipe(
map(
(members) =>
members.get(userId)?.rawDisplayName || userId,
),
),
),
mxcAvatarUrl$:
matrixMemberMetadataStore.createAvatarUrlBehavior$(
scope,
userId,
),
pickupState$,
intent,
}),
),
map(([media]) => media ?? null),
),
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
generateItems(
"CallViewModel ringingMedia$",
function* ([userMedia, roomMembers, callPickupState]) {
if (
callPickupState === "ringing" ||
callPickupState === "timeout" ||
callPickupState === "decline"
) {
// TODO: Respect io.element.functional_members
for (const member of roomMembers.values()) {
if (!userMedia.some((vm) => vm.userId === member.userId))
yield {
keys: [member.userId],
data: callPickupState,
};
}
}
},
(scope, pickupState$, userId) =>
createRingingMedia({
id: `ringing:${userId}`,
userId,
displayName$: scope.behavior(
matrixRoomMembers$.pipe(
map((members) => members.get(userId)?.rawDisplayName || userId),
),
),
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$(
scope,
userId,
),
pickupState$,
muteStates,
}),
),
startWith(null),
distinctUntilChanged(shallowEquals),
tap((ringingMedia) => {
if (ringingMedia.length > 1)
// Warn that UI may do something unexpected in this case
logger.warn(
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
);
}),
),
);
@@ -849,10 +867,14 @@ export function createCallViewModel$(
* multiple devices.
*/
const participantCount$ = scope.behavior(
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
);
const leaveSoundEffect$ = userMedia$.pipe(
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
pairwise(),
filter(
([prev, current]) =>
@@ -861,9 +883,6 @@ export function createCallViewModel$(
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
// Avoid doubling up on any auto-leave sounds (e.g. the decline sound),
// which are handled elsewhere
takeUntil(autoLeave$),
);
const userHangup$ = new Subject<void>();
@@ -942,8 +961,8 @@ export function createCallViewModel$(
);
/**
* Local user media suitable for displaying in a PiP (undefined if not found,
* video is muted, or if user prefers to not see themselves).
* Local user media suitable for displaying in a PiP (undefined if not found
* or if user prefers to not see themselves).
*/
const localUserMediaForPip$ = scope.behavior<
LocalUserMediaViewModel | undefined
@@ -955,10 +974,8 @@ export function createCallViewModel$(
m.type === "user" && m.local,
);
if (!localUserMedia) return of(undefined);
return combineLatest(
[localUserMedia.videoEnabled$, localUserMedia.alwaysShow$],
(videoEnabled, alwaysShow) =>
videoEnabled && alwaysShow ? localUserMedia : undefined,
return localUserMedia.alwaysShow$.pipe(
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
);
}),
),
@@ -970,8 +987,8 @@ export function createCallViewModel$(
}>(
ringingMedia$.pipe(
switchMap((ringingMedia) => {
if (ringingMedia !== null)
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
if (ringingMedia.length > 0)
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
return screenShares$.pipe(
switchMap((screenShares) => {
@@ -1127,10 +1144,14 @@ export function createCallViewModel$(
// show ringing media instead
if (userMedia.length === 1)
return ringingMedia$.pipe(
map(
(ringingMedia) =>
ringingMedia && { local, remote: ringingMedia },
),
map((ringingMedia) => {
return ringingMedia.length === 1
? {
local,
remote: ringingMedia[0],
}
: null;
}),
);
}
}
@@ -1427,7 +1448,7 @@ export function createCallViewModel$(
windowMode$.pipe(
switchMap((mode) => {
// In small windows the header would be too obstructive
if (mode === "pip") return of(false);
if (mode === "pip" || mode === "flat") return of(false);
// In edge-to-edge layouts, couple the visibility of the header
// to that of the footer
return edgeToEdge$.pipe(
@@ -1485,7 +1506,6 @@ export function createCallViewModel$(
({ tiles: prevTiles }, [media, visibleTiles]) => {
let layout: Layout;
let newTiles: TileStore;
let pip: GridTileViewModel | undefined;
switch (media.type) {
case "grid":
case "spotlight-landscape":
@@ -1511,7 +1531,6 @@ export function createCallViewModel$(
landscapePipAlignment$,
prevTiles,
);
pip = layout.pip;
break;
case "one-on-one-portrait":
[layout, newTiles] = oneOnOnePortraitLayout(
@@ -1520,17 +1539,12 @@ export function createCallViewModel$(
portraitPipAlignment$,
prevTiles,
);
pip = layout.pip;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
break;
}
for (const tile of newTiles.gridTiles) {
tile.setShowOutline(tile === pip);
}
return { layout, tiles: newTiles };
},
{ layout: null, tiles: TileStore.empty() },
@@ -1714,9 +1728,9 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringingVm$: ringingMedia$,
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
ringing$: scope.behavior(
callPickupState$.pipe(map((state) => state === "ringing")),
),
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
@@ -1761,8 +1775,8 @@ export function createCallViewModel$(
setGridMode: setGridMode,
layout$: layout$,
localMatrixLivekitMember$,
remoteMatrixLivekitMembers$: scope.behavior(
remoteMatrixLivekitMembers$.pipe(
matrixLivekitMembers$: scope.behavior(
matrixLivekitMembers$.pipe(
map((members) => members.value),
tap((v) => {
const listForLogs = v

View File

@@ -170,7 +170,7 @@ export const createLocalMembership$ = ({
logger: parentLogger,
muteStates,
matrixRTCSession,
roomId,
roomId: roomId,
}: Props): {
/**
* This request to start audio and video tracks.

View File

@@ -102,7 +102,7 @@ export class RtcTransportAutoDiscovery {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(isLivekitTransportConfig);
const first = transportList.filter(isLivekitTransportConfig)[0];
if (first) {
return first;
} else {

View File

@@ -15,7 +15,7 @@ import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts";
import {
type RemoteMatrixLivekitMember,
createRemoteMatrixLivekitMembers$,
createMatrixLivekitMembers$,
} from "./MatrixLivekitMembers.ts";
import {
Epoch,
@@ -31,7 +31,6 @@ import {
} from "../../../utils/test.ts";
import { type Connection } from "./Connection.ts";
import { constant } from "../../Behavior.ts";
import { localRtcMember } from "../../../utils/test-fixtures.ts";
let testScope: ObservableScope;
@@ -89,17 +88,16 @@ test("should signal participant not yet connected to livekit", async () => {
mockConnectionManagerData$,
);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -159,17 +157,16 @@ test("should signal participant on a connection that is publishing", async () =>
constant(dataWithPublisher),
);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -200,16 +197,15 @@ test("should signal participant on a connection that is not publishing", async (
constant(dataWithPublisher),
);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -249,16 +245,15 @@ describe("Publication edge case", () => {
constant(connectionWithPublisher),
);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
expect(matrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -308,17 +303,16 @@ test("bob is publishing in the wrong connection", async () => {
connectionsWithPublisher$,
);
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
expect(matrixLivekitMember$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);

View File

@@ -62,9 +62,7 @@ interface Props {
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
>;
connectionManager: IConnectionManager;
localUser: { deviceId: string; userId: string };
}
/**
* Combines MatrixRTC and Livekit worlds.
*
@@ -75,14 +73,13 @@ interface Props {
* - out (via public Observable):
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
*/
export function createRemoteMatrixLivekitMembers$({
export function createMatrixLivekitMembers$({
scope,
membershipsWithTransport$,
connectionManager,
localUser,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
/**
* Behavior of all the remote call members and their associated livekit data (if available).
* Stream of all the call members and their associated livekit data (if available).
*/
return scope.behavior(
combineLatest([
@@ -94,19 +91,12 @@ export function createRemoteMatrixLivekitMembers$({
),
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
generateItemsWithEpoch(
"RemoteMatrixLivekitMembers",
"MatrixLivekitMembers",
// Generator function.
// creates an array of `{key, data}[]`
// Each change in the keys (new key) will result in a call to the factory function.
function* ([membershipsWithTransport, managerData]) {
for (const { membership, transport } of membershipsWithTransport) {
// Exclude the local membership
if (
membership.userId === localUser.userId &&
membership.deviceId === localUser.deviceId
)
continue;
const participants = transport
? managerData.getParticipantsForTransport(transport)
: [];

View File

@@ -29,13 +29,13 @@ import {
import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import {
areLivekitTransportsEqual,
createRemoteMatrixLivekitMembers$,
createMatrixLivekitMembers$,
type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { constant } from "../../Behavior.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -130,15 +130,14 @@ test("bob, carl, then bob joining no tracks yet", () => {
ownMembershipIdentity: ownMemberMock,
});
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager,
localUser: localRtcMember,
});
expectObservable(remoteMatrixLivekitMembers$).toBe(vMarble, {
expectObservable(matrixLivekitMembers$).toBe(vMarble, {
a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
const items = e.value;
expect(items.length).toBe(1);

View File

@@ -1,162 +0,0 @@
/*
Copyright 2026 Element Corp.
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 Observable, of } from "rxjs";
import { ObservableScope } from "./ObservableScope";
import { constant } from "./Behavior";
import { type SelectedAudioOutputDevice } from "./MediaDevices";
import {
availableOutputDevices$,
type Controls,
type OutputDevice,
outputDevice$,
} from "../controls";
import {
EARPIECE_CONFIG_ID,
IOSControlledAudioOutput,
} from "./IOSControlledAudioOutput";
// `vi.mock` calls are hoisted above all imports, so the static imports below
// already see these mocks. Force the iOS platform so that the virtual earpiece
// is available, and stub the livekit device observer (only subscribed for its
// side effects).
vi.mock("../Platform", () => ({ platform: "ios" }));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: (): Observable<MediaDeviceInfo[]> => of([]),
}));
// On iOS the host reports a single device for the current route. When output is
// on the loudspeaker it is flagged `forEarpiece`, which makes the controller
// expose a virtual earpiece device.
const SPEAKER: OutputDevice = {
id: "speaker",
name: "Speaker",
isSpeaker: true,
forEarpiece: true,
};
// A connected headset (e.g. Bluetooth) is reported as a plain named device,
// with neither the speaker nor earpiece flag set.
const HEADSET: OutputDevice = {
id: "bt",
name: "AirPods",
};
let testScope: ObservableScope;
beforeEach(() => {
testScope = new ObservableScope();
window.controls = {
onAudioDeviceSelect: vi.fn(),
onOutputDeviceSelect: vi.fn(),
} as unknown as Controls;
});
afterEach(() => {
testScope.end();
});
/**
* Subscribe to the controller's `selected$` and return a getter for the latest
* emitted value.
*/
function latestSelection(
output: InstanceType<typeof IOSControlledAudioOutput>,
): () => SelectedAudioOutputDevice | undefined {
let latest: SelectedAudioOutputDevice | undefined;
output.selected$.subscribe((s) => {
latest = s;
});
return () => latest;
}
describe("Default selection", () => {
it("defaults to the earpiece for voice (audio) calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
expect(window.controls.onAudioDeviceSelect).toHaveBeenLastCalledWith(
EARPIECE_CONFIG_ID,
);
});
it("defaults to the speaker for video calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"video",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("keeps a headset for voice calls instead of forcing the earpiece", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
// The host proposes the headset as the route (listed first), even though a
// forEarpiece device is also present so the virtual earpiece exists.
availableOutputDevices$.next([HEADSET, SPEAKER]);
expect(selected()).toEqual({ id: HEADSET.id, virtualEarpiece: false });
});
});
describe("Explicit selection", () => {
it("an explicit user selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
// Earpiece by default for a voice call...
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
// ...until the user explicitly picks the speaker.
output.select(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("a host selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
outputDevice$.next(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
});

View File

@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
availableOutputDevices$ as controlledAvailableOutputDevices$,
outputDevice$ as controlledOutputSelection$,
@@ -25,7 +24,7 @@ import {
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
export const EARPIECE_CONFIG_ID = "earpiece-id";
const EARPIECE_CONFIG_ID = "earpiece-id";
/**
* A special implementation of audio output that allows the hosting application
@@ -95,7 +94,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
],
(available, preferredId) => {
const id = preferredId ?? this.chooseDefaultId(available);
const id = preferredId ?? available.keys().next().value;
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
@@ -107,41 +106,9 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
);
/**
* Chooses the default output device when no explicit selection (from the user
* or the hosting application) has been made yet.
*
* For voice calls (`initialIntent === "audio"`) we want to start on the
* earpiece rather than the speaker, like a regular phone call. We only
* override when the device that would otherwise be the default is the
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
* is reported as a plain named device, not "speaker"/"earpiece" — we keep it.
* This mirrors the Android behaviour in {@link AndroidControlledAudioOutput}.
*/
private chooseDefaultId(
available: Map<string, AudioOutputDeviceLabel>,
): string | undefined {
const firstId = available.keys().next().value;
if (this.initialIntent === "audio") {
const firstLabel =
firstId !== undefined ? available.get(firstId) : undefined;
if (firstLabel?.type === "speaker") {
for (const [id, label] of available)
if (label.type === "earpiece") {
this.logger.info(
`IOS routing: default to earpiece ${id} instead of speaker for voice call`,
);
return id;
}
}
}
return firstId;
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
private readonly initialIntent: RTCCallIntent | undefined = undefined,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.

View File

@@ -376,11 +376,7 @@ export class MediaDevices {
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
)
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =

View File

@@ -131,9 +131,9 @@ export class TileStoreBuilder {
private numGridEntries = 0;
// A sparse array of grid entries which should be kept in the same spots as
// which they appeared in the previous grid
private readonly stationaryGridEntries: GridTileData[] = Array.from({
length: this.prevGrid.length,
});
private readonly stationaryGridEntries: GridTileData[] = new Array(
this.prevGrid.length,
);
// Grid entries which should now enter the visible section of the grid
private readonly visibleGridEntries: GridTileData[] = [];
// Grid entries which should now enter the invisible section of the grid

View File

@@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type MediaViewModel } from "./media/MediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
@@ -19,18 +17,12 @@ function createId(): string {
export class GridTileViewModel {
public readonly id = createId();
private readonly _showOutline$ = new BehaviorSubject(false);
public readonly showOutline$: Behavior<boolean> = this._showOutline$;
public constructor(
public readonly media$: Behavior<
UserMediaViewModel | RingingMediaViewModel
>,
) {}
public setShowOutline(value: boolean): void {
this._showOutline$.next(value);
}
}
export class SpotlightTileViewModel {

View File

@@ -92,7 +92,6 @@ export function createMemberMedia(
}: MemberMediaInputs,
): BaseMemberMediaViewModel {
const trackBehavior$ = (
scope: ObservableScope,
source: Track.Source,
): Behavior<TrackReference | undefined> =>
scope.behavior(
@@ -103,8 +102,8 @@ export function createMemberMedia(
),
);
const audio$ = trackBehavior$(scope, audioSource);
const video$ = trackBehavior$(scope, videoSource);
const audio$ = trackBehavior$(audioSource);
const video$ = trackBehavior$(videoSource);
return {
...createBaseMedia(inputs),

View File

@@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { type Behavior } from "../Behavior";
import { type MuteStates } from "../MuteStates";
import {
type BaseMediaInputs,
type BaseMediaViewModel,
@@ -21,23 +20,32 @@ import {
export interface RingingMediaViewModel extends BaseMediaViewModel {
type: "ringing";
pickupState$: Behavior<"ringing" | "timeout" | "decline">;
intent: RTCCallIntent;
/**
* Whether this media would be expected to have video, were it not simply a
* placeholder.
*/
videoEnabled$: Behavior<boolean>;
}
export interface RingingMediaInputs extends BaseMediaInputs {
pickupState$: Behavior<"ringing" | "timeout" | "decline">;
intent: RTCCallIntent;
/**
* The local user's own mute states.
*/
muteStates: MuteStates;
}
export function createRingingMedia({
pickupState$,
intent,
muteStates,
...inputs
}: RingingMediaInputs): RingingMediaViewModel {
return {
...createBaseMedia(inputs),
type: "ringing",
pickupState$,
intent,
// If our own video is enabled, then this is a video call and we would
// expect remote media to have video as well
videoEnabled$: muteStates.video.enabled$,
};
}

View File

@@ -66,11 +66,6 @@ borders don't support gradients */
opacity: 1;
}
.tile.outline {
outline: var(--cpd-border-width-1) solid
var(--cpd-color-border-interactive-secondary);
}
@media (hover: hover) {
.tile:hover {
outline: var(--cpd-border-width-2) solid

View File

@@ -26,6 +26,7 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../state/media/RingingMediaViewModel";
import { type MuteStates } from "../state/MuteStates";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -77,8 +78,6 @@ test("GridTile is accessible", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
@@ -94,8 +93,10 @@ test("GridTile displays ringing media", async () => {
>("ringing");
const vm = createRingingMedia({
pickupState$,
muteStates: {
video: { enabled$: constant(false) },
} as unknown as MuteStates,
id: "test",
intent: "audio",
userId: "@alice:example.org",
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
@@ -110,8 +111,6 @@ test("GridTile displays ringing media", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,

View File

@@ -29,13 +29,15 @@ import {
UserProfileIcon,
VolumeOffSolidIcon,
SwitchCameraSolidIcon,
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ContextMenu,
MenuItem,
ToggleMenuItem,
Menu,
Text,
} from "@vector-im/compound-web";
import { useObservableEagerState } from "observable-hooks";
@@ -51,7 +53,6 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface TileProps {
ref?: Ref<HTMLDivElement>;
@@ -67,15 +68,17 @@ interface TileProps {
interface RingingMediaTileProps extends TileProps {
vm: RingingMediaViewModel;
showStatus: boolean;
}
const RingingMediaTile: FC<RingingMediaTileProps> = ({
vm,
showStatus,
className,
...props
}) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
const videoEnabled = useBehavior(vm.videoEnabled$);
return (
<MediaView
className={classNames(className, styles.tile)}
@@ -83,14 +86,14 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
userId={vm.userId}
unencryptedWarning={false}
status={
showStatus && (
<Text as="span" size="sm" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
pickupState === "ringing"
? {
text: t("video_tile.calling"),
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
}
avatarStyle="translucent"
videoEnabled={false}
videoEnabled={videoEnabled}
videoFit="cover"
mirror={false}
{...props}
@@ -397,8 +400,6 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
showOutline: boolean;
focusable: boolean;
}
@@ -406,10 +407,7 @@ export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
showSpeakingIndicators,
showRingingStatus,
showOutline,
onOpenProfile,
className,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
@@ -425,8 +423,6 @@ export const GridTile: FC<GridTileProps> = ({
vm={media}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -439,7 +435,6 @@ export const GridTile: FC<GridTileProps> = ({
onOpenProfile={onOpenProfile}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -451,7 +446,6 @@ export const GridTile: FC<GridTileProps> = ({
showSpeakingIndicators={showSpeakingIndicators}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);

View File

@@ -54,12 +54,11 @@ Please see LICENSE in the repository root for full details.
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 100%;
transition: opacity 0.2s;
}
.avatar[data-style="translucent"] {
.translucent {
opacity: 50%;
mix-blend-mode: multiply;
}
/* CSS makes us put a condition here, even though all we want to do is
@@ -122,18 +121,18 @@ unconditionally select the container so we can use cqmin units */
.status {
grid-area: status;
color: var(--cpd-color-text-primary);
display: flex;
flex-wrap: none;
align-items: center;
gap: 3px;
user-select: none;
overflow: hidden;
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
}
svg {
color: var(--cpd-color-icon-tertiary);
vertical-align: text-bottom;
margin-inline-end: 3px;
block-size: 1.2em;
inline-size: 1.2em;
}
.status svg {
color: var(--cpd-color-icon-tertiary);
}
.reactions {

View File

@@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details.
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web";
import { type FC, type ComponentProps, type ReactNode } from "react";
import {
type FC,
type ComponentProps,
type ReactNode,
type ComponentType,
type SVGAttributes,
} from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react";
@@ -37,12 +43,11 @@ interface Props extends ComponentProps<typeof animated.div> {
userId: string;
videoEnabled: boolean;
unencryptedWarning: boolean;
status?: ReactNode;
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
showNameTags: boolean;
nameTagLeadingIcon?: ReactNode;
displayName: string;
mxcAvatarUrl: string | undefined;
avatarStyle?: "solid" | "translucent";
focusable: boolean;
primaryButton?: ReactNode;
raisedHandTime?: Date;
@@ -72,7 +77,6 @@ export const MediaView: FC<Props> = ({
nameTagLeadingIcon,
displayName,
mxcAvatarUrl,
avatarStyle = "solid",
focusable,
primaryButton,
status,
@@ -126,8 +130,11 @@ export const MediaView: FC<Props> = ({
name={displayName}
size={avatarSize}
src={mxcAvatarUrl}
data-style={avatarStyle}
className={styles.avatar}
className={classNames(styles.avatar, {
// When the avatar is overlaid with a status, make it translucent
// for readability
[styles.translucent]: status,
})}
style={{ display: video && videoEnabled ? "none" : "initial" }}
/>
{video?.publication !== undefined && (
@@ -173,7 +180,14 @@ export const MediaView: FC<Props> = ({
/>
</>
)}
{status && <div className={styles.status}>{status}</div>}
{status && (
<div className={styles.status}>
<status.Icon width={16} height={16} aria-hidden />
<Text as="span" size="sm" weight="medium">
{status.text}
</Text>
</div>
)}
{/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}>

View File

@@ -1,41 +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 { type FC } from "react";
import {
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
interface Props {
vm: RingingMediaViewModel;
}
export const RingingStatus: FC<Props> = ({ vm }) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
const Icon =
pickupState === "ringing"
? vm.intent === "video"
? VideoCallSolidIcon
: VoiceCallSolidIcon
: EndCallIcon;
return (
<>
<Icon aria-hidden />
{pickupState === "ringing"
? t("video_tile.calling")
: t("video_tile.call_ended")}
</>
);
};

View File

@@ -28,6 +28,7 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../state/media/RingingMediaViewModel";
import { type MuteStates } from "../state/MuteStates";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -65,7 +66,6 @@ test("SpotlightTile is accessible", async () => {
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable={true}
/>,
);
@@ -108,7 +108,6 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable
/>
</TooltipProvider>,
@@ -139,7 +138,6 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable
/>,
);
@@ -158,8 +156,10 @@ test("SpotlightTile displays ringing media", async () => {
>("ringing");
const vm = createRingingMedia({
pickupState$,
muteStates: {
video: { enabled$: constant(false) },
} as unknown as MuteStates,
id: "test",
intent: "audio",
userId: "@alice:example.org",
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
@@ -175,7 +175,6 @@ test("SpotlightTile displays ringing media", async () => {
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable={true}
/>,
);

View File

@@ -24,6 +24,9 @@ import {
VolumeOnIcon,
VolumeOffSolidIcon,
VolumeOnSolidIcon,
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs";
@@ -31,7 +34,7 @@ import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { Menu, MenuItem, Text } from "@vector-im/compound-web";
import { Menu, MenuItem } from "@vector-im/compound-web";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
@@ -53,7 +56,6 @@ import { type MediaViewModel } from "../state/media/MediaViewModel";
import { Slider } from "../Slider";
import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
@@ -202,26 +204,28 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
vm: RingingMediaViewModel;
showStatus: boolean;
}
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
vm,
showStatus,
...props
}) => {
const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$);
const videoEnabled = useBehavior(vm.videoEnabled$);
return (
<MediaView
video={undefined}
unencryptedWarning={false}
status={
showStatus && (
<Text as="span" size="md" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
pickupState === "ringing"
? {
text: t("video_tile.calling"),
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
@@ -242,7 +246,6 @@ interface SpotlightItemProps {
*/
targetHeight: number;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
@@ -258,7 +261,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth,
targetHeight,
showNameTags,
showRingingStatus,
focusable,
intersectionObserver$,
snap,
@@ -300,11 +302,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
};
return vm.type === "ringing" ? (
<SpotlightRingingMediaItem
vm={vm}
showStatus={showRingingStatus}
{...baseProps}
/>
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
);
@@ -388,7 +386,6 @@ interface Props {
targetHeight: number;
showIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
@@ -403,7 +400,6 @@ export const SpotlightTile: FC<Props> = ({
targetHeight,
showIndicators,
showNameTags,
showRingingStatus,
focusable = true,
className,
style,
@@ -514,7 +510,6 @@ export const SpotlightTile: FC<Props> = ({
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags}
focusable={focusable}
intersectionObserver$={intersectionObserver$}

View File

@@ -113,7 +113,7 @@ interface Props<S extends string> {
muted?: boolean;
}
export interface UseAudioContext<S extends string> {
interface UseAudioContext<S extends string> {
playSound(soundName: S, volumeOverwrite?: number): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/**

58
src/utils/redact.test.ts Normal file
View File

@@ -0,0 +1,58 @@
/*
Copyright 2026 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 { expect, test } from "vitest";
import { redact } from "./redact";
test("empty object", () => {
expect(redact({})).toEqual({});
});
test("no keys", () => {
expect(redact({ foo: "bar" })).toEqual({ foo: "bar" });
});
test("redact one key", () => {
expect(redact({ foo: "bar" }, "foo")).toEqual({ foo: "<redacted>" });
});
test("redact two keys", () => {
expect(redact({ foo: "bar", bar: "foo" }, "foo", "bar")).toEqual({
foo: "<redacted>",
bar: "<redacted>",
});
});
test("no redaction of unrelated keys", () => {
expect(redact({ foo: "bar", bar: "foo" }, "foo")).toEqual({
foo: "<redacted>",
bar: "foo",
});
});
test("no redaction of missing keys", () => {
expect(
redact({ foo: "bar" } as { foo: string; bar: string | undefined }, "bar"),
).toEqual({
foo: "bar",
});
});
test("no redaction of null values", () => {
expect(redact({ foo: "bar", bar: null }, "bar")).toEqual({
foo: "bar",
bar: null,
});
});
test("no redaction of undefined values", () => {
expect(redact({ foo: "bar", bar: undefined }, "bar")).toEqual({
foo: "bar",
bar: undefined,
});
});

25
src/utils/redact.ts Normal file
View File

@@ -0,0 +1,25 @@
/*
Copyright 2026 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* Redacts properties in the supplied object by replacing them with
* a constant value.
* @param obj Object in which to perform redaction
* @param keys Keys to be redacted in the object
* @returns A new object with the specified properties redacted
*/
export function redact<T extends object>(
obj: T,
...keys: (keyof T)[]
): Record<keyof T, unknown> {
const result: Record<keyof T, unknown> = { ...obj };
for (const key of keys)
if (key in result && result[key] != null) {
result[key] = "<redacted>";
}
return result;
}

View File

@@ -128,8 +128,8 @@ export function getBasicRTCSession(
/**
* Construct a basic CallViewModel to test components that make use of it.
* @param members - Room members to include in the call.
* @param initialRtcMemberships - RTC memberships to start with.
* @param members
* @param initialRtcMemberships
* @returns
*/
export function getBasicCallViewModelEnvironment(

View File

@@ -38,7 +38,7 @@
"livekit-client/dist/src/proto/livekit_models_pb": [
"./node_modules/@livekit/protocol/src/gen/livekit_models_pb.d.ts"
]
}
},
// TODO: Enable the following options later.
// "forceConsistentCasingInFileNames": true,
@@ -48,6 +48,8 @@
// "noPropertyAccessFromIndexSignature": true,
// "noUncheckedIndexedAccess": true,
// "noUnusedParameters": true,
"plugins": [{ "name": "typescript-eslint-language-service" }]
},
"include": [
"./src/**/*.ts",