Compare commits

...

85 Commits

Author SHA1 Message Date
Robin
11fadebb87 Fix test failures due to scoped class names 2026-07-08 17:06:25 +02:00
Robin
943ce5b853 Update test snapshots to use scoped class names 2026-07-08 17:06:09 +02:00
Robin
000ee66cfe Use vitest's default for scoped class names
It reduces confusion when reading snapshots if class names are still scoped to their respective CSS modules. It also discourages the use of class names in tests, which is a good thing. (https://testing-library.com/docs/guiding-principles)

Thanks to Johannes for the suggestion.
2026-07-08 17:03:51 +02:00
Johannes Marbach
58a8b7316e Merge pull request #4077 from element-hq/renovate/github-actions
Update GitHub Actions
2026-07-08 16:20:07 +02:00
renovate[bot]
1e6d3d3357 Update GitHub Actions 2026-07-08 14:02:37 +00:00
Timo
dc21233767 Merge pull request #4097 from element-hq/toger5/fix-playwright-related-to-voice-in-pip
Consider voice calls starting in pip for playwright tests
2026-07-08 15:42:50 +02:00
Timo K.
374f56651c Consider voice calls starting in pip for playwright tests 2026-07-08 13:19:23 +02:00
Timo
59999b9b37 Merge pull request #4059 from element-hq/toger5/remove-blur-on-mobile
Remove blur on mobile
2026-07-07 15:30:36 +08:00
Johannes Marbach
ad76969ce2 Merge pull request #4084 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.7.0
2026-07-06 09:03:10 +02:00
Johannes Marbach
c170aad364 Merge pull request #4085 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.20.0
2026-07-06 09:01:51 +02:00
renovate[bot]
9e8bebc3bf Update dependency livekit-client to v2.20.0 2026-07-06 02:54:08 +00:00
renovate[bot]
d5cdbfd4d1 Update dependency @vector-im/compound-web to v9.7.0 2026-07-06 02:53:46 +00:00
Johannes Marbach
9199dcb583 Merge pull request #4076 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-07-02 11:37:07 +02:00
Johannes Marbach
cbc4b77a4f Dismiss Element Web release announcements 2026-07-02 11:19:16 +02:00
Johannes Marbach
ffb881436a Eliminate @livekit/protocol 2026-07-02 08:47:07 +02:00
Johannes Marbach
9cf87efc6b Remove global-jsdom 2026-07-02 08:35:59 +02:00
Johannes Marbach
b71454d63a Bump mcr.microsoft.com/playwright 2026-07-02 08:35:40 +02:00
renovate[bot]
b0feb33ec4 Update all non-major dependencies 2026-07-01 00:31:28 +00:00
Robin
3837576a5f Merge pull request #4073 from element-hq/refine-scope-leak-lint
Fix some false positives flagged by ObservableScope leak lint rule
2026-06-29 14:57:50 +02:00
Johannes Marbach
eb5a097999 Merge pull request #4072 from element-hq/johannes/jest
Switch to jest-style assertions to please oxlint
2026-06-29 14:36:18 +02:00
Robin
db2d20ac56 Fix some false positives flagged by ObservableScope leak lint rule
The rule should only care about enclosing function/class scopes. For example if an ObservableScope is received as a parameter to a function and then simply used inside an 'if' block (technically a different scope), that's not a problem.
2026-06-29 14:33:23 +02:00
Johannes Marbach
f276c51772 Switch to jest-style assertions to please oxlint
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-29 14:32:49 +02:00
Johannes Marbach
ac2214ed77 Merge commit from fork
Restrain logging of URL properties
2026-06-29 14:21:05 +02:00
Timo
8fe7bb5329 Merge pull request #4066 from element-hq/toger5/matrixRTCMode-compatibility
Default to compatibilty mode (multi sfu) - non breaking, as multi-sfu is compatible with all versions younger 6 month
2026-06-29 19:41:23 +08:00
Timo K
8028a6aa24 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:11:26 +02:00
Timo K
5b4e8865e5 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:04:22 +02:00
Timo K
356d526116 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:02:26 +02:00
Johannes Marbach
cca69b8108 Merge pull request #4069 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.5.0
2026-06-29 08:00:57 +02:00
renovate[bot]
75235a7853 Update dependency @vector-im/compound-web to v9.5.0 2026-06-29 00:34:12 +00:00
Timo K
0421783ebe Update config.sample.json 2026-06-26 13:30:19 +02:00
Robin
a1be4ff0f9 Merge pull request #4068 from element-hq/landscape-voice-call
Hide local tile from mobile voice calls in landscape orientation
2026-06-26 11:22:25 +02:00
Robin
8ab7f89e36 Hide local tile from mobile voice calls in landscape orientation
To match the designs. Just like in portrait, it wouldn't contribute anything meaningful.
2026-06-25 20:29:24 +02:00
Valere Fedronic
ddcb0f9981 Merge pull request #4064 from element-hq/ios/default_voice_call_to_earpiece
fix(voice): Default to earpiece for voice only call
2026-06-25 16:32:59 +02:00
Timo K
6ca5ddda73 fix snapshot 2026-06-25 16:05:58 +02:00
Valere
41d0933d4d playwright: New toast 2026-06-25 15:55:08 +02:00
Johannes Marbach
5ad62c7097 Merge pull request #4053 from element-hq/johannes/oxlint
Switch from eslint to oxlint
2026-06-25 15:46:05 +02:00
Johannes Marbach
76714ac3ad Use the correct company name
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-25 15:29:00 +02:00
Timo K
15b40835b8 Default to compatibilty mode 2026-06-25 13:31:02 +02:00
Valere
dc5b541b21 fix(voice): Default to earpiece for voice only call 2026-06-25 13:10:27 +02:00
Johannes Marbach
f376ffb96b Don't redact null or undefined 2026-06-24 13:35:44 +02:00
Johannes Marbach
d5ce3a955a Port over copyright rule 2026-06-24 11:08:29 +02:00
Johannes Marbach
d2cb7e334e Remove duplicate paths config 2026-06-24 10:53:14 +02:00
Johannes Marbach
9d61ca13cb Use regex to test node name 2026-06-24 10:49:18 +02:00
Johannes Marbach
51bd17d335 Restrain logging of URL properties 2026-06-24 08:22:02 +02:00
Johannes Marbach
1cd2764eb2 Switch from eslint to oxlint 2026-06-23 15:13:46 +02:00
Johannes Marbach
3fb81032e0 Merge pull request #4054 from element-hq/johannes/pip-border
Add PiP border in 1on1 layouts
2026-06-23 12:38:24 +02:00
Johannes Marbach
da942ea900 Fix formatting 2026-06-23 12:19:40 +02:00
Johannes Marbach
d534c617d2 Eliminate noOutline constant 2026-06-23 11:42:14 +02:00
Robin
ded3d0ab81 Merge pull request #4061 from element-hq/subtitle-only
Show the subtitle of the app bar on small screens
2026-06-23 10:01:55 +02:00
Robin
a4b4a6b655 Remove redundant styles 2026-06-23 09:42:57 +02:00
Robin
be5d8140c9 Make it clearer why the app bar elements need to be hidden individually 2026-06-23 09:42:28 +02:00
Robin
7d54487354 Merge pull request #4060 from element-hq/speaker-color
Invert the colors of the speaker toggle button
2026-06-23 09:27:39 +02:00
Johannes Marbach
93fae5dd67 Add PiP border in 1on1 layouts
Fixes: #4008
2026-06-23 08:56:41 +02:00
Robin
504888b359 Show the subtitle of the app bar on small screens
Previously we were hiding the entire app bar on mobile phones in landscape orientation. However now that the app bar supports a small 'subtitle' element, we should show only the subtitle in this case to match the designs.

The subtitle still hides on tap, just like the footer.
2026-06-22 18:21:17 +02:00
Robin
99e95658df Invert the colors of the speaker toggle button 2026-06-22 16:10:45 +02:00
Robin
5a68010022 Merge pull request #4046 from element-hq/header-subtitle
Move ringing status indicator to header on mobile
2026-06-22 13:14:55 +02:00
Robin
a456e47796 Show ringing status even on spotlight tiles (except in app bar mode) 2026-06-22 12:55:37 +02:00
Robin
27abf816f5 Fix vertical alignment of ringing status icons on Chromium and Safari 2026-06-22 12:12:54 +02:00
Robin
c6188a8345 Improve test coverage 2026-06-22 11:59:16 +02:00
Robin
22ff0d34b7 Merge branch 'livekit' into header-subtitle 2026-06-22 11:35:52 +02:00
Robin
96d1d1e1aa Merge pull request #4040 from element-hq/scope-leak-lint
Add lint rule to prevent ObservableScope resource leaks
2026-06-22 11:13:10 +02:00
Robin
6f75e90fcd Merge pull request #4051 from element-hq/remote-members
Clarify which Matrix-LiveKit members are remote
2026-06-22 11:05:32 +02:00
Robin
f78f507745 Address remaining resource leak error 2026-06-22 10:53:08 +02:00
Robin
89c148a530 Update remaining variable names for remote Matrix-LiveKit members 2026-06-22 10:44:24 +02:00
Robin
e11c04ac87 Move ringing status indicator to header on mobile
On mobile, the ringing status indicator is supposed to display in the header rather than on a tile. The exact layout differs between Android and iOS. To get it right I had to refactor AppBar to use CSS grid templates.

(Also, I changed my mind about the exact ringing data I needed out of CallViewModel - sorry. A little move of the ringtone audio renderer into its own component was necessary to accommodate that.)
2026-06-18 17:41:35 +02:00
Timo
9b070052a0 Merge pull request #4032 from element-hq/toger5/lobby-remove-minimise-add-back-button
Add primary button icon configuration and set it left arrow in lobby
2026-06-18 23:02:03 +08:00
Johannes Marbach
bb3d2cd95c Merge pull request #4048 from element-hq/johannes/oxfmt
Switch from prettier to oxfmt
2026-06-18 13:06:55 +02:00
Robin
d417bd8b90 Merge pull request #4052 from element-hq/auto-leave-docs
Fix docs for autoLeave URL param
2026-06-18 12:50:13 +02:00
Robin
f72a593eb4 Merge branch 'livekit' into scope-leak-lint 2026-06-18 12:49:40 +02:00
Robin
e77d143ce1 Clarify which Matrix-LiveKit members are remote
It was rather confusing that matrixLivekitMembers$ gives you objects of type RemoteMatrixLivekitMembers and yet the *local* member would often be among these. I've attempted to clear this up. To my knowledge this wasn't creating any bugs.
2026-06-18 12:10:32 +02:00
Timo K
049c432fc3 update comment + fix tests 2026-06-17 18:48:26 +02:00
Timo K
19fd7f42b1 I cannot belive GH... Does this commit fix the processing situation? 2026-06-17 16:35:09 +02:00
Johannes Marbach
b2f02b0ff6 Switch from prettier to oxfmt 2026-06-17 12:20:42 +02:00
Timo K
b31825d05b review 2026-06-16 16:54:14 +02:00
Timo K
c4fe0223ab remove blur on mobile 2026-06-16 16:42:24 +02:00
Timo K
b9d769364d update tests 2026-06-15 14:59:39 +02:00
Timo K
164fb99ace refactor using enum vs jsx node 2026-06-15 14:42:45 +02:00
Timo K
db26db266d update back arrow size 2026-06-15 14:26:24 +02:00
Timo K
8c8c9eef24 Update LobbyView.tsx 2026-06-15 14:07:44 +02:00
Robin
0a572a9528 Add lint rule to prevent ObservableScope resource leaks
The rule of thumb to avoid resource leaks is that you should never call ObservableScope methods in a callback unless the ObservableScope is directly passed to or created inside that callback. I had a go at codifying this as a lint rule.
2026-06-12 13:16:28 +02:00
Timo K
659da15cc2 more test coverage 2026-06-11 10:39:59 +02:00
Timo K
9b2e1279bc Update DeveloperSettingsTab.test.tsx.snap 2026-06-11 09:33:27 +02:00
Timo K
2b05c415c1 Merge branch 'livekit' into toger5/lobby-remove-minimise-add-back-button 2026-06-10 14:58:56 +02:00
Timo K
ba1957ef52 tests 2026-06-09 18:44:27 +02:00
Timo K
abf9022fd9 Add primary button icon configuration and set it left arrow in lobby 2026-06-09 17:17:25 +02:00
87 changed files with 4193 additions and 4707 deletions

View File

@@ -1,116 +0,0 @@
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",
},
},
};

View File

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

View File

@@ -10,7 +10,7 @@ jobs:
container:
# Make sure to grab the latest version of the Playwright image
# https://playwright.dev/docs/docker#pull-the-image
image: mcr.microsoft.com/playwright:v1.60.0-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

View File

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

View File

@@ -15,9 +15,9 @@ jobs:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7

6
.oxfmtrc.json Normal file
View File

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

153
.oxlintrc.json Normal file
View File

@@ -0,0 +1,153 @@
{
"$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"
}
}
]
}

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_mode": "legacy",
"matrix_rtc_mode": "compatibility",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,

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" }

66
eslint/CopyrightHeader.js Normal file
View File

@@ -0,0 +1,66 @@
/*
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

@@ -0,0 +1,78 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { ESLintUtils } from "@typescript-eslint/utils";
// These ObservableScope methods will not generally cause resource leaks even if
// called from a callback
const safeScopeMethods = new Set(["bind", "end"]);
/**
* Determines whether the variable with the given name is local to
* the enclosing function or class scope.
*/
function isLocal(name, scope) {
// If it is nowhere to be found in the "through" scope, it is local.
if (!scope.through.some(({ identifier }) => identifier.name === name))
return true;
if (scope.type === "function" || scope.type === "class") return false;
// If this is something other than a function or class scope, check its outer
// scope.
return !scope.upper || isLocal(name, scope.upper);
}
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 scope?
!isLocal(node.name, scope) &&
// Exclude calls to "safe" ObservableScope methods
node.parent?.type === "MemberExpression" &&
node.parent.object === node &&
node.parent.property.type === "Identifier" &&
!safeScopeMethods.has(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;

6
eslint/index.js Normal file
View File

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

4
eslint/package.json Normal file
View File

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

View File

@@ -11,12 +11,14 @@ export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
},
entry: ["src/main.tsx", "i18next.config.ts"],
entry: ["src/main.tsx", "eslint/index.js", "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: [
@@ -30,6 +32,9 @@ 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

@@ -205,6 +205,7 @@
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"developer_tab_title": "Developer",
"devices": {
"activating": "Activating…",
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",

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",
"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",
"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",
"lint:knip": "knip",
"lint:types": "tsc",
"i18n": "npx i18next-cli extract",
@@ -47,7 +47,6 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.7.1",
"@mediapipe/tasks-vision": "^0.10.18",
"@playwright/test": "^1.60.0",
@@ -60,11 +59,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,32 +73,19 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@typescript-eslint/utils": "^8.61.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",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.7",
"@vitest/ui": "4.1.9",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"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-element-call": "link:eslint",
"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",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-cli": "^1.61.0",
@@ -113,11 +99,13 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.56.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",
@@ -128,7 +116,6 @@
"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",

View File

@@ -120,6 +120,11 @@ export const widgetTest = test.extend<MyFixtures>({
.getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
} else if (callType === "dm") {
await TestHelpers.closeReleaseAnnouncement(
ewPage1,
"Introducing Sections",
);
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })

View File

@@ -88,15 +88,18 @@ test("When creator left, avoid reconnect to the same SFU", async ({
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
await guestCPage.waitForTimeout(1000);
if (wsConnectionCount === 2) {
console.warn("wsConnectionCount is 2, expecting 1 after join");
}
const wsConnectionCountBeforeLeave = wsConnectionCount;
// ========
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(wsConnectionCount).toBe(wsConnectionCountBeforeLeave);
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
await guestCPage.waitForTimeout(6000);
expect(wsConnectionCount).toBe(1);
expect(wsConnectionCount).toBe(wsConnectionCountBeforeLeave);
});

View File

@@ -160,6 +160,7 @@ 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" },
];
@@ -189,11 +190,27 @@ export class TestHelpers {
}
}
public static async closeReleaseAnnouncement(
page: Page,
name: string,
): Promise<void> {
try {
await page
.getByRole("dialog", { name })
.getByRole("button", { name: "OK" })
.click({ timeout: 2000 });
} catch {
// Announcement not shown; nothing to do
}
}
public static async createRoom(
name: string,
page: Page,
andInvite: string[] = [],
): Promise<void> {
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })

View File

@@ -31,12 +31,10 @@ widgetTest(
const brooksFrame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// We should show a ringing tile, let's check for that
await expect(
brooksFrame
.getByTestId("videoTile")
.filter({ has: brooksFrame.getByText(whistler.displayName) })
.filter({ has: brooksFrame.getByText("Calling…") }),
).toBeVisible();
@@ -83,23 +81,22 @@ widgetTest(
}),
).toBeVisible();
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
// So first we need to confirm that it is hidden when in the call.
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
await expect(
brooks.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
// ASSERT hanging up on one side ends the call for both
await brooksFrame.getByRole("button", { name: "End call" }).click();
// The widget should be closed on both sides and the timeline should be back on screen
// We confirm that we started in Pip mode (voice call) and check that we still see the composer.
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
// ASSERT hanging up on one side ends the call for both
await brooksFrame.getByRole("button", { name: "End call" }).click();
// The widget should be closed on both sides
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
},
);

5825
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@
}
],
"semanticCommits": "disabled",
"ignoreDeps": ["eslint-plugin-matrix-org"],
"ignoreDeps": [],
"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 members
* flattened list of remote members
*/
members$: Behavior<
remoteMembers$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
@@ -86,7 +86,7 @@ interface MatrixRTCSdk {
}[]
>;
/**
* flattened local members
* flattened local member
*/
localMember$: Behavior<{
connection: Connection | null;
@@ -338,8 +338,8 @@ export async function createMatrixRTCSdk(
),
),
connected$: callViewModel.connected$,
members$: scope.behavior(
callViewModel.matrixLivekitMembers$.pipe(
remoteMembers$: scope.behavior(
callViewModel.remoteMatrixLivekitMembers$.pipe(
switchMap((members) => {
const listOfMemberObservables = members.map((member) =>
combineLatest([

View File

@@ -1,6 +1,12 @@
.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 */
@@ -9,8 +15,7 @@
position: absolute;
inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */
inset-block: -24px;
z-index: var(--call-view-header-footer-layer);
inset-block: 0 -16px;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
@@ -18,21 +23,157 @@
);
}
.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;
block-size: 64px;
z-index: var(--call-view-header-footer-layer);
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);
}
.bar svg path {
fill: var(--cpd-color-icon-primary);
.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 > header > h1 {
/* 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 {
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,21 +5,33 @@ 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 } from "./AppBar";
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();
}
describe("AppBar", () => {
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
it("renders", () => snapshotAppBar(content));
it("renders with title and subtitle", () => {
const TestComponent: FC = () => {
useAppBarTitle("Title");
useAppBarSubtitle("Subtitle");
return content;
};
snapshotAppBar(<TestComponent />);
});
});

View File

@@ -6,28 +6,34 @@ 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 { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
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 { 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;
}
@@ -49,44 +55,76 @@ 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, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
() => ({
setTitle,
setSubtitle,
setSecondaryButton,
setHidden,
setPrimaryButtonIconKind,
}),
[
setTitle,
setSubtitle,
setHidden,
setSecondaryButton,
setPrimaryButtonIconKind,
],
);
const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon;
return (
<>
<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}>
{/* 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 />
) : (
<CollapseIcon aria-hidden />
</IconButton>
</Tooltip>
</LeftNav>
)}
</IconButton>
</Tooltip>
{title && (
<Heading
className={styles.title}
type="body"
size="lg"
weight={platform === "android" ? "medium" : "semibold"}
size={platform === "ios" ? "md" : "lg"}
weight={platform === "ios" ? "semibold" : "medium"}
>
{title}
</Heading>
)}
<RightNav>{secondaryButton}</RightNav>
</Header>
{subtitle && (
<Text
className={styles.subtitle}
as="span"
size={platform === "ios" ? "sm" : "lg"}
>
{subtitle}
</Text>
)}
<div className={styles.secondaryButton}>{secondaryButton}</div>
</header>
</div>
<AppBarContext value={context}>{children}</AppBarContext>
</>
@@ -107,6 +145,36 @@ 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: Error | unknown;
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

@@ -3,44 +3,90 @@
exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
class="_bar_221541"
>
<header
class="header"
>
<div
class="nav leftNav"
<header>
<button
aria-labelledby="_r_0_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<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%;"
>
<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"
>
<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>
<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
class="nav rightNav"
class="_secondaryButton_221541"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;
exports[`AppBar > renders with title and subtitle 1`] = `
<div>
<div
class="_bar_221541"
>
<header>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 _primaryButton_221541"
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_221541"
>
Title
</h1>
<span
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 _subtitle_221541"
>
Subtitle
</span>
<div
class="_secondaryButton_221541"
/>
</header>
</div>

View File

@@ -3,7 +3,7 @@
exports[`the content is rendered when the modal is open 1`] = `
<div
aria-labelledby="radix-_r_4_"
class="overlay animate modal dialog _glass_sepwu_8"
class="_overlay_2f5303 _animate_2f5303 _modal_dbeffe _dialog_dbeffe _glass_sepwu_8"
data-state="open"
id="radix-_r_3_"
role="dialog"
@@ -11,10 +11,10 @@ exports[`the content is rendered when the modal is open 1`] = `
tabindex="-1"
>
<div
class="content"
class="_content_dbeffe"
>
<div
class="header"
class="_header_dbeffe"
>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
@@ -24,7 +24,7 @@ exports[`the content is rendered when the modal is open 1`] = `
</h2>
</div>
<div
class="body"
class="_body_dbeffe"
>
<p>
This is the content.
@@ -37,7 +37,7 @@ exports[`the content is rendered when the modal is open 1`] = `
exports[`the modal renders as a drawer in mobile viewports 1`] = `
<div
aria-labelledby="radix-_r_a_"
class="overlay modal drawer"
class="_overlay_2f5303 _modal_dbeffe _drawer_dbeffe"
data-state="open"
data-vaul-animate="true"
data-vaul-custom-container="false"
@@ -51,13 +51,13 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
tabindex="-1"
>
<div
class="content"
class="_content_dbeffe"
>
<div
class="header"
class="_header_dbeffe"
>
<div
class="handle"
class="_handle_dbeffe"
/>
<h2
id="radix-_r_a_"
@@ -67,7 +67,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
</h2>
</div>
<div
class="body"
class="_body_dbeffe"
>
<p>
This is the content.

View File

@@ -2,7 +2,7 @@
exports[`QrCode > renders 1`] = `
<div
class="qrCode bar"
class="_qrCode_458264 bar"
>
<img
alt="QR Code"

View File

@@ -3,7 +3,7 @@
exports[`Toast > renders 1`] = `
<button
aria-labelledby="radix-_r_4_"
class="overlay animate toast"
class="_overlay_2f5303 _animate_2f5303 _toast_15a045"
data-state="open"
id="radix-_r_3_"
role="dialog"

View File

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

View File

@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_1j_"
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _raisedButton_fb25ab _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"

View File

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

View File

@@ -180,12 +180,12 @@ describe("MediaMuteAndSwitchButton", () => {
);
await user.click(screen.getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitem", { name: "Microphone 1" });
screen.getByRole("menuitem", { name: "Microphone 2" });
screen.getByRole("menuitemradio", { name: "Microphone 1" });
screen.getByRole("menuitemradio", { name: "Microphone 2" });
await user.keyboard("[Escape]");
await user.click(screen.getByRole("button", { name: "Camera" }));
screen.getByRole("menuitem", { name: "Camera 1" });
screen.getByRole("menuitem", { name: "Camera 2" });
screen.getByRole("menuitemradio", { name: "Camera 1" });
screen.getByRole("menuitemradio", { name: "Camera 2" });
});
test("calls select callback on menu click", async () => {
@@ -206,7 +206,9 @@ describe("MediaMuteAndSwitchButton", () => {
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
expect(onSelect).toHaveBeenCalledWith("mic2");
});
@@ -228,7 +230,9 @@ describe("MediaMuteAndSwitchButton", () => {
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
);
expect(onSelect).not.toHaveBeenCalled();
});
@@ -264,18 +268,24 @@ describe("MediaMuteAndSwitchButton", () => {
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
expect(onSelectPressed).toHaveBeenCalled();
expect(onOptionUpdated).not.toHaveBeenCalled();
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
// so a spinner should appear on the mic2 item
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
// so mic2 should be in an activating state
screen.getByRole("menuitemradio", {
name: "Microphone 2 Activating…",
checked: false,
});
// The currently-selected mic1 item should not have a spinner
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic1Item.querySelector(".rotate")).toBeNull();
// The currently-selected mic1 item should not be activating
screen.getByRole("menuitemradio", {
name: "Microphone 1",
checked: true,
});
await act(async () => {
// resolve the promise that acutally updates the select option.
resolve();
@@ -284,7 +294,7 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onOptionUpdated).toHaveBeenCalled();
// Spinner should now be gone since the selection has caught up
const mic2ItemAfter = screen.getByRole("menuitem", {
const mic2ItemAfter = screen.getByRole("menuitemradio", {
name: "Microphone 2",
});
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
@@ -336,11 +346,15 @@ describe("MediaMuteAndSwitchButton", () => {
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
const mic1Item = screen.getByRole("menuitemradio", {
name: "Microphone 2",
});
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
const mic2Item = screen.getByRole("menuitemradio", {
name: "Microphone 1",
});
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});

View File

@@ -191,6 +191,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
width={24}
height={24}
className={styles.itemIcon}
aria-hidden
/>
)
}
@@ -201,10 +202,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onSelect?.(id);
}}
key={id}
role="menuitemradio"
aria-checked={selectedOption === id}
>
{selectedOption === id && <CheckIcon width={24} height={24} />}
{selectedOption === id && (
<CheckIcon
width={24}
height={24}
aria-hidden // A label would be redundant to aria-checked above
/>
)}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
<SpinnerIcon
width={24}
height={24}
className={styles.rotate}
aria-label={t("settings.devices.activating")}
/>
)}
</MenuItem>
);

View File

@@ -3,7 +3,7 @@
exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div>
<div
class="container"
class="_container_e649de"
>
<button
aria-busy="false"
@@ -35,7 +35,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
ProcessorWrapper,
supportsBackgroundProcessors,
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
type BackgroundOptions,
} from "@livekit/track-processors";
import {
@@ -29,6 +29,7 @@ import {
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { platform } from "../Platform";
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
@@ -106,6 +107,10 @@ interface Props {
children: JSX.Element;
}
function supportsBackgroundProcessors(): boolean {
return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop";
}
export const ProcessorProvider: FC<Props> = ({ children }) => {
// The setting the user wants to have
const [blurActivated] = useSetting(backgroundBlurSettings);

View File

@@ -2,10 +2,10 @@
exports[`RaisedHandIndicator > renders a smaller indicator when miniature is specified 1`] = `
<div
class="reactionIndicatorWidget"
class="_reactionIndicatorWidget_abd277"
>
<div
class="reaction"
class="_reaction_abd277"
>
<span
aria-label="Reaction"
@@ -22,10 +22,10 @@ exports[`RaisedHandIndicator > renders a smaller indicator when miniature is spe
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = `
<div
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
class="_reactionIndicatorWidget_abd277 _reactionIndicatorWidgetLarge_abd277"
>
<div
class="reaction reactionLarge"
class="_reaction_abd277 _reactionLarge_abd277"
>
<span
aria-label="Reaction"
@@ -42,10 +42,10 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = `
<div
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
class="_reactionIndicatorWidget_abd277 _reactionIndicatorWidgetLarge_abd277"
>
<div
class="reaction reactionLarge"
class="_reaction_abd277 _reactionLarge_abd277"
>
<span
aria-label="Reaction"

View File

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

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
beforeEach,
describe,
expect,
@@ -15,12 +14,7 @@ import {
type MockedFunction,
vi,
} from "vitest";
import {
render,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -50,7 +44,6 @@ 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";
@@ -195,45 +188,6 @@ 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,7 +44,6 @@ 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";
@@ -69,22 +68,24 @@ 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 } from "../AppBar.tsx";
import {
useAppBarHidden,
useAppBarSecondaryButton,
useAppBarSubtitle,
} 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 {
@@ -240,20 +241,6 @@ 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$);
@@ -266,7 +253,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const ringingIntent = useBehavior(vm.ringingIntent$);
const ringingVm = useBehavior(vm.ringingVm$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
@@ -286,22 +273,6 @@ export const InCallView: FC<InCallViewProps> = ({
throw fatalCallError;
}
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current;
if (ringingIntent !== null && audio) {
const endSound = audio.playSoundLooping(
"waiting",
audio.soundDuration["waiting"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [ringingIntent, 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.
@@ -363,6 +334,11 @@ export const InCallView: FC<InCallViewProps> = ({
);
useAppBarHidden(!showHeader);
useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null;
switch (headerStyle) {
@@ -457,6 +433,12 @@ 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
@@ -469,6 +451,8 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
showOutline={showOutline}
focusable={!contentObscured}
/>
) : (
@@ -481,6 +465,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
@@ -515,6 +500,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -626,6 +612,7 @@ 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,6 +11,10 @@ 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";
@@ -20,6 +24,7 @@ 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[] => [],
@@ -47,6 +52,13 @@ 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",
@@ -60,25 +72,32 @@ 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>
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={false}
participantCount={3}
onShareClick={null}
{...props}
/>
{withAppBar && <AppBar>{lobbyView}</AppBar>}
{!withAppBar && lobbyView}
</TooltipProvider>
</MediaDevicesContext>
</BrowserRouter>,
@@ -97,9 +116,10 @@ describe("LobbyView", () => {
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeFalsy();
const els = container.getElementsByClassName(headerStyles.header);
for (const el of els) {
expect(el).not.toBeVisible();
}
});
it("renders with waiting for invite state", () => {
@@ -108,4 +128,50 @@ 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("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("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@@ -51,6 +51,7 @@ 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;
@@ -85,8 +86,9 @@ export const LobbyView: FC<Props> = ({
}, []);
const { t } = useTranslation();
usePageTitle(matrixInfo.roomName);
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

@@ -0,0 +1,59 @@
/*
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

@@ -0,0 +1,72 @@
/*
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

@@ -3,17 +3,17 @@
exports[`ConnectionLostError: Action handling should reset error state 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -95,20 +95,20 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -161,17 +161,17 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' error correctly 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -253,20 +253,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -318,17 +318,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed' error correctly 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -410,20 +410,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -475,17 +475,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreachable' error correctly 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -567,20 +567,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -632,17 +632,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFound' error correctly 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -724,20 +724,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -789,17 +789,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' error correctly 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -881,20 +881,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -946,17 +946,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
exports[`LiveKit ConnectionError variants > should link to troubleshoot guide when timeout error 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1038,20 +1038,20 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1103,17 +1103,17 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
exports[`should have a close button in widget mode 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1195,20 +1195,20 @@ exports[`should have a close button in widget mode 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1257,17 +1257,17 @@ exports[`should have a close button in widget mode 1`] = `
exports[`should render the error page with link back to home 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1349,20 +1349,20 @@ exports[`should render the error page with link back to home 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1411,17 +1411,17 @@ exports[`should render the error page with link back to home 1`] = `
exports[`should report correct error for 'Call is not supported' 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1503,20 +1503,20 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1565,17 +1565,17 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
exports[`should report correct error for 'Connection lost' 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1657,20 +1657,20 @@ exports[`should report correct error for 'Connection lost' 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1723,17 +1723,17 @@ exports[`should report correct error for 'Connection lost' 1`] = `
exports[`should report correct error for 'Homeserver does not support Matrix 2.…' 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1815,20 +1815,20 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -1877,17 +1877,17 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
exports[`should report correct error for 'Incompatible browser' 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -1969,20 +1969,20 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>
@@ -2026,17 +2026,17 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
exports[`should report correct error for 'Insufficient capacity' 1`] = `
<DocumentFragment>
<div
class="page"
class="_page_4be5c0"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<a
aria-label="Element Call Home"
class="headerLogo"
class="_headerLogo_e4b327"
data-discover="true"
href="/"
>
@@ -2118,20 +2118,20 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
</a>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="container"
class="_container_4be5c0"
>
<div
class="content"
class="_content_4be5c0"
>
<div
class="error"
class="_error_a69dc5"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_a69dc5"
data-kind="primary"
data-size="lg"
>

View File

@@ -3,28 +3,28 @@
exports[`InCallView > rendering > renders 1`] = `
<div>
<div
class="inRoom"
class="_inRoom_4e7ff8"
>
<header
class="header header"
class="_header_e4b327 _header_4e7ff8"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<div
class="roomHeaderInfo"
class="_roomHeaderInfo_e4b327"
data-size="lg"
>
<span
aria-label=""
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
/>
<div
class="nameLine"
class="_nameLine_e4b327"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
@@ -35,7 +35,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<svg
aria-labelledby="_r_0_"
class="lock"
class="_lock_edc97d"
data-encrypted="false"
fill="currentColor"
height="16"
@@ -50,7 +50,7 @@ exports[`InCallView > rendering > renders 1`] = `
</span>
</div>
<div
class="participantsLine"
class="_participantsLine_e4b327"
>
<svg
aria-label="Participants"
@@ -80,21 +80,21 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="scrollingGrid grid"
class="_scrollingGrid_4e7ff8 _grid_b0d1cd"
>
<div
class="layer"
class="_layer_b00c5f"
>
<div
class="container slot"
class="_container_b00c5f _slot_b0d1cd"
data-id="1"
>
<div
class="slot local slot"
class="_slot_b00c5f _local_b00c5f _slot_b0d1cd"
data-block-alignment="start"
data-id="0"
data-inline-alignment="end"
@@ -103,22 +103,22 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="fixedGrid grid"
class="_fixedGrid_4e7ff8 _grid_b0d1cd"
style="inset-block-start: NaNpx;"
>
<div />
</div>
<div
class="bg animate"
class="_bg_2f5303 _animate_2f5303"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay"
class="_overlay_eb6724"
data-show="false"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_eb6724"
data-kind="primary"
data-size="lg"
>
@@ -157,22 +157,22 @@ exports[`InCallView > rendering > renders 1`] = `
Back to Speaker Mode
</button>
<div
class="spacer"
class="_spacer_eb6724"
/>
</div>
<div
class="container"
class="_container_8084b5"
/>
<div
class="footer"
class="_footer_20b7b4"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_8_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
@@ -198,7 +198,7 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</button>
<div
class="logo"
class="_logo_20b7b4"
>
<svg
aria-hidden="true"
@@ -303,11 +303,11 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="buttons"
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_d_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
@@ -382,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_s_"
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -405,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-labelledby="_r_14_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
class="_button_1nw83_8 _endCall_204dcb _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
@@ -428,7 +428,7 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
<fieldset
aria-label="Layout"
class="_toggle_13rnk_9 layout"
class="_toggle_13rnk_9 _layout_20b7b4"
data-size="lg"
>
<input

View File

@@ -1,23 +1,485 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LobbyView > renders with AppBar android 1`] = `
<div>
<div
class="_bar_221541"
>
<header>
<button
aria-labelledby="_r_36_"
class="_icon-button_1215g_8 _primaryButton_221541"
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_221541"
/>
</header>
</div>
<div
class="_inRoom_4e7ff8"
>
<div
class="_content_f9ee84"
>
<div
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="_avatarContainer_dd2178"
>
<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_dd2178"
>
<button
aria-disabled="true"
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
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_20b7b4"
data-testid="footer-container"
>
<div
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
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_20b7b4"
>
<button
aria-labelledby="_r_3h_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _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_204dcb _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_221541"
>
<header>
<button
aria-labelledby="_r_4a_"
class="_icon-button_1215g_8 _primaryButton_221541"
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_221541"
/>
</header>
</div>
<div
class="_inRoom_4e7ff8"
>
<div
class="_content_f9ee84"
>
<div
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="_avatarContainer_dd2178"
>
<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_dd2178"
>
<button
aria-disabled="true"
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
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_20b7b4"
data-testid="footer-container"
>
<div
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_4g_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
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_20b7b4"
>
<button
aria-labelledby="_r_4l_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _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_204dcb _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
class="inRoom"
class="_inRoom_4e7ff8"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<div
class="roomHeaderInfo"
class="_roomHeaderInfo_e4b327"
data-size="lg"
>
<span
aria-label="!room:example.org"
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
@@ -26,7 +488,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
T
</span>
<div
class="nameLine"
class="_nameLine_e4b327"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
@@ -39,7 +501,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
>
<svg
aria-labelledby="_r_0_"
class="lock"
class="_lock_edc97d"
data-encrypted="false"
fill="currentColor"
height="16"
@@ -54,7 +516,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</span>
</div>
<div
class="participantsLine"
class="_participantsLine_e4b327"
>
<svg
aria-label="Participants"
@@ -84,14 +546,14 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="content"
class="_content_f9ee84"
>
<div
class="preview"
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
@@ -99,7 +561,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
tabindex="-1"
/>
<div
class="avatarContainer"
class="_avatarContainer_dd2178"
>
<div>
<span
@@ -115,10 +577,10 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="buttonBar"
class="_buttonBar_dd2178"
>
<button
class="_button_1nw83_8 join"
class="_button_1nw83_8 _join_f9ee84"
data-kind="primary"
data-size="lg"
data-testid="lobby_joinCall"
@@ -140,15 +602,15 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</a>
</div>
<div
class="footer"
class="_footer_20b7b4"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
@@ -168,13 +630,13 @@ exports[`LobbyView > renders with header and participant count 1`] = `
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"
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
class="logo"
class="_logo_20b7b4"
>
<svg
aria-hidden="true"
@@ -279,11 +741,11 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="buttons"
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_b_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
@@ -299,7 +761,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
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"
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>
@@ -355,7 +817,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</button>
<button
aria-labelledby="_r_q_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
class="_button_1nw83_8 _endCall_204dcb _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"

View File

@@ -18,10 +18,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
Device ID: DEVICE123
</p>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field inputField"
class="_field_1bd8c0 _inputField_1bd8c0"
>
<input
aria-describedby="_r_1_"
@@ -38,10 +38,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field checkboxField"
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_2_"
@@ -52,7 +52,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
for="debugTileLayout"
>
<div
class="checkbox"
class="_checkbox_1bd8c0"
>
<svg
fill="none"
@@ -75,10 +75,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field checkboxField"
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
@@ -89,7 +89,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
for="showConnectionStats"
>
<div
class="checkbox"
class="_checkbox_1bd8c0"
>
<svg
fill="none"
@@ -112,10 +112,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field checkboxField"
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
@@ -126,7 +126,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
for="muteAllAudio"
>
<div
class="checkbox"
class="_checkbox_1bd8c0"
>
<svg
fill="none"
@@ -150,10 +150,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field checkboxField"
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
@@ -164,7 +164,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
for="alwaysShowIphoneEarpiece"
>
<div
class="checkbox"
class="_checkbox_1bd8c0"
>
<svg
fill="none"
@@ -187,10 +187,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="fieldRow"
class="_fieldRow_1bd8c0"
>
<div
class="field checkboxField"
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
@@ -201,7 +201,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
for="enableLivekitExtendedLogs"
>
<div
class="checkbox"
class="_checkbox_1bd8c0"
>
<svg
fill="none"
@@ -274,7 +274,6 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_9_"
name="_r_0_"
@@ -315,6 +314,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_b_"
name="_r_0_"
@@ -386,7 +386,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</form>
<div
class="livekit_room_box"
class="_livekit_room_box_2ddec4"
>
<h4>
LiveKit SFU: wss://local-sfu.example.org
@@ -427,7 +427,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<ul />
</div>
<div
class="livekit_room_box"
class="_livekit_room_box_2ddec4"
>
<h4>
LiveKit SFU: wss://remote-sfu.example.org

View File

@@ -137,7 +137,7 @@ export const enableExtendedLivekitLogs = new Setting<boolean>(
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",
MatrixRTCMode.Legacy,
MatrixRTCMode.Compatibility,
);
export const customLivekitUrl = new Setting<string | null>(

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

@@ -161,6 +161,10 @@ export function createCallNotificationLifecycle$({
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,
),
});

View File

@@ -22,6 +22,7 @@ import { SyncState } from "matrix-js-sdk";
import {
ConnectionState,
type LocalTrackPublication,
type Participant,
type RemoteParticipant,
} from "livekit-client";
import * as ComponentsCore from "@livekit/components-core";
@@ -1031,6 +1032,10 @@ describe.each([
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
videoEnabled: new Map<Participant, Behavior<boolean>>([
[localParticipant, constant(true)],
[aliceParticipant, constant(true)],
]),
},
(vm) => {
schedule(modeInputMarbles, {
@@ -1060,6 +1065,33 @@ 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
@@ -1096,7 +1128,7 @@ describe.each([
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
pip: undefined,
},
c: {
type: "grid",
@@ -1420,10 +1452,13 @@ describe.each([
},
});
// Should ring for 30ms and then time out
expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", {
expectObservable(vm.ringingVm$).toBe("(ab)", {
a: null,
b: "audio",
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
});
// Layout should show placeholder media for the participant we're
// ringing the entire time (even once timed out)
@@ -1463,9 +1498,13 @@ describe.each([
});
// Should ring until Alice joins
expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", {
expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", {
a: null,
b: "audio",
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
}),
});
// Layout should show placeholder media for the participant we're
// ringing the entire time

View File

@@ -39,12 +39,10 @@ import {
throttleTime,
timer,
takeUntil,
concat,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type RTCCallIntent,
type LivekitTransportConfig,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -127,10 +125,9 @@ import {
createConnectionManager$,
} from "./remoteMembers/ConnectionManager.ts";
import {
createMatrixLivekitMembers$,
createRemoteMatrixLivekitMembers$,
type LocalMatrixLivekitMember,
type RemoteMatrixLivekitMember,
type MatrixLivekitMember,
} from "./remoteMembers/MatrixLivekitMembers.ts";
import {
type AutoLeaveReason,
@@ -158,6 +155,7 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
const logger = rootLogger.getChild("[CallViewModel]");
//TODO
@@ -232,9 +230,13 @@ export interface CallViewModel {
// lifecycle
autoLeave$: Observable<AutoLeaveReason>;
/**
* Whether we are ringing a call recipient. Contains the ringing intent if so.
* View model for info relating to ringing, timing out, calling back, etc.
*/
ringingIntent$: Behavior<RTCCallIntent | null>;
ringingVm$: Behavior<RingingMediaViewModel | null>;
/**
* Which visual element the ringing status should be shown in.
*/
ringingStatusLocation: "app_bar" | "tile";
/** 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
@@ -302,7 +304,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. */
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
remoteMatrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
/** List of participants raising their hand */
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
@@ -529,13 +531,15 @@ export function createCallViewModel$(
ownMembershipIdentity,
});
const matrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>> =
createMatrixLivekitMembers$({
scope: scope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager,
});
const remoteMatrixLivekitMembers$: Behavior<
Epoch<RemoteMatrixLivekitMember[]>
> = createRemoteMatrixLivekitMembers$({
scope: scope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager,
localUser: { userId, deviceId },
});
const connectOptions$ = scope.behavior(
matrixRTCMode$.pipe(
@@ -612,6 +616,13 @@ export function createCallViewModel$(
),
);
const matrixLivekitMembers$ = scope.behavior(
combineLatest(
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
),
);
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
@@ -641,7 +652,7 @@ export function createCallViewModel$(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior(
matrixLivekitMembers$.pipe(
remoteMatrixLivekitMembers$.pipe(
switchMap((members) => {
const a$ = combineLatest(
members.value.map((member) =>
@@ -707,43 +718,20 @@ export function createCallViewModel$(
* List of user media (camera feeds) that we want tiles for.
*/
const userMedia$ = scope.behavior<WrappedUserMediaViewModel[]>(
combineLatest([
localMatrixLivekitMember$,
matrixLivekitMembers$,
duplicateTiles.value$,
]).pipe(
combineLatest([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* ([
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);
function* ([members, duplicateTiles]) {
for (const {
userId,
participant,
connection$,
membership$,
} of members) {
const rtcId = membership$.value.rtcBackendIdentity;
const mediaId = `${userId}:${membership$.value.deviceId}`;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [dup, mediaId, userId, participant, connection$, rtcId],
@@ -861,7 +849,7 @@ export function createCallViewModel$(
* multiple devices.
*/
const participantCount$ = scope.behavior(
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
);
const leaveSoundEffect$ = userMedia$.pipe(
@@ -954,8 +942,8 @@ export function createCallViewModel$(
);
/**
* Local user media suitable for displaying in a PiP (undefined if not found
* or if user prefers to not see themselves).
* Local user media suitable for displaying in a PiP (undefined if not found,
* video is muted, or if user prefers to not see themselves).
*/
const localUserMediaForPip$ = scope.behavior<
LocalUserMediaViewModel | undefined
@@ -967,8 +955,10 @@ export function createCallViewModel$(
m.type === "user" && m.local,
);
if (!localUserMedia) return of(undefined);
return localUserMedia.alwaysShow$.pipe(
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
return combineLatest(
[localUserMedia.videoEnabled$, localUserMedia.alwaysShow$],
(videoEnabled, alwaysShow) =>
videoEnabled && alwaysShow ? localUserMedia : undefined,
);
}),
),
@@ -1437,7 +1427,7 @@ export function createCallViewModel$(
windowMode$.pipe(
switchMap((mode) => {
// In small windows the header would be too obstructive
if (mode === "pip" || mode === "flat") return of(false);
if (mode === "pip") return of(false);
// In edge-to-edge layouts, couple the visibility of the header
// to that of the footer
return edgeToEdge$.pipe(
@@ -1495,6 +1485,7 @@ 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":
@@ -1520,6 +1511,7 @@ export function createCallViewModel$(
landscapePipAlignment$,
prevTiles,
);
pip = layout.pip;
break;
case "one-on-one-portrait":
[layout, newTiles] = oneOnOnePortraitLayout(
@@ -1528,12 +1520,17 @@ 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() },
@@ -1717,15 +1714,9 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringingIntent$: scope.behavior(
ringAttempts$.pipe(
switchMap(({ intent, outcome$ }) =>
// Hold the intent as the value until the ring attempt completes
concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)),
),
startWith<RTCCallIntent | null>(null),
),
),
ringingVm$: ringingMedia$,
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
@@ -1770,8 +1761,8 @@ export function createCallViewModel$(
setGridMode: setGridMode,
layout$: layout$,
localMatrixLivekitMember$,
matrixLivekitMembers$: scope.behavior(
matrixLivekitMembers$.pipe(
remoteMatrixLivekitMembers$: scope.behavior(
remoteMatrixLivekitMembers$.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.filter(isLivekitTransportConfig)[0];
const first = transportList.find(isLivekitTransportConfig);
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,
createMatrixLivekitMembers$,
createRemoteMatrixLivekitMembers$,
} from "./MatrixLivekitMembers.ts";
import {
Epoch,
@@ -31,6 +31,7 @@ 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;
@@ -88,16 +89,17 @@ test("should signal participant not yet connected to livekit", async () => {
mockConnectionManagerData$,
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -157,16 +159,17 @@ test("should signal participant on a connection that is publishing", async () =>
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -197,15 +200,16 @@ test("should signal participant on a connection that is not publishing", async (
constant(dataWithPublisher),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -245,15 +249,16 @@ describe("Publication edge case", () => {
constant(connectionWithPublisher),
);
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(matrixLivekitMembers$.value.value).toSatisfy(
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);
@@ -303,16 +308,17 @@ test("bob is publishing in the wrong connection", async () => {
connectionsWithPublisher$,
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember,
});
await flushPromises();
expect(matrixLivekitMember$.value.value).toSatisfy(
expect(remoteMatrixLivekitMembers$.value.value).toSatisfy(
(data: RemoteMatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expect(data[0].membership$.value).toBe(bobMembership);

View File

@@ -62,7 +62,9 @@ interface Props {
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
>;
connectionManager: IConnectionManager;
localUser: { deviceId: string; userId: string };
}
/**
* Combines MatrixRTC and Livekit worlds.
*
@@ -73,13 +75,14 @@ interface Props {
* - out (via public Observable):
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
*/
export function createMatrixLivekitMembers$({
export function createRemoteMatrixLivekitMembers$({
scope,
membershipsWithTransport$,
connectionManager,
localUser,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
/**
* Stream of all the call members and their associated livekit data (if available).
* Behavior of all the remote call members and their associated livekit data (if available).
*/
return scope.behavior(
combineLatest([
@@ -91,12 +94,19 @@ export function createMatrixLivekitMembers$({
),
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
generateItemsWithEpoch(
"MatrixLivekitMembers",
"RemoteMatrixLivekitMembers",
// 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,
createMatrixLivekitMembers$,
createRemoteMatrixLivekitMembers$,
type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { constant } from "../../Behavior.ts";
import { testJWTToken } from "../../../utils/test-fixtures.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger
@@ -130,14 +130,15 @@ test("bob, carl, then bob joining no tracks yet", () => {
ownMembershipIdentity: ownMemberMock,
});
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager,
localUser: localRtcMember,
});
expectObservable(matrixLivekitMembers$).toBe(vMarble, {
expectObservable(remoteMatrixLivekitMembers$).toBe(vMarble, {
a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
const items = e.value;
expect(items.length).toBe(1);

View File

@@ -0,0 +1,162 @@
/*
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,6 +8,7 @@ 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$,
@@ -24,7 +25,7 @@ import {
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export const EARPIECE_CONFIG_ID = "earpiece-id";
/**
* A special implementation of audio output that allows the hosting application
@@ -94,7 +95,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
],
(available, preferredId) => {
const id = preferredId ?? available.keys().next().value;
const id = preferredId ?? this.chooseDefaultId(available);
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
@@ -106,9 +107,41 @@ 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,7 +376,11 @@ export class MediaDevices {
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
)
: 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[] = new Array(
this.prevGrid.length,
);
private readonly stationaryGridEntries: GridTileData[] = Array.from({
length: 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,6 +5,8 @@ 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";
@@ -17,12 +19,18 @@ 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,6 +92,7 @@ export function createMemberMedia(
}: MemberMediaInputs,
): BaseMemberMediaViewModel {
const trackBehavior$ = (
scope: ObservableScope,
source: Track.Source,
): Behavior<TrackReference | undefined> =>
scope.behavior(
@@ -102,8 +103,8 @@ export function createMemberMedia(
),
);
const audio$ = trackBehavior$(audioSource);
const video$ = trackBehavior$(videoSource);
const audio$ = trackBehavior$(scope, audioSource);
const video$ = trackBehavior$(scope, videoSource);
return {
...createBaseMedia(inputs),

View File

@@ -66,6 +66,11 @@ 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

@@ -77,6 +77,8 @@ test("GridTile is accessible", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
@@ -108,6 +110,8 @@ test("GridTile displays ringing media", async () => {
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,

View File

@@ -29,15 +29,13 @@ 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";
@@ -53,6 +51,7 @@ 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>;
@@ -68,16 +67,15 @@ 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$);
return (
<MediaView
className={classNames(className, styles.tile)}
@@ -85,14 +83,13 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
userId={vm.userId}
unencryptedWarning={false}
status={
pickupState === "ringing"
? {
text: t("video_tile.calling"),
Icon:
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
showStatus && (
<Text as="span" size="sm" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
@@ -400,6 +397,8 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
showOutline: boolean;
focusable: boolean;
}
@@ -407,7 +406,10 @@ export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
showSpeakingIndicators,
showRingingStatus,
showOutline,
onOpenProfile,
className,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
@@ -423,6 +425,8 @@ export const GridTile: FC<GridTileProps> = ({
vm={media}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -435,6 +439,7 @@ export const GridTile: FC<GridTileProps> = ({
onOpenProfile={onOpenProfile}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -446,6 +451,7 @@ export const GridTile: FC<GridTileProps> = ({
showSpeakingIndicators={showSpeakingIndicators}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);

View File

@@ -54,11 +54,12 @@ 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;
}
.translucent {
.avatar[data-style="translucent"] {
opacity: 50%;
mix-blend-mode: multiply;
}
/* CSS makes us put a condition here, even though all we want to do is
@@ -121,18 +122,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));
}
.status svg {
color: var(--cpd-color-icon-tertiary);
svg {
color: var(--cpd-color-icon-tertiary);
vertical-align: text-bottom;
margin-inline-end: 3px;
block-size: 1.2em;
inline-size: 1.2em;
}
}
.reactions {

View File

@@ -13,8 +13,7 @@ import {
type TrackReference,
type TrackReferencePlaceholder,
} from "@livekit/components-core";
import { LocalTrackPublication, Track } from "livekit-client";
import { TrackInfo } from "@livekit/protocol";
import { type LocalTrackPublication, Track } from "livekit-client";
import { type ComponentProps } from "react";
import { MediaView } from "./MediaView";
@@ -28,10 +27,7 @@ describe("MediaView", () => {
};
const trackReference: TrackReference = {
...trackReferencePlaceholder,
publication: new LocalTrackPublication(
Track.Kind.Video,
new TrackInfo({ sid: "id", name: "name" }),
),
publication: {} as Partial<LocalTrackPublication> as LocalTrackPublication,
};
const baseProps: ComponentProps<typeof MediaView> = {

View File

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

View File

@@ -0,0 +1,41 @@
/*
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

@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable={true}
/>,
);
@@ -107,6 +108,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable
/>
</TooltipProvider>,
@@ -137,6 +139,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable
/>,
);
@@ -172,6 +175,7 @@ test("SpotlightTile displays ringing media", async () => {
onToggleExpanded={toggleExpanded}
showIndicators
showNameTags
showRingingStatus
focusable={true}
/>,
);

View File

@@ -24,9 +24,6 @@ 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";
@@ -34,7 +31,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 } from "@vector-im/compound-web";
import { Menu, MenuItem, Text } from "@vector-im/compound-web";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
@@ -56,6 +53,7 @@ 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>;
@@ -204,28 +202,26 @@ 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$);
return (
<MediaView
video={undefined}
unencryptedWarning={false}
status={
pickupState === "ringing"
? {
text: t("video_tile.calling"),
Icon:
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
}
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
showStatus && (
<Text as="span" size="md" weight="medium">
<RingingStatus vm={vm} />
</Text>
)
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
@@ -246,6 +242,7 @@ interface SpotlightItemProps {
*/
targetHeight: number;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
@@ -261,6 +258,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth,
targetHeight,
showNameTags,
showRingingStatus,
focusable,
intersectionObserver$,
snap,
@@ -302,7 +300,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
};
return vm.type === "ringing" ? (
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
<SpotlightRingingMediaItem
vm={vm}
showStatus={showRingingStatus}
{...baseProps}
/>
) : (
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
);
@@ -386,6 +388,7 @@ interface Props {
targetHeight: number;
showIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
@@ -400,6 +403,7 @@ export const SpotlightTile: FC<Props> = ({
targetHeight,
showIndicators,
showNameTags,
showRingingStatus,
focusable = true,
className,
style,
@@ -510,6 +514,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
showRingingStatus={showRingingStatus}
showNameTags={showNameTags}
focusable={focusable}
intersectionObserver$={intersectionObserver$}

View File

@@ -6,22 +6,22 @@ Please see LICENSE in the repository root for full details.
*/
import { expect, describe, it } from "vitest";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { TileAvatar } from "./TileAvatar";
describe("TileAvatar", () => {
it("should show loading spinner when loading", () => {
const { container } = render(
render(
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={true} />,
);
expect(container.querySelector(".loading")).toBeInTheDocument();
screen.getByLabelText("Loading");
});
it("should not show loading spinner when not loading", () => {
const { container } = render(
render(
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={false} />,
);
expect(container.querySelector(".loading")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Loading")).toBe(null);
});
});

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC } from "react";
import { InlineSpinner } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import styles from "./TileAvatar.module.css";
import { Avatar, type Props as AvatarProps } from "../Avatar";
@@ -17,11 +18,12 @@ interface Props extends AvatarProps {
}
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
const { t } = useTranslation();
return (
<div>
{loading && (
<div className={styles.loading}>
<InlineSpinner size={size / 3} />
<InlineSpinner size={size / 3} aria-label={t("common.loading")} />
</div>
)}
<Avatar size={size} {...props} />

View File

@@ -113,7 +113,7 @@ interface Props<S extends string> {
muted?: boolean;
}
interface UseAudioContext<S extends string> {
export 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
* @param initialRtcMemberships
* @param members - Room members to include in the call.
* @param initialRtcMemberships - RTC memberships to start with.
* @returns
*/
export function getBasicCallViewModelEnvironment(

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import "global-jsdom/register";
import "@formatjs/intl-durationformat/polyfill.js";
import "@formatjs/intl-segmenter/polyfill";
import i18n from "i18next";

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,8 +48,6 @@
// "noPropertyAccessFromIndexSignature": true,
// "noUncheckedIndexedAccess": true,
// "noUnusedParameters": true,
"plugins": [{ "name": "typescript-eslint-language-service" }]
},
"include": [
"./src/**/*.ts",

View File

@@ -18,12 +18,7 @@ export default defineConfig((configEnv) =>
extends: true,
test: {
name: "unit",
css: {
include: /.+/,
modules: {
classNameStrategy: "non-scoped",
},
},
css: { include: /.+/ },
setupFiles: ["src/vitest.setup.ts"],
environment: "jsdom",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],