mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
159 Commits
fkwp/chang
...
mobile-gra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d826b2f31 | ||
|
|
32e30dd4f5 | ||
|
|
bb06905aa2 | ||
|
|
f2c42c314c | ||
|
|
4b50a191ba | ||
|
|
11fadebb87 | ||
|
|
943ce5b853 | ||
|
|
137093350b | ||
|
|
000ee66cfe | ||
|
|
58a8b7316e | ||
|
|
1e6d3d3357 | ||
|
|
9f681b87a0 | ||
|
|
dc21233767 | ||
|
|
27315a7090 | ||
|
|
bebe79d109 | ||
|
|
4ca0270115 | ||
|
|
374f56651c | ||
|
|
eefa6f843d | ||
|
|
2b84c587f2 | ||
|
|
706cddafb0 | ||
|
|
a6921e2bfb | ||
|
|
3cc396f42c | ||
|
|
59999b9b37 | ||
|
|
e68321e16c | ||
|
|
ad76969ce2 | ||
|
|
c170aad364 | ||
|
|
9e8bebc3bf | ||
|
|
d5cdbfd4d1 | ||
|
|
7840f4bccb | ||
|
|
9199dcb583 | ||
|
|
cbc4b77a4f | ||
|
|
ffb881436a | ||
|
|
9cf87efc6b | ||
|
|
b71454d63a | ||
|
|
b0feb33ec4 | ||
|
|
900d0abd27 | ||
|
|
a3d3a1e951 | ||
|
|
8fa3f33f37 | ||
|
|
360a4ff026 | ||
|
|
8c6a16f3d7 | ||
|
|
9d21581259 | ||
|
|
3837576a5f | ||
|
|
eb5a097999 | ||
|
|
db2d20ac56 | ||
|
|
f276c51772 | ||
|
|
ac2214ed77 | ||
|
|
8fe7bb5329 | ||
|
|
8028a6aa24 | ||
|
|
5b4e8865e5 | ||
|
|
356d526116 | ||
|
|
cca69b8108 | ||
|
|
75235a7853 | ||
|
|
0421783ebe | ||
|
|
a1be4ff0f9 | ||
|
|
8ab7f89e36 | ||
|
|
ddcb0f9981 | ||
|
|
6ca5ddda73 | ||
|
|
41d0933d4d | ||
|
|
5ad62c7097 | ||
|
|
76714ac3ad | ||
|
|
15b40835b8 | ||
|
|
dc5b541b21 | ||
|
|
f376ffb96b | ||
|
|
d5ce3a955a | ||
|
|
d2cb7e334e | ||
|
|
9d61ca13cb | ||
|
|
51bd17d335 | ||
|
|
1cd2764eb2 | ||
|
|
3fb81032e0 | ||
|
|
da942ea900 | ||
|
|
d534c617d2 | ||
|
|
ded3d0ab81 | ||
|
|
a4b4a6b655 | ||
|
|
be5d8140c9 | ||
|
|
7d54487354 | ||
|
|
93fae5dd67 | ||
|
|
504888b359 | ||
|
|
99e95658df | ||
|
|
5a68010022 | ||
|
|
a456e47796 | ||
|
|
27abf816f5 | ||
|
|
c6188a8345 | ||
|
|
22ff0d34b7 | ||
|
|
96d1d1e1aa | ||
|
|
6f75e90fcd | ||
|
|
f78f507745 | ||
|
|
89c148a530 | ||
|
|
e11c04ac87 | ||
|
|
9b070052a0 | ||
|
|
bb3d2cd95c | ||
|
|
d417bd8b90 | ||
|
|
f72a593eb4 | ||
|
|
f6cd06fc01 | ||
|
|
e77d143ce1 | ||
|
|
256219c4bf | ||
|
|
6af72f4c90 | ||
|
|
3a598ffe43 | ||
|
|
05797097c6 | ||
|
|
8b0f5054dd | ||
|
|
1c760f3ec4 | ||
|
|
699f383b57 | ||
|
|
6009988d3c | ||
|
|
8cda9777a5 | ||
|
|
049c432fc3 | ||
|
|
3ea89e8275 | ||
|
|
19fd7f42b1 | ||
|
|
0410be33cd | ||
|
|
002f9b0cea | ||
|
|
669f4264c7 | ||
|
|
dc6511b99e | ||
|
|
032968ea2d | ||
|
|
b2f02b0ff6 | ||
|
|
b31825d05b | ||
|
|
c4fe0223ab | ||
|
|
6ce79d8a52 | ||
|
|
b68abb7df4 | ||
|
|
1281ed31df | ||
|
|
e7377fb104 | ||
|
|
edb4dfa9ed | ||
|
|
7db13e1fc0 | ||
|
|
b9d769364d | ||
|
|
164fb99ace | ||
|
|
db26db266d | ||
|
|
fc3461dd54 | ||
|
|
8c8c9eef24 | ||
|
|
df7451c2cf | ||
|
|
9537c7657c | ||
|
|
6c105e971e | ||
|
|
cbbe6a812e | ||
|
|
0a572a9528 | ||
|
|
3ef3ebe897 | ||
|
|
0c284ed75b | ||
|
|
838d2f3c5d | ||
|
|
2a24fbcee1 | ||
|
|
be8a76868b | ||
|
|
d1ea63587b | ||
|
|
96a9115ee7 | ||
|
|
2ac6cdeb46 | ||
|
|
659da15cc2 | ||
|
|
9b2e1279bc | ||
|
|
3af160bb02 | ||
|
|
73ccc5f483 | ||
|
|
3a824dfff0 | ||
|
|
2b05c415c1 | ||
|
|
7ffa585f0d | ||
|
|
0690387b70 | ||
|
|
ba1957ef52 | ||
|
|
abf9022fd9 | ||
|
|
11b08c9a9f | ||
|
|
c1f528e120 | ||
|
|
664a10c724 | ||
|
|
308a042076 | ||
|
|
b554ce4a87 | ||
|
|
b86909af52 | ||
|
|
d6635b7818 | ||
|
|
a6fc710d8c | ||
|
|
5dfd1648c5 | ||
|
|
1d5ac67324 | ||
|
|
2faab3280a |
116
.eslintrc.cjs
116
.eslintrc.cjs
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
253
.github/labels.yml
vendored
Normal file
253
.github/labels.yml
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
- name: "A-1:1"
|
||||
description: "Calls between two people"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Big-Grid"
|
||||
description: "The freedom layout system used for >12 participants"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Developer-Experience"
|
||||
description: "Workflow of developing: building, linting, debugging, profiling, etc."
|
||||
color: "c5def5"
|
||||
- name: "A-E2EE"
|
||||
description: "End-to-end encryption"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Embedded"
|
||||
description: "Using the app embedded within other Matrix clients (as a widget)"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Feedback-Reporting"
|
||||
description: "Reporting process for bugs, debug logs (rageshakes), suggestions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Freedom"
|
||||
description: "Freedom layout, where participants can be rearranged and resized"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Handset"
|
||||
description: "Audio playback through the earpiece of a phone. Also known as 'earpiece mode' or 'handset mode'."
|
||||
color: "bfd4f2"
|
||||
- name: "A-Huddle"
|
||||
description: "Ad-hoc calls in a room notifying others"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Lobby"
|
||||
description: "The page before joining a call"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Login"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Matrix2.0"
|
||||
description: "Issues relating to the Matrix 2.0 / MSC4143 work, such as sticky events and multi-sfu"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Media-Devices"
|
||||
color: "BFD4F2"
|
||||
- name: "A-Media-Quality"
|
||||
description: "Distortions or glitches in audio/video"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Meeting"
|
||||
description: "Scheduled call on the calendar"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Mobile"
|
||||
description: "Using the app on a mobile device"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Moderation"
|
||||
description: "Access to calls and powers within calls"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Performance"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Reactions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Registration"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Screen-Sharing"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SDK"
|
||||
description: "SDK for building MatrixRTC + LiveKit widgets"
|
||||
color: "c5def5"
|
||||
- name: "A-Settings"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SFU"
|
||||
description: "Routing calls through a selective forwarding unit"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Signaling"
|
||||
description: "Call signaling"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Simulcast"
|
||||
description: "Automatic selection of variable video resolutions"
|
||||
color: "bfd4f2"
|
||||
- name: "A-SPA"
|
||||
description: "Standalone application accessed via call links"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Spatial-Audio"
|
||||
description: "Directional audio based on where a speaker appears on screen"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Speech-Enhancement"
|
||||
description: "Techniques to enhance the intelligibility of speech in calls"
|
||||
color: "c5def5"
|
||||
- name: "A-Split-Grid"
|
||||
description: "The freedom layout system used for ≤12 participants"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Spotlight"
|
||||
description: "Spotlight layout, where the active speaker is foregrounded"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Telemetry-Posthog"
|
||||
description: "Share opt in usage data for optimizing the app via posthog"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Testing"
|
||||
description: "Integration tests, unit tests, etc."
|
||||
color: "bfd4f2"
|
||||
- name: "A-Video-Rooms"
|
||||
description: "Rooms reserved exclusively for calling"
|
||||
color: "bfd4f2"
|
||||
- name: "A-Walkie-Talkie"
|
||||
description: "Walkie-talkie / PTT (push-to-talk) mode"
|
||||
color: "bfd4f2"
|
||||
- name: "A11y"
|
||||
description: "Accessibility"
|
||||
color: "4ADEC0"
|
||||
- name: "backport-candidate"
|
||||
description: "Something that is a candidate for backport to a particular release branch"
|
||||
color: "0B8D85"
|
||||
- name: "customer-retainer"
|
||||
color: "F44A5F"
|
||||
- name: "dependencies"
|
||||
description: "Pull requests that update a dependency file"
|
||||
color: "0366d6"
|
||||
- name: "development build"
|
||||
description: "runs yarn build process in development mode"
|
||||
color: "1d76db"
|
||||
- name: "Discord"
|
||||
description: "Use case familiar to Discord users"
|
||||
color: "3670d2"
|
||||
- name: "docker build"
|
||||
description: "Creates a docker image for this PR"
|
||||
color: "0e8a16"
|
||||
- name: "EPIC"
|
||||
color: "5319E7"
|
||||
- name: "good first issue"
|
||||
description: "Good for newcomers"
|
||||
color: "7057ff"
|
||||
- name: "Help Wanted"
|
||||
description: "Community contributions are welcome!"
|
||||
color: "159818"
|
||||
- name: "I18n"
|
||||
description: "Internationalisation"
|
||||
color: "d4c5f9"
|
||||
- name: "O-Frequent"
|
||||
description: "Affects or can be seen by most users regularly or impacts most users' first experience"
|
||||
color: "0052CC"
|
||||
- name: "O-Occasional"
|
||||
description: "Affects or can be seen by some users regularly or most users rarely"
|
||||
color: "1D76DB"
|
||||
- name: "O-Uncommon"
|
||||
description: "Most users are unlikely to come across this or unexpected workflow"
|
||||
color: "C5DEF5"
|
||||
- name: "p1"
|
||||
description: "Must fix/implement before this is usable as a product"
|
||||
color: "D93F0B"
|
||||
- name: "p2"
|
||||
description: "Should fix/implement, but not at the expense of p1s"
|
||||
color: "FBCA04"
|
||||
- name: "p3"
|
||||
description: "Could fix/implement when time allows"
|
||||
color: "0E8A16"
|
||||
- name: "PR-Breaking-Change"
|
||||
description: "A Pull request that changes EC in a way that is incompatible to the previous version."
|
||||
color: "D93F0B"
|
||||
- name: "PR-Bug-Fix"
|
||||
description: "Release note category. A PR that fixes a bug."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Developer-Experience"
|
||||
description: "Release note category. A PR that does not change EC but improves working with the repository."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Documentation"
|
||||
description: "Release note category. A PR that improves the documentation."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Feature"
|
||||
description: "Release note category. A PR that introduces a new user facing feature."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Improvement"
|
||||
description: "Release note category. A PR that improves EC's performance or stability."
|
||||
color: "C2E0C6"
|
||||
- name: "PR-Task"
|
||||
description: "Release note category. A PR that is hidden from release note."
|
||||
color: "C2E0C6"
|
||||
- name: "Privacy"
|
||||
color: "f41192"
|
||||
- name: "Roadmap"
|
||||
color: "57457E"
|
||||
- name: "S-Critical"
|
||||
description: "Prevents work, causes data loss and/or has no workaround"
|
||||
color: "bd0026"
|
||||
- name: "S-Major"
|
||||
description: "Severely degrades major functionality or product features, with no satisfactory workaround"
|
||||
color: "fc4e2a"
|
||||
- name: "S-Minor"
|
||||
description: "Impairs non-critical functionality or suitable workarounds exist"
|
||||
color: "feb24c"
|
||||
- name: "S-Tolerable"
|
||||
description: "Low/no impact on users"
|
||||
color: "ffeda0"
|
||||
- name: "Security"
|
||||
color: "b3e5fc"
|
||||
- name: "storybook build"
|
||||
description: "Build and deploy the storybook frontend to netlify."
|
||||
color: "45cd61"
|
||||
- name: "T-Defect"
|
||||
description: "Something isn't working: bugs, crashes, hangs, vulnerabilities, or other reported problems"
|
||||
color: "98e6ae"
|
||||
- name: "T-Enhancement"
|
||||
description: "New features, changes in functionality, performance boosts, user-facing improvements"
|
||||
color: "98e6ae"
|
||||
- name: "T-Other"
|
||||
description: "Questions, user support, anything else"
|
||||
color: "98e6ae"
|
||||
- name: "T-Task"
|
||||
description: "Refactoring, enabling or disabling functionality, other engineering tasks"
|
||||
color: "98e6ae"
|
||||
- name: "X-Blocked"
|
||||
description: "Cannot be merged due to external dependencies"
|
||||
color: "ff7979"
|
||||
- name: "X-Cannot-Reproduce"
|
||||
description: "Needs reproduction steps"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Design"
|
||||
description: "May require input from the design team"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Info"
|
||||
description: "This issue is blocked awaiting information from the reporter"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Investigation"
|
||||
color: "ff7979"
|
||||
- name: "X-Needs-Product"
|
||||
description: "More input needed from the Product team"
|
||||
color: "ff7979"
|
||||
- name: "X-Regression"
|
||||
color: "ff7979"
|
||||
- name: "X-Release-Blocker"
|
||||
color: "ff7979"
|
||||
- name: "X-Spec-Changes"
|
||||
description: "May require spec changes"
|
||||
color: "ff7979"
|
||||
- name: "X-Won't-Fix"
|
||||
description: "This will not be worked on"
|
||||
color: "ff7979"
|
||||
- name: "Z-Community-Testing"
|
||||
description: "Issues found during the community testing sessions"
|
||||
color: "efefef"
|
||||
- name: "Z-Could"
|
||||
color: "ededed"
|
||||
- name: "Z-Design"
|
||||
color: "ededed"
|
||||
- name: "Z-Flaky-Test"
|
||||
color: "aaaaaa"
|
||||
- name: "Z-Media-Failure"
|
||||
description: "Someone's audio or video isn't coming through"
|
||||
color: "ededed"
|
||||
- name: "Z-Must"
|
||||
color: "ededed"
|
||||
- name: "Z-Platform-Specific"
|
||||
color: "ededed"
|
||||
- name: "Z-Power-Users"
|
||||
color: "ededed"
|
||||
- name: "Z-ProductPolish"
|
||||
color: "aaaaaa"
|
||||
- name: "Z-Should"
|
||||
color: "ededed"
|
||||
- name: "Z-Splitbrain"
|
||||
description: "Someone who should be on the call isn't showing up"
|
||||
color: "ededed"
|
||||
10
.github/workflows/lint.yaml
vendored
10
.github/workflows/lint.yaml
vendored
@@ -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
|
||||
|
||||
23
.github/workflows/sync-labels.yml
vendored
Normal file
23
.github/workflows/sync-labels.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Sync labels
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
paths:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
|
||||
with:
|
||||
LABELS: |
|
||||
.github/labels.yml
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/zizmor.yml
vendored
4
.github/workflows/zizmor.yml
vendored
@@ -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
6
.oxfmtrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
|
||||
}
|
||||
154
.oxlintrc.json
Normal file
154
.oxlintrc.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"$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",
|
||||
"element-call/no-top-level-logger-get-child": "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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm-lock.yaml
|
||||
node_modules
|
||||
dist
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
10
README.md
10
README.md
@@ -359,6 +359,16 @@ Usage and other technical details about the project can be found here:
|
||||
|
||||
[**Docs**](./docs/README.md)
|
||||
|
||||
## GitHub Labels
|
||||
|
||||
GitHub labels in this repository are maintained in the [`labels.yml`](.github/labels.yml) file and
|
||||
automatically synced to GitHub using the [`sync-labels` workflow](.github/workflows/sync-labels.yml).
|
||||
We do this so that we can reuse the labels between repositories.
|
||||
|
||||
> [!WARNING]
|
||||
> Do not manually edit labels in the GitHub UI. Any manual changes will be overridden by the
|
||||
> workflow on its next invocation.
|
||||
|
||||
## 📝 Copyright & License
|
||||
|
||||
Copyright 2021-2025 New Vector Ltd
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
targets: {
|
||||
node: "current",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
runtime: "automatic",
|
||||
},
|
||||
],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: ["babel-plugin-transform-vite-meta-env"],
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,9 @@ A few aspects of Element Call's interface can be controlled through a global API
|
||||
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
|
||||
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
|
||||
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
|
||||
- `controls.onPipMediaOrientationUpdate: ((orientation: "landscape"|"portrait") => void) | undefined` Callback called whenever the PiP media orientation changes.
|
||||
The client should track this value to already initiate the pip in the right orientation.
|
||||
It should update the orientation of the current Pip window when called.
|
||||
|
||||
## Audio devices
|
||||
|
||||
|
||||
@@ -67,9 +67,10 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `background` | One of: `solid`, `gradient` | No, defaults to `gradient` | No, defaults to `gradient` | Visual style of the page background. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
@@ -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
66
eslint/CopyrightHeader.js
Normal 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;
|
||||
78
eslint/NoObservableScopeLeak.js
Normal file
78
eslint/NoObservableScopeLeak.js
Normal 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;
|
||||
92
eslint/NoTopLevelLoggerGetChild.js
Normal file
92
eslint/NoTopLevelLoggerGetChild.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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";
|
||||
|
||||
/**
|
||||
* Node types that introduce a new non-module scope. A getChild() call nested
|
||||
* inside any of these is considered "not at the top level".
|
||||
*/
|
||||
const FUNCTION_OR_CLASS_TYPES = new Set([
|
||||
"FunctionDeclaration",
|
||||
"FunctionExpression",
|
||||
"ArrowFunctionExpression",
|
||||
"ClassBody",
|
||||
]);
|
||||
|
||||
const rule = ESLintUtils.RuleCreator(
|
||||
() => "https://github.com/element-hq/element-call",
|
||||
)({
|
||||
name: "no-top-level-logger-get-child",
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow calling logger.getChild() at the top level of a module." +
|
||||
"`getChild` has to be called after the rageshake logger `init()`." +
|
||||
"If it is called at the top level the child logger will never be setup for rageshakes.",
|
||||
},
|
||||
messages: {
|
||||
noTopLevelGetChild:
|
||||
"Do not call logger.getChild() at the top level of a module; move it inside a function or class instead that gets called after rageshake logger `init()` is called.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
// Tracks the local binding names that refer to the logger imported from
|
||||
// 'matrix-js-sdk/lib/logger', e.g. both `logger` and `rootLogger` in:
|
||||
// import { logger } from "matrix-js-sdk/lib/logger";
|
||||
// import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
const loggerNames = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== "matrix-js-sdk/lib/logger") return;
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === "ImportSpecifier" &&
|
||||
specifier.imported.name === "logger"
|
||||
) {
|
||||
loggerNames.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
// Must be a non-computed member expression call: something.getChild(...)
|
||||
if (
|
||||
node.callee.type !== "MemberExpression" ||
|
||||
node.callee.computed ||
|
||||
node.callee.property.type !== "Identifier" ||
|
||||
node.callee.property.name !== "getChild"
|
||||
)
|
||||
return;
|
||||
|
||||
// The receiver must be one of the tracked logger names.
|
||||
const object = node.callee.object;
|
||||
if (object.type !== "Identifier" || !loggerNames.has(object.name))
|
||||
return;
|
||||
|
||||
// Flag the call only when it is at module top level — i.e. there is no
|
||||
// enclosing function or class body anywhere in the ancestor chain.
|
||||
const ancestors = context.sourceCode.getAncestors(node);
|
||||
const isTopLevel = !ancestors.some((a) =>
|
||||
FUNCTION_OR_CLASS_TYPES.has(a.type),
|
||||
);
|
||||
|
||||
if (isTopLevel) {
|
||||
context.report({
|
||||
messageId: "noTopLevelGetChild",
|
||||
node,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default rule;
|
||||
8
eslint/index.js
Normal file
8
eslint/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"copyright-header": require("./CopyrightHeader").default,
|
||||
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
||||
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||
.default,
|
||||
},
|
||||
};
|
||||
4
eslint/package.json
Normal file
4
eslint/package.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "eslint-plugin-element-call",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { UserConfig } from "i18next-parser";
|
||||
|
||||
const config: UserConfig = {
|
||||
keySeparator: ".",
|
||||
namespaceSeparator: false,
|
||||
contextSeparator: "|",
|
||||
pluralSeparator: "_",
|
||||
createOldCatalogs: false,
|
||||
defaultNamespace: "app",
|
||||
lexers: {
|
||||
ts: [
|
||||
{
|
||||
lexer: "JavascriptLexer",
|
||||
functions: ["t", "translatedError"],
|
||||
namespaceFunctions: ["useTranslation", "withTranslation"],
|
||||
},
|
||||
],
|
||||
tsx: [
|
||||
{
|
||||
lexer: "JsxLexer",
|
||||
functions: ["t", "translatedError"],
|
||||
namespaceFunctions: ["useTranslation", "withTranslation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
locales: ["en"],
|
||||
output: "locales/$LOCALE/$NAMESPACE.json",
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
sort: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
i18next.config.ts
Normal file
20
i18next.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "i18next-cli";
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en"],
|
||||
extract: {
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
output: "locales/{{language}}/{{namespace}}.json",
|
||||
defaultNS: "app",
|
||||
keySeparator: ".",
|
||||
nsSeparator: false,
|
||||
contextSeparator: "|",
|
||||
extractFromComments: false,
|
||||
functions: ["t", "*.t", "translatedError", "i18nKey"],
|
||||
transComponents: ["Trans"],
|
||||
},
|
||||
types: {
|
||||
input: ["locales/{{language}}/{{namespace}}.json"],
|
||||
output: "src/types/i18next.d.ts",
|
||||
},
|
||||
});
|
||||
13
knip.ts
13
knip.ts
@@ -11,12 +11,14 @@ export default {
|
||||
vite: {
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
|
||||
},
|
||||
entry: ["src/main.tsx", "i18next-parser.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: [
|
||||
@@ -24,18 +26,15 @@ export default {
|
||||
"normalize.css",
|
||||
// Used for its global type declarations
|
||||
"@types/grecaptcha",
|
||||
// Because we use matrix-js-sdk as a Git dependency rather than consuming
|
||||
// the proper release artifacts, and also import directly from src/, we're
|
||||
// forced to re-install some of the types that it depends on even though
|
||||
// these look unused to Knip
|
||||
"@types/content-type",
|
||||
"@types/sdp-transform",
|
||||
"@types/uuid",
|
||||
// We obviously use this, but if the package has been linked with pnpm link,
|
||||
// then Knip will flag it as a false positive
|
||||
// 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;
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
|
||||
"room_creation_restricted": "Failed to create call",
|
||||
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
|
||||
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
|
||||
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
|
||||
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -203,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",
|
||||
|
||||
68
package.json
68
package.json
@@ -17,15 +17,15 @@
|
||||
"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": "i18next",
|
||||
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
|
||||
"i18n": "npx i18next-cli extract",
|
||||
"i18n:check": "npx i18next-cli extract --ci",
|
||||
"test": "vitest",
|
||||
"test:storybook": "vitest --project=storybook",
|
||||
"test:unit": "vitest --project=unit",
|
||||
@@ -40,10 +40,6 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.23.0",
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
@@ -51,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",
|
||||
@@ -64,12 +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/content-type": "^1.1.5",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -79,52 +73,39 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@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": "^4.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"@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-parser": "^9.1.0",
|
||||
"i18next-cli": "^1.61.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.86.0",
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"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",
|
||||
@@ -135,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",
|
||||
@@ -149,17 +129,5 @@
|
||||
"vitest": "^4.1.5",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@livekit/components-core>rxjs": "^7.8.1",
|
||||
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -125,21 +125,10 @@ async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
});
|
||||
|
||||
// There should be `count` video elements, visible and autoplaying
|
||||
await expect(page.locator("video")).toHaveCount(count);
|
||||
|
||||
await expect(async () => {
|
||||
const videoBlockCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(videoBlockCount).toBe(count);
|
||||
}).toPass({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("video").filter({ visible: true })).toHaveCount(
|
||||
count,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
|
||||
@@ -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" })
|
||||
@@ -368,25 +385,9 @@ export class TestHelpers {
|
||||
frame: FrameLocator,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) =>
|
||||
window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
.toBe(count);
|
||||
await expect(frame.locator("video").filter({ visible: true })).toHaveCount(
|
||||
count,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
8181
pnpm-lock.yaml
generated
8181
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,35 @@
|
||||
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
|
||||
onlyBuiltDependencies:
|
||||
- "matrix-js-sdk"
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@sentry/cli": true
|
||||
"@swc/core": true
|
||||
"core-js": true
|
||||
"esbuild": true
|
||||
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
|
||||
"protobufjs": true
|
||||
overrides:
|
||||
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
|
||||
# LiveKit has pinned it to a very specific version which is now holding us back from updating.
|
||||
# See livekit/components-js#1101 for a request for a proper solution.
|
||||
"@livekit/components-core>rxjs": "^7.8.1"
|
||||
# Dedupe Mediapipe dependencies.
|
||||
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18"
|
||||
# Security fix: https://security-tracker.debian.org/tracker/CVE-2026-31802
|
||||
"tar": "^7.5.11"
|
||||
# Security fixes:
|
||||
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
|
||||
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
|
||||
"minimatch": "^10.2.3"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
|
||||
"glob": "^10.5.0"
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/110
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/122
|
||||
"qs": "^6.14.1"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/106
|
||||
"js-yaml": "^4.1.1"
|
||||
# Storybook declares support for 0.27.0 only but empirically works fine with 0.28.0.
|
||||
"esbuild": "^0.28.0"
|
||||
# Multiple security fixes: https://github.com/nodejs/undici/releases/tag/v6.24.0
|
||||
"undici": "^6.24.0"
|
||||
# Security fix: https://github.com/advisories/GHSA-rf6f-7fwh-wjgh
|
||||
"flatted": "^3.4.2"
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
"ignoreDeps": ["eslint-plugin-matrix-org"],
|
||||
"ignoreDeps": [],
|
||||
"vulnerabilityAlerts": {
|
||||
"schedule": ["at any time"],
|
||||
"prHourlyLimit": 0,
|
||||
|
||||
@@ -15,9 +15,8 @@ import { scan } from "rxjs";
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
|
||||
14
sdk/main.ts
14
sdk/main.ts
@@ -52,7 +52,8 @@ import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
@@ -76,9 +77,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 +87,7 @@ interface MatrixRTCSdk {
|
||||
}[]
|
||||
>;
|
||||
/**
|
||||
* flattened local members
|
||||
* flattened local member
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
@@ -104,6 +105,7 @@ export async function createMatrixRTCSdk(
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
@@ -338,8 +340,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([
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -30,7 +30,7 @@ import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { getUrlParams, HeaderStyle } from "./UrlParams";
|
||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||
import { AppBar } from "./AppBar";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
@@ -41,19 +41,17 @@ interface SimpleProviderProps {
|
||||
|
||||
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { pathname } = useLocation();
|
||||
const { background } = useUrlParams();
|
||||
|
||||
useEffect(() => {
|
||||
let backgroundImage = "";
|
||||
if (!["/login", "/register"].includes(pathname) && !widget) {
|
||||
backgroundImage = "var(--background-gradient)";
|
||||
}
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.setAttribute("data-background", background);
|
||||
}, [pathname, background]);
|
||||
|
||||
document.getElementsByTagName("body")[0].style.backgroundImage =
|
||||
backgroundImage;
|
||||
}, [pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
return children;
|
||||
};
|
||||
|
||||
const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
useTheme();
|
||||
return children;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
});
|
||||
});
|
||||
|
||||
124
src/AppBar.tsx
124
src/AppBar.tsx
@@ -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.
|
||||
|
||||
@@ -47,7 +47,7 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
};
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: Error | unknown;
|
||||
error: unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -44,6 +45,11 @@ export enum HeaderStyle {
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
@@ -144,6 +150,10 @@ export interface UrlProperties {
|
||||
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
|
||||
*/
|
||||
theme: string | null;
|
||||
/**
|
||||
* The visual style of the page background.
|
||||
*/
|
||||
background: BackgroundStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,6 +461,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
fonts: parser.getAllParams("font"),
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
theme: parser.getParam("theme"),
|
||||
background:
|
||||
parser.getEnumParam("background", BackgroundStyle) ??
|
||||
BackgroundStyle.Gradient,
|
||||
viaServers: !isWidget ? parser.getParam("viaServers") : null,
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
@@ -494,7 +507,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
"intent:",
|
||||
intent,
|
||||
"\nproperties:",
|
||||
properties,
|
||||
redact(properties, "password"),
|
||||
"configuration:",
|
||||
configuration,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`QrCode > renders 1`] = `
|
||||
<div
|
||||
class="qrCode bar"
|
||||
class="_qrCode_458264 bar"
|
||||
>
|
||||
<img
|
||||
alt="QR Code"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details.
|
||||
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||
padding-block: var(--cpd-space-10x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.footer.hidden {
|
||||
|
||||
@@ -112,11 +112,17 @@ export interface FooterState {
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
className,
|
||||
ref,
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
@@ -292,7 +298,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="footer-container"
|
||||
className={classNames(styles.footer, {
|
||||
className={classNames(className, styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: !showFooter,
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Controls {
|
||||
canEnterPip(): boolean;
|
||||
enablePip(): void;
|
||||
disablePip(): void;
|
||||
onPipMediaOrientationUpdate?: (orientation: "landscape" | "portrait") => void;
|
||||
|
||||
setAvailableAudioDevices(devices: OutputDevice[]): void;
|
||||
setAudioDevice(id: string): void;
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
const logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
|
||||
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
private rtcSession?: MatrixRTCSession;
|
||||
|
||||
private logger: Logger;
|
||||
public constructor() {
|
||||
super({ ratchetWindowSize: 10, keyringSize: 256 });
|
||||
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
}
|
||||
|
||||
public setRTCSession(rtcSession: MatrixRTCSession): void {
|
||||
@@ -60,12 +60,12 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
encryptionKeyIndex,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
logger.error(
|
||||
this.logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 938 B |
@@ -1,48 +0,0 @@
|
||||
<svg width="1440" height="500" viewBox="0 0 1440 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80135)">
|
||||
<circle cx="720" cy="1620" r="1500" fill="url(#paint0_linear_4162_80135)"/>
|
||||
<circle cx="720" cy="1620" r="1498.92" stroke="white" stroke-opacity="0.5" stroke-width="2.16028" style="mix-blend-mode:overlay"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_4162_80135)">
|
||||
<circle cx="720" cy="1550.86" r="1272.86" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80135" x="-900" y="0" width="3240" height="3240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_4162_80135" x="-672.863" y="158" width="2785.73" height="2785.73" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80135" x1="549.5" y1="120" x2="549.5" y2="505.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#062993"/>
|
||||
<stop offset="0.040404" stop-color="#02389D"/>
|
||||
<stop offset="0.0808081" stop-color="#0045A6"/>
|
||||
<stop offset="0.121212" stop-color="#0051AD"/>
|
||||
<stop offset="0.161616" stop-color="#005DB4"/>
|
||||
<stop offset="0.20202" stop-color="#0069BA"/>
|
||||
<stop offset="0.242424" stop-color="#0075BB"/>
|
||||
<stop offset="0.282828" stop-color="#0081BB"/>
|
||||
<stop offset="0.323232" stop-color="#008CB9"/>
|
||||
<stop offset="0.363636" stop-color="#0098B7"/>
|
||||
<stop offset="0.40404" stop-color="#00A3B3"/>
|
||||
<stop offset="0.444444" stop-color="#00AEAD"/>
|
||||
<stop offset="0.484848" stop-color="#00B8A4"/>
|
||||
<stop offset="0.525253" stop-color="#00C2A0"/>
|
||||
<stop offset="0.565657" stop-color="#00CC99"/>
|
||||
<stop offset="0.606061" stop-color="#3AD396"/>
|
||||
<stop offset="0.646465" stop-color="#5DD898"/>
|
||||
<stop offset="0.686869" stop-color="#79DD99"/>
|
||||
<stop offset="0.727273" stop-color="#92E29B"/>
|
||||
<stop offset="0.767677" stop-color="#A8E69F"/>
|
||||
<stop offset="0.808081" stop-color="#BBEAA5"/>
|
||||
<stop offset="0.848485" stop-color="#CDEEAE"/>
|
||||
<stop offset="0.888889" stop-color="#DCF2B9"/>
|
||||
<stop offset="0.929293" stop-color="#EAF6C7"/>
|
||||
<stop offset="0.969697" stop-color="#F5FBD5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
86
src/graphics/mobile-gradient.svg
Normal file
86
src/graphics/mobile-gradient.svg
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1200"
|
||||
height="285"
|
||||
viewBox="0 0 1200 285"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g
|
||||
filter="url(#filter0_f_3970_9366)"
|
||||
id="g2"
|
||||
transform="translate(422.00201)">
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint0_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path1"
|
||||
style="stroke:url(#paint0_linear_3970_9366)" />
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint1_linear_3970_9366)"
|
||||
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path2" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs5">
|
||||
<filter
|
||||
id="filter0_f_3970_9366"
|
||||
x="-517.617"
|
||||
y="-0.00012207"
|
||||
width="1391.23"
|
||||
height="848.409"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood
|
||||
flood-opacity="0"
|
||||
result="BackgroundImageFix"
|
||||
id="feFlood2" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
id="feBlend2" />
|
||||
<feGaussianBlur
|
||||
stdDeviation="117.758"
|
||||
result="effect1_foregroundBlur_3970_9366"
|
||||
id="feGaussianBlur2" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -55,7 +55,6 @@ layer(compound);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--background-gradient: url("graphics/backgroundGradient.svg");
|
||||
|
||||
--call-view-overlay-layer: 1;
|
||||
--call-view-header-footer-layer: 2;
|
||||
@@ -74,9 +73,6 @@ layer(compound);
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
color: var(--cpd-color-text-primary);
|
||||
color-scheme: dark;
|
||||
margin: 0;
|
||||
@@ -85,6 +81,24 @@ body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-height: 330px) {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* This prohibits the view to scroll for pages smaller than 122px in width
|
||||
we use this for mobile pip webviews */
|
||||
.no-scroll-body {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
@@ -40,7 +40,6 @@ export interface MatrixAudioRendererProps {
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
/**
|
||||
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
@@ -60,6 +59,7 @@ export function LivekitRoomAudioRenderer({
|
||||
validIdentities,
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -80,7 +80,7 @@ export function LivekitRoomAudioRenderer({
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
prefixedLogger.warn(
|
||||
logger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,9 +26,9 @@ const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
||||
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
|
||||
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
|
||||
stopMicTrackOnMute: false,
|
||||
videoCodec: "h264",
|
||||
videoCodec: "vp8",
|
||||
videoEncoding: VideoPresets.h720.encoding,
|
||||
backupCodec: { codec: "h264", encoding: VideoPresets.h720.encoding },
|
||||
backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding },
|
||||
} as const;
|
||||
|
||||
export const defaultLiveKitOptions: RoomOptions = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.reactionIndicatorWidget {
|
||||
display: flex;
|
||||
background-color: #00000030;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
|
||||
background: "ffffff40";
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
background: var(--cpd-color-icon-secondary-alpha);
|
||||
backdrop-filter: blur(10px);
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-1));
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
.reaction {
|
||||
margin: var(--cpd-space-1x);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
LivekitConnectionError,
|
||||
MatrixRTCTransportMissingError,
|
||||
PeerConnectionTimeoutError,
|
||||
StickyEventsRequiredError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
@@ -59,6 +60,12 @@ test.each([
|
||||
expectedDescription:
|
||||
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||
},
|
||||
{
|
||||
error: new StickyEventsRequiredError(),
|
||||
expectedTitle: "Homeserver does not support Matrix 2.0 calls",
|
||||
expectedDescription:
|
||||
"This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
|
||||
},
|
||||
])(
|
||||
"should report correct error for $expectedTitle",
|
||||
async ({ error, expectedTitle, expectedDescription }) => {
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import { render, waitFor, screen, act } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import {
|
||||
type MatrixClient,
|
||||
JoinRule,
|
||||
type RoomState,
|
||||
UnsupportedStickyEventsEndpointError,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSession,
|
||||
@@ -46,6 +51,7 @@ import {
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
@@ -130,6 +136,9 @@ beforeEach(() => {
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
joined = true,
|
||||
options: {
|
||||
withErrorBoundary?: boolean;
|
||||
} = {},
|
||||
): {
|
||||
rtcSession: MatrixRTCSession;
|
||||
getByText: ReturnType<typeof render>["getByText"];
|
||||
@@ -166,24 +175,36 @@ function createGroupCallView(
|
||||
video: { enabled: false },
|
||||
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
|
||||
} as unknown as MuteStates;
|
||||
const groupCallView = (
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
);
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
/>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
@@ -251,7 +272,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Should close widget when all other left and have time to play a sound", async () => {
|
||||
test("Should close widget when all other left and play a sound", async () => {
|
||||
const user = userEvent.setup();
|
||||
let widgetClosedCalled = false;
|
||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
||||
@@ -289,8 +310,6 @@ test("Should close widget when all other left and have time to play a sound", as
|
||||
expect(widgetClosedCalled).toBeFalsy();
|
||||
resolvePlaySound.resolve();
|
||||
|
||||
// Expect the leave sound to be played but silent (volumeOverwrite = 0)
|
||||
// The allOthersLeft effect should already play a leave sound for the last user in the call.
|
||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||
await widgetClosedPromise;
|
||||
await flushPromises();
|
||||
@@ -298,37 +317,6 @@ test("Should close widget when all other left and have time to play a sound", as
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
}, 80000);
|
||||
|
||||
test("Should close widget when all other left", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widgetClosedCalled = Promise.withResolvers<void>();
|
||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
||||
if (action === ElementWidgetActions.Close) {
|
||||
widgetClosedCalled.resolve();
|
||||
}
|
||||
});
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
||||
transport: {
|
||||
send: widgetSendMock,
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
stop: widgetStopMock,
|
||||
} as unknown as ITransport,
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
|
||||
await widgetClosedCalled.promise;
|
||||
await flushPromises();
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Should not close widget when auto leave due to error", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -394,6 +382,46 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
|
||||
test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
|
||||
// Mirror the shape the SDK emits: the MembershipManager scheduler wraps
|
||||
// the original UnsupportedStickyEventsEndpointError in a generic Error
|
||||
// but preserves the original on `.cause`.
|
||||
const stickyError = new UnsupportedStickyEventsEndpointError(
|
||||
"Server does not support the sticky events",
|
||||
"sendStickyEvent",
|
||||
);
|
||||
const wrappedError = new Error(
|
||||
"The MembershipManager shut down because of the end condition: " +
|
||||
String(stickyError),
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
|
||||
);
|
||||
|
||||
await screen.findByText("Homeserver does not support Matrix 2.0 calls");
|
||||
});
|
||||
|
||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
await act(() =>
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
new Error("something else broke"),
|
||||
),
|
||||
);
|
||||
|
||||
await screen.findByText("Connection lost");
|
||||
});
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
|
||||
import {
|
||||
type MatrixClient,
|
||||
JoinRule,
|
||||
type Room,
|
||||
UnsupportedStickyEventsEndpointError,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
Room as LivekitRoom,
|
||||
isE2EESupported as isE2EESupportedBrowser,
|
||||
@@ -67,6 +72,7 @@ import {
|
||||
ConnectionLostError,
|
||||
E2EENotSupportedError,
|
||||
ElementCallError,
|
||||
StickyEventsRequiredError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
|
||||
@@ -162,8 +168,22 @@ export const GroupCallView: FC<Props> = ({
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
(error) => setExternalError(new ConnectionLostError()),
|
||||
(error) => {
|
||||
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
|
||||
// not advertise MSC4354 (sticky events), the SDK throws an
|
||||
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
|
||||
// scheduler wraps it and exposes the original via `.cause`.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.cause instanceof UnsupportedStickyEventsEndpointError
|
||||
) {
|
||||
setExternalError(new StickyEventsRequiredError());
|
||||
} else {
|
||||
setExternalError(new ConnectionLostError());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Sanity check the room object
|
||||
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
|
||||
@@ -533,11 +553,9 @@ export const GroupCallView: FC<Props> = ({
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={
|
||||
(/**error*/) => {
|
||||
if (rtcSession.isJoined()) onLeft("error");
|
||||
}
|
||||
}
|
||||
onError={(_error) => {
|
||||
if (rtcSession.isJoined()) onLeft("error");
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</GroupCallErrorBoundary>
|
||||
|
||||
@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Normally the footer uses a transparent background to allow our expressive
|
||||
page gradients to shine through. However, we sometimes need to visually separate
|
||||
it from the content underneath. If the call layout is overflowing, or if the
|
||||
spotlight tile is maximised and displaying video, apply a gradient background. */
|
||||
.overflowing > .footer,
|
||||
.fixedGrid:has(
|
||||
> .tile[data-maximised="true"]
|
||||
.spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"]
|
||||
)
|
||||
~ .footer {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
|
||||
/* Disable pointer events so the overlay doesn't block interaction with
|
||||
elements behind it */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fixedGrid > :not(:first-child),
|
||||
.scrollingGrid > :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
> :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.tile.maximised {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -93,8 +94,6 @@ declare module "react" {
|
||||
}
|
||||
}
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm"
|
||||
@@ -115,7 +114,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
logger.info("START CALL VIEW SCOPE");
|
||||
rootLogger.info("START CALL VIEW SCOPE");
|
||||
const scope = new ObservableScope();
|
||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
@@ -217,6 +216,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
muteStates,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
const { t } = useTranslation();
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
@@ -240,20 +240,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,12 +252,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const ringing = useBehavior(vm.ringing$);
|
||||
const ringingVm = useBehavior(vm.ringingVm$);
|
||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
@@ -286,22 +273,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
throw fatalCallError;
|
||||
}
|
||||
|
||||
// While ringing, loop the ringtone
|
||||
useEffect((): void | (() => void) => {
|
||||
const audio = latestPickupPhaseAudio.current;
|
||||
if (ringing && audio) {
|
||||
const endSound = audio.playSoundLooping(
|
||||
"waiting",
|
||||
audio.soundDuration["waiting"] ?? 1,
|
||||
);
|
||||
return () => {
|
||||
void endSound().catch((e) => {
|
||||
logger.error("Failed to stop ringing sound", e);
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [ringing, latestPickupPhaseAudio]);
|
||||
|
||||
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
|
||||
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
||||
// so this still only fires for taps.
|
||||
@@ -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,8 +465,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetHeight={targetHeight}
|
||||
showIndicators={showSpotlightIndicators}
|
||||
showNameTags={showNameTags}
|
||||
showRingingStatus={showRingingStatus}
|
||||
focusable={!contentObscured}
|
||||
className={classNames(className, styles.tile)}
|
||||
itemClassName={styles.spotlightItem}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
@@ -507,7 +493,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
className={styles.tile}
|
||||
itemClassName={styles.spotlightItem}
|
||||
data-maximised
|
||||
vm={layout.spotlight}
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
@@ -515,6 +503,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetHeight={gridBounds.height}
|
||||
showIndicators={false}
|
||||
showNameTags={showNameTags}
|
||||
showRingingStatus={vm.ringingStatusLocation === "tile"}
|
||||
focusable={!contentObscured}
|
||||
aria-hidden={contentObscured}
|
||||
/>
|
||||
@@ -598,7 +587,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = footerVm !== null && (
|
||||
<CallFooter ref={footerRef} vm={footerVm} />
|
||||
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
|
||||
);
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
@@ -607,7 +596,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
className={classNames(styles.inRoom, {
|
||||
[styles.overflowing]: overflowing,
|
||||
})}
|
||||
ref={containerRef}
|
||||
onPointerUp={onViewPointerUp}
|
||||
onPointerMove={onPointerMove}
|
||||
@@ -626,6 +617,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} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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$);
|
||||
|
||||
59
src/room/RingingAudioRenderer.test.tsx
Normal file
59
src/room/RingingAudioRenderer.test.tsx
Normal 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();
|
||||
});
|
||||
72
src/room/RingingAudioRenderer.tsx
Normal file
72
src/room/RingingAudioRenderer.tsx
Normal 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;
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
@@ -1720,20 +1720,20 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`should report correct error for 'Incompatible browser' 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,174 @@ 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"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.713 6.713A.97.97 0 0 1 16 7a.97.97 0 0 1-.713-.287A.97.97 0 0 1 15 6q0-.424.287-.713A.97.97 0 0 1 16 5q.424 0 .712.287Q17 5.576 17 6t-.288.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm12 2H6v4h12zM6 12v-2h12v2zm0 2v2h12v-2zm0 6v-2h12v2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Homeserver does not support Matrix 2.0 calls
|
||||
</h1>
|
||||
<p>
|
||||
This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.
|
||||
</p>
|
||||
<button
|
||||
class="_button_1nw83_8"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Return to home screen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="30"
|
||||
viewBox="0 0 260 30"
|
||||
width="260"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<desc>
|
||||
Element Call (Beta)
|
||||
</desc>
|
||||
<circle
|
||||
cx="15"
|
||||
cy="15"
|
||||
fill="white"
|
||||
r="13"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15C0 23.2843 6.71573 30 15 30ZM12.2579 6.98923C12.2579 6.38376 12.7497 5.89292 13.3565 5.89292C17.4687 5.89292 20.8024 9.21967 20.8024 13.3234C20.8024 13.9289 20.3106 14.4197 19.7038 14.4197C19.0971 14.4197 18.6052 13.9289 18.6052 13.3234C18.6052 10.4306 16.2553 8.08554 13.3565 8.08554C12.7497 8.08554 12.2579 7.59471 12.2579 6.98923ZM24.1066 13.3235C24.1066 12.7181 23.6148 12.2272 23.008 12.2272C22.4013 12.2272 21.9094 12.7181 21.9094 13.3235C21.9094 16.2163 19.5595 18.5614 16.6607 18.5614C16.0539 18.5614 15.5621 19.0523 15.5621 19.6577C15.5621 20.2632 16.0539 20.754 16.6607 20.754C20.7729 20.754 24.1066 17.4273 24.1066 13.3235ZM17.7601 23.011C17.7601 23.6164 17.2682 24.1073 16.6615 24.1073C12.5492 24.1073 9.21553 20.7805 9.21553 16.6768C9.21553 16.0713 9.70739 15.5805 10.3141 15.5805C10.9209 15.5805 11.4127 16.0713 11.4127 16.6768C11.4127 19.5696 13.7627 21.9146 16.6615 21.9146C17.2682 21.9146 17.7601 22.4055 17.7601 23.011ZM5.89281 16.6769C5.89281 17.2824 6.38466 17.7732 6.9914 17.7732C7.59813 17.7732 8.08999 17.2824 8.08999 16.6769C8.08999 13.7841 10.4399 11.439 13.3388 11.439C13.9455 11.439 14.4373 10.9482 14.4373 10.3427C14.4373 9.73722 13.9455 9.24639 13.3388 9.24639C9.22647 9.24639 5.89281 12.5731 5.89281 16.6769Z"
|
||||
fill="#0DBD8B"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M53.5406 17.258H42.8052C42.932 18.3814 43.3397 19.2782 44.0282 19.9486C44.7167 20.6009 45.6227 20.927 46.746 20.927C47.4889 20.927 48.1593 20.7459 48.7572 20.3835C49.3551 20.0211 49.7809 19.5319 50.0346 18.9159H53.296C52.8611 20.3472 52.0458 21.5068 50.8499 22.3947C49.6722 23.2644 48.2771 23.6992 46.6645 23.6992C44.5627 23.6992 42.8596 23.0016 41.555 21.6065C40.2686 20.2114 39.6254 18.4448 39.6254 16.3068C39.6254 14.2231 40.2776 12.4747 41.5822 11.0614C42.8867 9.64814 44.5718 8.94151 46.6373 8.94151C48.7029 8.94151 50.3698 9.63908 51.6381 11.0342C52.9245 12.4112 53.5677 14.1506 53.5677 16.2524L53.5406 17.258ZM46.6373 11.5778C45.6227 11.5778 44.7801 11.8767 44.1098 12.4747C43.4394 13.0726 43.0226 13.8698 42.8596 14.8663H50.3607C50.2158 13.8698 49.8172 13.0726 49.1649 12.4747C48.5126 11.8767 47.6701 11.5778 46.6373 11.5778Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M55.7934 19.1605V2.9895H59.0276V19.2148C59.0276 19.9396 59.4262 20.302 60.2234 20.302L60.7941 20.2748V23.3459C60.4861 23.4003 60.16 23.4274 59.8157 23.4274C58.4206 23.4274 57.3969 23.0741 56.7446 22.3675C56.1104 21.6609 55.7934 20.5919 55.7934 19.1605Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M75.8563 17.258H65.121C65.2478 18.3814 65.6555 19.2782 66.344 19.9486C67.0325 20.6009 67.9384 20.927 69.0618 20.927C69.8047 20.927 70.4751 20.7459 71.073 20.3835C71.6709 20.0211 72.0967 19.5319 72.3503 18.9159H75.6117C75.1769 20.3472 74.3615 21.5068 73.1657 22.3947C71.988 23.2644 70.5928 23.6992 68.9803 23.6992C66.8785 23.6992 65.1753 23.0016 63.8708 21.6065C62.5843 20.2114 61.9411 18.4448 61.9411 16.3068C61.9411 14.2231 62.5934 12.4747 63.898 11.0614C65.2025 9.64814 66.8875 8.94151 68.9531 8.94151C71.0186 8.94151 72.6855 9.63908 73.9539 11.0342C75.2403 12.4112 75.8835 14.1506 75.8835 16.2524L75.8563 17.258ZM68.9531 11.5778C67.9384 11.5778 67.0959 11.8767 66.4255 12.4747C65.7551 13.0726 65.3384 13.8698 65.1753 14.8663H72.6765C72.5315 13.8698 72.1329 13.0726 71.4806 12.4747C70.8284 11.8767 69.9859 11.5778 68.9531 11.5778Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M90.448 15.274V23.3731H87.2138V14.9207C87.2138 12.7827 86.326 11.7137 84.5503 11.7137C83.59 11.7137 82.82 12.0217 82.2402 12.6377C81.6785 13.2538 81.3977 14.0963 81.3977 15.1653V23.3731H78.1635V9.26764H81.1531V11.1429C81.4973 10.5088 82.0228 9.98333 82.7294 9.5666C83.436 9.14987 84.3148 8.94151 85.3657 8.94151C87.3225 8.94151 88.7358 9.68438 89.6055 11.1701C90.8013 9.68438 92.3958 8.94151 94.3888 8.94151C96.0376 8.94151 97.3059 9.45789 98.1937 10.4907C99.0816 11.5053 99.5255 12.8461 99.5255 14.513V23.3731H96.2913V14.9207C96.2913 12.7827 95.4035 11.7137 93.6278 11.7137C92.6494 11.7137 91.8703 12.0308 91.2905 12.6649C90.7288 13.2809 90.448 14.1506 90.448 15.274Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M115.61 17.258H104.874C105.001 18.3814 105.409 19.2782 106.097 19.9486C106.786 20.6009 107.692 20.927 108.815 20.927C109.558 20.927 110.228 20.7459 110.826 20.3835C111.424 20.0211 111.85 19.5319 112.104 18.9159H115.365C114.93 20.3472 114.115 21.5068 112.919 22.3947C111.741 23.2644 110.346 23.6992 108.734 23.6992C106.632 23.6992 104.929 23.0016 103.624 21.6065C102.338 20.2114 101.694 18.4448 101.694 16.3068C101.694 14.2231 102.347 12.4747 103.651 11.0614C104.956 9.64814 106.641 8.94151 108.706 8.94151C110.772 8.94151 112.439 9.63908 113.707 11.0342C114.994 12.4112 115.637 14.1506 115.637 16.2524L115.61 17.258ZM108.706 11.5778C107.692 11.5778 106.849 11.8767 106.179 12.4747C105.508 13.0726 105.092 13.8698 104.929 14.8663H112.43C112.285 13.8698 111.886 13.0726 111.234 12.4747C110.582 11.8767 109.739 11.5778 108.706 11.5778Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M120.906 9.26764V11.1429C121.232 10.5269 121.767 10.0105 122.51 9.59378C123.271 9.15893 124.186 8.94151 125.255 8.94151C126.922 8.94151 128.208 9.44883 129.114 10.4635C130.038 11.4781 130.5 12.828 130.5 14.513V23.3731H127.266V14.9207C127.266 13.9242 127.03 13.1451 126.559 12.5834C126.106 12.0036 125.409 11.7137 124.467 11.7137C123.434 11.7137 122.619 12.0217 122.021 12.6377C121.441 13.2538 121.151 14.1053 121.151 15.1925V23.3731H117.917V9.26764H120.906Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M139.946 20.4922V23.2915C139.547 23.4003 138.985 23.4546 138.261 23.4546C135.507 23.4546 134.13 22.0685 134.13 19.2964V11.8496H131.982V9.26764H134.13V5.5986H137.364V9.26764H140V11.8496H137.364V18.9702C137.364 20.0755 137.889 20.6281 138.94 20.6281L139.946 20.4922Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M148.304 20.864C146.768 19.184 146 17.056 146 14.48C146 11.904 146.768 9.784 148.304 8.12C149.856 6.44 151.896 5.6 154.424 5.6C156.504 5.6 158.264 6.176 159.704 7.328C161.144 8.48 162.064 10.024 162.464 11.96H160.616C160.28 10.52 159.552 9.376 158.432 8.528C157.312 7.68 155.976 7.256 154.424 7.256C152.44 7.256 150.84 7.92 149.624 9.248C148.424 10.576 147.824 12.32 147.824 14.48C147.824 16.64 148.424 18.384 149.624 19.712C150.84 21.04 152.44 21.704 154.424 21.704C155.976 21.704 157.312 21.28 158.432 20.432C159.552 19.584 160.28 18.44 160.616 17H162.464C162.064 18.936 161.144 20.48 159.704 21.632C158.264 22.784 156.504 23.36 154.424 23.36C151.896 23.36 149.856 22.528 148.304 20.864Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M173.63 17.192C171.438 17.192 169.942 17.24 169.142 17.336C168.358 17.416 167.782 17.552 167.414 17.744C166.758 18.112 166.43 18.704 166.43 19.52C166.43 21.088 167.358 21.872 169.214 21.872C170.638 21.872 171.726 21.552 172.478 20.912C173.246 20.272 173.63 19.416 173.63 18.344V17.192ZM169.022 23.288C167.63 23.288 166.566 22.952 165.83 22.28C165.11 21.592 164.75 20.688 164.75 19.568C164.75 18.832 164.942 18.176 165.326 17.6C165.726 17.024 166.27 16.6 166.958 16.328C167.534 16.104 168.278 15.96 169.19 15.896C170.102 15.816 171.582 15.776 173.63 15.776V14.984C173.63 12.968 172.478 11.96 170.174 11.96C168.19 11.96 167.038 12.768 166.718 14.384H165.062C165.238 13.2 165.742 12.256 166.574 11.552C167.422 10.848 168.646 10.496 170.246 10.496C171.958 10.496 173.23 10.896 174.062 11.696C174.91 12.496 175.334 13.6 175.334 15.008V23H173.702V21.224C172.854 22.6 171.294 23.288 169.022 23.288Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M179.418 20.312V5H181.122V20.12C181.122 20.616 181.202 20.96 181.362 21.152C181.538 21.344 181.85 21.44 182.298 21.44L182.778 21.392V22.952C182.506 23 182.21 23.024 181.89 23.024C180.242 23.024 179.418 22.12 179.418 20.312Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M185.582 20.312V5H187.286V20.12C187.286 20.616 187.366 20.96 187.526 21.152C187.702 21.344 188.014 21.44 188.462 21.44L188.942 21.392V22.952C188.67 23 188.374 23.024 188.054 23.024C186.406 23.024 185.582 22.12 185.582 20.312Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M201 10C201 5.58172 204.582 2 209 2H252C256.418 2 260 5.58172 260 10V20C260 24.4183 256.418 28 252 28H209C204.582 28 201 24.4183 201 20V10Z"
|
||||
fill="#368BD6"
|
||||
/>
|
||||
<path
|
||||
d="M212.076 20H216.492C218.99 20 220.215 18.7269 220.215 17.0277C220.215 15.3764 219.043 14.407 217.882 14.3484V14.2418C218.947 13.9915 219.789 13.2457 219.789 11.9194C219.789 10.2947 218.617 9.09091 216.252 9.09091H212.076V20ZM214.052 18.3487V15.1527H216.231C217.451 15.1527 218.207 15.8984 218.207 16.8732C218.207 17.7415 217.61 18.3487 216.178 18.3487H214.052ZM214.052 13.7305V10.7209H216.05C217.211 10.7209 217.813 11.3335 217.813 12.1751C217.813 13.1339 217.035 13.7305 216.007 13.7305H214.052ZM221.934 20H229.072V18.3434H223.911V15.3658H228.662V13.7092H223.911V10.7475H229.03V9.09091H221.934V20ZM230.566 10.7475H233.938V20H235.898V10.7475H239.27V9.09091H230.566V10.7475ZM241.031 20L241.931 17.31H246.032L246.938 20H249.047L245.201 9.09091H242.762L238.921 20H241.031ZM242.464 15.7227L243.939 11.3281H244.024L245.5 15.7227H242.464Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1872,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="/"
|
||||
>
|
||||
@@ -1964,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"
|
||||
>
|
||||
|
||||
@@ -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_4e7ff8 _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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,7 +149,7 @@ class IndexedDBLogStore {
|
||||
* @return Resolves when the store is ready.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
const req = this.indexedDB.open("logs");
|
||||
const req = this.indexedDB.open("logs-element-call");
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = (): void => {
|
||||
this.db = req.result;
|
||||
@@ -467,7 +467,7 @@ declare global {
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initStoragePromise: Promise<void> | undefined;
|
||||
}
|
||||
|
||||
export let rageshakeLogger: Logger;
|
||||
/**
|
||||
* Configure rage shaking support for sending bug reports.
|
||||
* Modifies globals.
|
||||
@@ -477,7 +477,8 @@ export async function init(): Promise<void> {
|
||||
global.mx_rage_logger = new ConsoleLogger();
|
||||
|
||||
// configure loglevel based loggers:
|
||||
setLogExtension(logger, global.mx_rage_logger.log);
|
||||
rageshakeLogger = logger;
|
||||
setLogExtension(rageshakeLogger, global.mx_rage_logger.log);
|
||||
|
||||
// intercept console logging so that we can get matrix_sdk logs:
|
||||
// this is nasty, but no logging hooks are provided
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -5,17 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
type IEvent,
|
||||
type IRoomTimelineData,
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
import {
|
||||
alice,
|
||||
aliceRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
@@ -23,9 +25,10 @@ import {
|
||||
import {
|
||||
type CallNotificationWrapper,
|
||||
createCallNotificationLifecycle$,
|
||||
type Props as CallNotificationLifecycleProps,
|
||||
type RingAttempt,
|
||||
} from "./CallNotificationLifecycle";
|
||||
import { trackEpoch } from "../ObservableScope";
|
||||
import { Epoch, trackEpoch } from "../ObservableScope";
|
||||
import { constant } from "../Behavior";
|
||||
|
||||
function mockRingEvent(
|
||||
eventId: string,
|
||||
@@ -40,311 +43,272 @@ function mockRingEvent(
|
||||
} as unknown as CallNotificationWrapper;
|
||||
}
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
it("unknown -> ringing -> timeout when notified and nobody joins", () => {
|
||||
withTestScheduler(({ scope, expectObservable, behavior, hot }) => {
|
||||
// No one ever joins (only local user)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", { a: [] }).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const defaultProps = {
|
||||
memberships$: constant(new Epoch([])),
|
||||
matrixRoomMembers$: constant(new Map([[alice.userId, alice]])),
|
||||
receivedDecline$: NEVER,
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
function summarizeRingAttempts$(
|
||||
ringAttempts$: Observable<RingAttempt>,
|
||||
): Observable<
|
||||
| { intent: RTCCallIntent; recipient: string }
|
||||
| { outcome: "accept" | "decline" | "timeout" }
|
||||
> {
|
||||
return ringAttempts$.pipe(
|
||||
mergeMap(({ intent, recipient, outcome$ }) =>
|
||||
outcome$.pipe(
|
||||
map((outcome) => ({ outcome })),
|
||||
startWith({ intent, recipient }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "timeout",
|
||||
});
|
||||
test("no ring attempt when waitForCallPickup=false", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
options: { ...defaultProps.options, waitForCallPickup: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("ringing -> success if someone joins before timeout is reached", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a 19ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("5ms a", {
|
||||
a: mockRingEvent("$notif2", 100),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("success when someone joins before we notify", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a 9ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("20ms a", {
|
||||
a: mockRingEvent("$notif2", 50),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("notify without lifetime -> immediate timeout", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$notif2", undefined),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "timeout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("stays null when waitForCallPickup=false", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const validProps: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a--b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$notif5", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const propsDeactivated = {
|
||||
...validProps,
|
||||
options: {
|
||||
...validProps.options,
|
||||
waitForCallPickup: false,
|
||||
},
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("n", {
|
||||
n: null,
|
||||
});
|
||||
const lifecycleReference = createCallNotificationLifecycle$(validProps);
|
||||
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
|
||||
u: "unknown",
|
||||
s: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("decline before timeout window ends -> decline", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$decl1", 50),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$decl1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
e: "decline",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("decline after timeout window ends -> stays timeout", () => {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$decl", 20),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$decl",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
|
||||
"a 9ms b 19ms e",
|
||||
{
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
e: "timeout",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
//
|
||||
function testStaysRinging(
|
||||
declineEvent: Partial<IEvent>,
|
||||
expectDecline: boolean,
|
||||
): void {
|
||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
const props: CallNotificationLifecycleProps = {
|
||||
scope,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a", {
|
||||
a: [localRtcMember],
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: mockRingEvent("$right", 50),
|
||||
}),
|
||||
receivedDecline$: hot("20ms d", {
|
||||
d: [
|
||||
new MatrixEvent(declineEvent),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
options: {
|
||||
waitForCallPickup: true,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
},
|
||||
localUser: localRtcMember,
|
||||
};
|
||||
const lifecycle = createCallNotificationLifecycle$(props);
|
||||
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
|
||||
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
d: "decline",
|
||||
});
|
||||
});
|
||||
}
|
||||
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
|
||||
event_id: "$decline",
|
||||
type: EventType.RTCDecline,
|
||||
sender: sender ?? "@other:example.org",
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: refId ?? "$right",
|
||||
},
|
||||
},
|
||||
});
|
||||
it("decline reference works", () => {
|
||||
testStaysRinging(reference(), true);
|
||||
});
|
||||
it("decline with wrong id is ignored (stays ringing)", () => {
|
||||
testStaysRinging(reference("$wrong"), false);
|
||||
});
|
||||
it("decline with wrong id is ignored (stays ringing)", () => {
|
||||
testStaysRinging(reference(undefined, local.userId), false);
|
||||
expectObservable(ringAttempts$).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("no ring attempt when notification type is not ring", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: {
|
||||
...mockRingEvent("$notif1", 30),
|
||||
notification_type: "notification",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(ringAttempts$).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("no ring attempt if lifetime is missing", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", undefined),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(ringAttempts$).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt times out after nobody joins", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
// No one ever joins (only local user)
|
||||
memberships$: constant(new Epoch([])),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt is accepted once recipient joins", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot, behavior }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberships$: scope.behavior(
|
||||
behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt is immediately accepted if recipient is already joined", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberships$: constant(new Epoch([aliceRtcMember])),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt can be declined", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "decline" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt times out if recipient declines too late", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("100ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("decline event relating to wrong event is ignored (times out)", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$other", // <---- WRONG
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("decline event from wrong sender is ignored (times out)", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: local.userId, // <---- WRONG
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,24 +10,22 @@ import {
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
concat,
|
||||
endWith,
|
||||
filter,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timer,
|
||||
EMPTY,
|
||||
race,
|
||||
take,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type EventTimelineSetHandlerMap,
|
||||
@@ -35,18 +33,26 @@ import {
|
||||
type Room as MatrixRoom,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
export type CallPickupState =
|
||||
| "unknown"
|
||||
| "ringing"
|
||||
| "timeout"
|
||||
| "decline"
|
||||
| "success"
|
||||
| null;
|
||||
|
||||
export interface RingAttempt {
|
||||
intent: RTCCallIntent;
|
||||
/**
|
||||
* The user ID of the recipient being rung.
|
||||
*/
|
||||
recipient: string;
|
||||
/**
|
||||
* The eventual outcome of the ringing attempt. (Emits a single value.)
|
||||
*/
|
||||
// TODO: Include a callback for attempting ringing again in case of a timeout
|
||||
outcome$: Observable<"accept" | "decline" | "timeout">;
|
||||
}
|
||||
|
||||
export type CallNotificationWrapper = {
|
||||
event_id: string;
|
||||
@@ -76,6 +82,7 @@ export function createReceivedDecline$(
|
||||
export interface Props {
|
||||
scope: ObservableScope;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
matrixRoomMembers$: Behavior<RoomMemberMap>;
|
||||
sentCallNotification$: Observable<CallNotificationWrapper | null>;
|
||||
receivedDecline$: Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
@@ -84,34 +91,86 @@ export interface Props {
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns two observables:
|
||||
* `callPickupState$` The current call pickup state of the call.
|
||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||
* Then we can conclude if we were the first one to join or not.
|
||||
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
|
||||
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
|
||||
* The call failed. If desired this can be used as a trigger to exit the call.
|
||||
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
|
||||
* - null: EC is configured to never show any waiting for answer state.
|
||||
*
|
||||
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
|
||||
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
|
||||
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
|
||||
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
|
||||
*
|
||||
*/
|
||||
export function createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$,
|
||||
matrixRoomMembers$,
|
||||
sentCallNotification$,
|
||||
receivedDecline$,
|
||||
options,
|
||||
localUser,
|
||||
}: Props): {
|
||||
callPickupState$: Behavior<CallPickupState>;
|
||||
/**
|
||||
* An observable of attempts to ring the remote participant's devices.
|
||||
*/
|
||||
ringAttempts$: Observable<RingAttempt>;
|
||||
/**
|
||||
* An observable that emits when the call should be automatically left.
|
||||
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
|
||||
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
|
||||
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
|
||||
*/
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
} {
|
||||
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
|
||||
let ringAttempts$: Observable<RingAttempt> = NEVER;
|
||||
if (options.waitForCallPickup)
|
||||
ringAttempts$ = sentCallNotification$.pipe(
|
||||
filter(
|
||||
(
|
||||
notificationEvent: CallNotificationWrapper | null,
|
||||
): notificationEvent is CallNotificationWrapper =>
|
||||
// only care about new events (legacy do not have decline pattern)
|
||||
notificationEvent?.notification_type === "ring" &&
|
||||
notificationEvent.lifetime > 0,
|
||||
),
|
||||
switchMap((notificationEvent) => {
|
||||
// We assume that there is only one other user in the room when ringing
|
||||
// TODO: Respect io.element.functional_members
|
||||
const recipient = [...matrixRoomMembers$.value.keys()].find(
|
||||
(userId) => userId !== localUser.userId,
|
||||
);
|
||||
if (recipient === undefined) {
|
||||
logger.warn("No recipient for notification event; not ringing.");
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
// Ringing times out after lifetime ms have passed
|
||||
const timeout$ = timer(notificationEvent.lifetime).pipe(
|
||||
map(() => "timeout" as const),
|
||||
);
|
||||
// Call is accepted when the recipient joins
|
||||
const accept$ = memberships$.pipe(
|
||||
filter((ms) => ms.value.some((m) => m.userId === recipient)),
|
||||
map(() => "accept" as const),
|
||||
);
|
||||
// Call is declined when we receive a decline event
|
||||
const decline$ = receivedDecline$.pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id === notificationEvent.event_id &&
|
||||
event.getSender() === recipient,
|
||||
),
|
||||
map(() => "decline" as const),
|
||||
);
|
||||
|
||||
return of({
|
||||
intent: notificationEvent["m.call.intent"] ?? "audio",
|
||||
recipient,
|
||||
outcome$: race(timeout$, accept$, decline$).pipe(
|
||||
take(1),
|
||||
// Make this observable 'hot' to avoid running multiple timers. This
|
||||
// is not actually a resource leak since there will be at most one
|
||||
// active ring attempt at any given time.
|
||||
// eslint-disable-next-line element-call/no-observablescope-leak
|
||||
scope.share,
|
||||
),
|
||||
});
|
||||
}),
|
||||
scope.share,
|
||||
);
|
||||
|
||||
const allOthersLeft$ = memberships$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
@@ -122,87 +181,18 @@ export function createCallNotificationLifecycle$({
|
||||
map(() => {}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether some Matrix user other than ourself is joined to the call.
|
||||
*/
|
||||
const someoneElseJoined$ = memberships$.pipe(
|
||||
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
|
||||
) as Behavior<Epoch<boolean>>;
|
||||
|
||||
/**
|
||||
* The state of the current ringing attempt, if the RTC session is indeed
|
||||
* ringing the remote participant's devices. Otherwise `null`.
|
||||
*/
|
||||
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
|
||||
scope.behavior(
|
||||
sentCallNotification$.pipe(
|
||||
filter(
|
||||
(notificationEventArgs: CallNotificationWrapper | null) =>
|
||||
// only care about new events (legacy do not have decline pattern)
|
||||
notificationEventArgs?.notification_type === "ring",
|
||||
),
|
||||
map((e) => e as CallNotificationWrapper),
|
||||
switchMap((notificationEvent) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
of(null)
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetimeMs).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has likely
|
||||
// stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
// This makes sure we will not drop into the `endWith("decline" as const)` state
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(
|
||||
receivedDecline$.pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id ===
|
||||
notificationEvent.event_id &&
|
||||
event.getSender() !== localUser.userId &&
|
||||
callPickupState$.value !== "timeout",
|
||||
),
|
||||
),
|
||||
),
|
||||
endWith("decline" as const),
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const callPickupState$ = scope.behavior(
|
||||
options.waitForCallPickup === true
|
||||
? combineLatest(
|
||||
[someoneElseJoined$, remoteRingState$],
|
||||
(someoneElseJoined, ring) => {
|
||||
if (someoneElseJoined.value === true) {
|
||||
return "success" as const;
|
||||
}
|
||||
// Show the ringing state of the most recent ringing attempt.
|
||||
// as long as we have not yet sent an RTC notification event or noone else joined,
|
||||
// ring will be null -> callPickupState$ = unknown.
|
||||
return ring ?? ("unknown" as const);
|
||||
},
|
||||
)
|
||||
: NEVER,
|
||||
null,
|
||||
);
|
||||
|
||||
const autoLeave$ = merge(
|
||||
options.autoLeaveWhenOthersLeft === true
|
||||
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
|
||||
: NEVER,
|
||||
callPickupState$.pipe(
|
||||
filter((state) => state === "timeout" || state === "decline"),
|
||||
ringAttempts$.pipe(
|
||||
switchMap(({ outcome$ }) =>
|
||||
outcome$.pipe(
|
||||
filter((outcome) => outcome === "timeout" || outcome === "decline"),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return { autoLeave$, callPickupState$ };
|
||||
|
||||
return { ringAttempts$, autoLeave$ };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi, onTestFinished, it, describe } from "vitest";
|
||||
import { test, vi, onTestFinished, it, describe, expect } from "vitest";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
@@ -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";
|
||||
@@ -899,6 +900,41 @@ describe.each([
|
||||
},
|
||||
);
|
||||
|
||||
// TODO add media to lk mocks
|
||||
test("onPipMediaOrientationUpdate is called with the spotlight media orientation", () => {
|
||||
// Set the spy before creating the view model so the initial call is captured
|
||||
const onPipMediaOrientationUpdate = vi.fn();
|
||||
window.controls.onPipMediaOrientationUpdate = onPipMediaOrientationUpdate;
|
||||
onTestFinished(() => {
|
||||
window.controls.onPipMediaOrientationUpdate = undefined;
|
||||
});
|
||||
|
||||
withTestScheduler(({ behavior }) => {
|
||||
// Alice starts as a regular participant, then shares her screen, then stops
|
||||
const aliceSharingInputMarbles = "nyn";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
sharingScreen: new Map([
|
||||
[aliceParticipant, behavior(aliceSharingInputMarbles, yesNo)],
|
||||
]),
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
// Should be called exactly 3 times:
|
||||
// 1. Initially with "portrait" (Alice is in spotlight as a user, default portrait orientation)
|
||||
// 2. With "landscape" when Alice starts screen sharing (screen shares always use landscape)
|
||||
// 3. With "portrait" again when Alice stops screen sharing and returns to user tile
|
||||
expect(onPipMediaOrientationUpdate).toHaveBeenCalledTimes(3);
|
||||
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(1, "portrait");
|
||||
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(2, "landscape");
|
||||
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(3, "portrait");
|
||||
});
|
||||
|
||||
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Switch to spotlight immediately
|
||||
@@ -996,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, {
|
||||
@@ -1025,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
|
||||
@@ -1061,7 +1128,7 @@ describe.each([
|
||||
b: {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pip: undefined,
|
||||
},
|
||||
c: {
|
||||
type: "grid",
|
||||
@@ -1385,8 +1452,14 @@ describe.each([
|
||||
},
|
||||
});
|
||||
|
||||
// Should ring for 30ms and then time out
|
||||
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo);
|
||||
expectObservable(vm.ringingVm$).toBe("(ab)", {
|
||||
a: null,
|
||||
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)
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
@@ -1425,7 +1498,14 @@ describe.each([
|
||||
});
|
||||
|
||||
// Should ring until Alice joins
|
||||
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo);
|
||||
expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", {
|
||||
a: null,
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: "audio",
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
// ringing the entire time
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
pairwise,
|
||||
race,
|
||||
scan,
|
||||
skipWhile,
|
||||
startWith,
|
||||
Subject,
|
||||
switchAll,
|
||||
@@ -39,8 +38,9 @@ import {
|
||||
tap,
|
||||
throttleTime,
|
||||
timer,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
@@ -125,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,
|
||||
@@ -156,8 +155,8 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
// Larger rename
|
||||
// member,membership -> rtcMember
|
||||
@@ -208,6 +207,7 @@ export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
@@ -230,9 +230,13 @@ export interface CallViewModel {
|
||||
// lifecycle
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
/**
|
||||
* Whether we are ringing a call recipient.
|
||||
* View model for info relating to ringing, timing out, calling back, etc.
|
||||
*/
|
||||
ringing$: Behavior<boolean>;
|
||||
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
|
||||
@@ -300,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>>;
|
||||
@@ -356,6 +360,10 @@ export interface CallViewModel {
|
||||
* and header as overlays.
|
||||
*/
|
||||
edgeToEdge$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the call layout is overflowing the interface (causing it to scroll).
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
@@ -407,6 +415,7 @@ export function createCallViewModel$(
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
trackProcessorState$: Behavior<ProcessorState>,
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const client = matrixRoom.client;
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
@@ -416,6 +425,7 @@ export function createCallViewModel$(
|
||||
const livekitKeyProvider = getE2eeKeyProvider(
|
||||
options.encryptionSystem,
|
||||
matrixRTCSession,
|
||||
logger,
|
||||
);
|
||||
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
|
||||
// It is validated at config load (src/config/Config.ts) so the cast is safe.
|
||||
@@ -527,13 +537,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(
|
||||
@@ -610,19 +622,12 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// callLifecycle
|
||||
|
||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
||||
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
|
||||
receivedDecline$: createReceivedDecline$(matrixRoom),
|
||||
options: options,
|
||||
localUser: { userId: userId, deviceId: deviceId },
|
||||
});
|
||||
const matrixLivekitMembers$ = scope.behavior(
|
||||
combineLatest(
|
||||
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
|
||||
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// matrixMemberMetadataStore
|
||||
@@ -634,11 +639,26 @@ export function createCallViewModel$(
|
||||
matrixRoomMembers$,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// callLifecycle
|
||||
|
||||
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
|
||||
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
|
||||
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$,
|
||||
matrixRoomMembers$,
|
||||
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
|
||||
receivedDecline$: createReceivedDecline$(matrixRoom),
|
||||
options,
|
||||
localUser: { userId, deviceId },
|
||||
});
|
||||
|
||||
const allConnections$ = scope.behavior(
|
||||
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
||||
);
|
||||
const livekitRoomItems$ = scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
remoteMatrixLivekitMembers$.pipe(
|
||||
switchMap((members) => {
|
||||
const a$ = combineLatest(
|
||||
members.value.map((member) =>
|
||||
@@ -704,43 +724,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],
|
||||
@@ -766,11 +763,13 @@ export function createCallViewModel$(
|
||||
pretendToBeDisconnected$: localMembership.reconnecting$,
|
||||
displayName$: scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.createDisplayNameBehavior$(scope, userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
userId,
|
||||
),
|
||||
handRaised$: scope.behavior(
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
),
|
||||
@@ -782,49 +781,42 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
|
||||
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
|
||||
generateItems(
|
||||
"CallViewModel ringingMedia$",
|
||||
function* ([userMedia, roomMembers, callPickupState]) {
|
||||
if (
|
||||
callPickupState === "ringing" ||
|
||||
callPickupState === "timeout" ||
|
||||
callPickupState === "decline"
|
||||
) {
|
||||
// TODO: Respect io.element.functional_members
|
||||
for (const member of roomMembers.values()) {
|
||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
||||
yield {
|
||||
keys: [member.userId],
|
||||
data: callPickupState,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, pickupState$, userId) =>
|
||||
createRingingMedia({
|
||||
id: `ringing:${userId}`,
|
||||
userId,
|
||||
displayName$: scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map((members) => members.get(userId)?.rawDisplayName || userId),
|
||||
),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
pickupState$,
|
||||
muteStates,
|
||||
}),
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
|
||||
ringAttempts$.pipe(
|
||||
switchMap(({ intent, recipient, outcome$ }) =>
|
||||
outcome$.pipe(
|
||||
startWith("ringing" as const),
|
||||
generateItems(
|
||||
"CallViewModel ringingMedia$",
|
||||
function* (pickupState) {
|
||||
if (pickupState !== "accept")
|
||||
yield { keys: [intent, recipient], data: pickupState };
|
||||
},
|
||||
(scope, pickupState$, intent, userId) =>
|
||||
createRingingMedia({
|
||||
id: `ringing:${userId}`,
|
||||
userId,
|
||||
displayName$: scope.behavior(
|
||||
matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
members.get(userId)?.rawDisplayName || userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
userId,
|
||||
),
|
||||
pickupState$,
|
||||
intent,
|
||||
}),
|
||||
),
|
||||
map(([media]) => media ?? null),
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
tap((ringingMedia) => {
|
||||
if (ringingMedia.length > 1)
|
||||
// Warn that UI may do something unexpected in this case
|
||||
logger.warn(
|
||||
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
|
||||
);
|
||||
}),
|
||||
startWith(null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -863,14 +855,10 @@ export function createCallViewModel$(
|
||||
* multiple devices.
|
||||
*/
|
||||
const participantCount$ = scope.behavior(
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe(
|
||||
// Until the call is successful, do not play a leave sound.
|
||||
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
|
||||
skipWhile(([c]) => c !== null && c !== "success"),
|
||||
map(([, userMedia]) => userMedia),
|
||||
const leaveSoundEffect$ = userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
@@ -879,6 +867,9 @@ export function createCallViewModel$(
|
||||
),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
// Avoid doubling up on any auto-leave sounds (e.g. the decline sound),
|
||||
// which are handled elsewhere
|
||||
takeUntil(autoLeave$),
|
||||
);
|
||||
|
||||
const userHangup$ = new Subject<void>();
|
||||
@@ -957,8 +948,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
|
||||
@@ -970,8 +961,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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -983,8 +976,8 @@ export function createCallViewModel$(
|
||||
}>(
|
||||
ringingMedia$.pipe(
|
||||
switchMap((ringingMedia) => {
|
||||
if (ringingMedia.length > 0)
|
||||
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
|
||||
if (ringingMedia !== null)
|
||||
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
|
||||
|
||||
return screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
@@ -1140,14 +1133,10 @@ export function createCallViewModel$(
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map((ringingMedia) => {
|
||||
return ringingMedia.length === 1
|
||||
? {
|
||||
local,
|
||||
remote: ringingMedia[0],
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
map(
|
||||
(ringingMedia) =>
|
||||
ringingMedia && { local, remote: ringingMedia },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1199,6 +1188,33 @@ export function createCallViewModel$(
|
||||
})),
|
||||
);
|
||||
|
||||
spotlight$
|
||||
.pipe(
|
||||
switchMap((media) => {
|
||||
let layout;
|
||||
const pipMedia = media[0];
|
||||
if (pipMedia === undefined) return of(undefined);
|
||||
switch (pipMedia.type) {
|
||||
case "user":
|
||||
layout = pipMedia.videoOrientation$;
|
||||
break;
|
||||
case "ringing":
|
||||
layout = of("landscape" as const);
|
||||
break;
|
||||
case "screen share":
|
||||
layout = of("landscape" as const);
|
||||
break;
|
||||
}
|
||||
return layout;
|
||||
}),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((orientation) => {
|
||||
if (orientation === undefined) return;
|
||||
logger.info("controls api pip orientation updated:", orientation);
|
||||
window.controls.onPipMediaOrientationUpdate?.(orientation);
|
||||
});
|
||||
|
||||
/**
|
||||
* The media to be used to produce a layout.
|
||||
*/
|
||||
@@ -1417,7 +1433,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(
|
||||
@@ -1457,7 +1473,7 @@ export function createCallViewModel$(
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
// first render a layout. To deal with this we assume initially that no tiles
|
||||
// first render a layout. To deal with this we assume initially that all tiles
|
||||
// are visible, and loop the data back into the layouts with a Subject.
|
||||
const visibleTiles$ = new Subject<number>();
|
||||
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
|
||||
@@ -1465,7 +1481,7 @@ export function createCallViewModel$(
|
||||
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
|
||||
combineLatest([
|
||||
layoutMedia$,
|
||||
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
||||
visibleTiles$.pipe(startWith(Infinity), distinctUntilChanged()),
|
||||
]).pipe(
|
||||
scan<
|
||||
[LayoutMedia, number],
|
||||
@@ -1475,6 +1491,8 @@ export function createCallViewModel$(
|
||||
({ tiles: prevTiles }, [media, visibleTiles]) => {
|
||||
let layout: Layout;
|
||||
let newTiles: TileStore;
|
||||
let pip: GridTileViewModel | undefined;
|
||||
let overflowing = false;
|
||||
switch (media.type) {
|
||||
case "grid":
|
||||
case "spotlight-landscape":
|
||||
@@ -1486,6 +1504,7 @@ export function createCallViewModel$(
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
overflowing = newTiles.gridTiles.length > visibleTiles;
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
@@ -1500,6 +1519,7 @@ export function createCallViewModel$(
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "one-on-one-portrait":
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||
@@ -1508,15 +1528,20 @@ export function createCallViewModel$(
|
||||
portraitPipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "pip":
|
||||
[layout, newTiles] = pipLayout(media, prevTiles);
|
||||
break;
|
||||
}
|
||||
|
||||
return { layout, tiles: newTiles };
|
||||
for (const tile of newTiles.gridTiles) {
|
||||
tile.setShowOutline(tile === pip);
|
||||
}
|
||||
|
||||
return { layout, overflowing, tiles: newTiles };
|
||||
},
|
||||
{ layout: null, tiles: TileStore.empty() },
|
||||
{ layout: null, overflowing: false, tiles: TileStore.empty() },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1528,6 +1553,10 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
layoutInternals$.pipe(map(({ overflowing }) => overflowing)),
|
||||
);
|
||||
|
||||
/**
|
||||
* The current generation of the tile store, exposed for debugging purposes.
|
||||
*/
|
||||
@@ -1697,9 +1726,9 @@ export function createCallViewModel$(
|
||||
|
||||
return {
|
||||
autoLeave$: autoLeave$,
|
||||
ringing$: scope.behavior(
|
||||
callPickupState$.pipe(map((state) => state === "ringing")),
|
||||
),
|
||||
ringingVm$: ringingMedia$,
|
||||
ringingStatusLocation:
|
||||
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
@@ -1744,8 +1773,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
|
||||
@@ -1769,6 +1798,7 @@ export function createCallViewModel$(
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
overflowing$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
@@ -1780,6 +1810,7 @@ export function createCallViewModel$(
|
||||
function getE2eeKeyProvider(
|
||||
e2eeSystem: EncryptionSystem,
|
||||
rtcSession: MatrixRTCSession,
|
||||
logger: Logger,
|
||||
): BaseKeyProvider | undefined {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
|
||||
|
||||
@@ -31,11 +31,6 @@ import { type ObservableScope } from "../../ObservableScope";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
|
||||
/**
|
||||
* Logger instance (scoped child) for homeserver connection updates.
|
||||
*/
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
|
||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
export interface HomeserverConnected {
|
||||
@@ -70,6 +65,7 @@ export function createHomeserverConnected$(
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
gracePeriodMs?: number,
|
||||
): HomeserverConnected {
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ export const createLocalMembership$ = ({
|
||||
logger: parentLogger,
|
||||
muteStates,
|
||||
matrixRTCSession,
|
||||
roomId: roomId,
|
||||
roomId,
|
||||
}: Props): {
|
||||
/**
|
||||
* This request to start audio and video tracks.
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
@@ -46,8 +46,6 @@ import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers
|
||||
import { customLivekitUrl } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
|
||||
/*
|
||||
* It figures out “which LiveKit focus URL/alias the local user should use,”
|
||||
* optionally aligning with the oldest member, and ensures the SFU path is primed
|
||||
@@ -140,9 +138,14 @@ export const createLocalTransport$ = ({
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): LocalTransport => {
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
// The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
// oldest member has no such transport.
|
||||
const oldestMemberTransport$ = observerOldestMembership$(scope, memberships$);
|
||||
const oldestMemberTransport$ = observerOldestMembership$(
|
||||
scope,
|
||||
memberships$,
|
||||
logger,
|
||||
);
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
@@ -190,6 +193,7 @@ export const createLocalTransport$ = ({
|
||||
roomId,
|
||||
client,
|
||||
delayId ?? undefined,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
@@ -209,6 +213,7 @@ export const createLocalTransport$ = ({
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +253,7 @@ export const createLocalTransport$ = ({
|
||||
function observerOldestMembership$(
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
logger: Logger,
|
||||
): Behavior<LivekitTransportConfig | null> {
|
||||
return scope.behavior<LivekitTransportConfig | null>(
|
||||
memberships$.pipe(
|
||||
@@ -307,6 +313,7 @@ async function doOpenIdAndJWTFromUrl(
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
delayId?: string,
|
||||
logger?: Logger,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
@@ -337,6 +344,7 @@ function observeLocalTransportForOldestMembership(
|
||||
OpenIDClientParts,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
logger: Logger,
|
||||
): LocalTransport {
|
||||
// Ensure we can authenticate with the SFU.
|
||||
const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe(
|
||||
@@ -355,6 +363,7 @@ function observeLocalTransportForOldestMembership(
|
||||
roomId,
|
||||
client,
|
||||
undefined,
|
||||
logger,
|
||||
),
|
||||
).pipe(
|
||||
catchError((e: unknown) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
: [];
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
}
|
||||
|
||||
it("should show our own user if present in rtc session and room", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
rawDisplayName: "it's a me",
|
||||
@@ -118,8 +118,10 @@ describe("MatrixMemberMetadata", () => {
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const dn$ =
|
||||
metadataStore.createDisplayNameBehavior$("@local:example.com");
|
||||
const dn$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
expectObservable(dn$).toBe("a", {
|
||||
a: "it's a me",
|
||||
@@ -146,7 +148,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
it("should get displayName for users", () => {
|
||||
setUpBasicRoom();
|
||||
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||
@@ -158,8 +160,10 @@ describe("MatrixMemberMetadata", () => {
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const aliceDispName$ =
|
||||
metadataStore.createDisplayNameBehavior$("@alice:example.com");
|
||||
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(aliceDispName$).toBe("a", {
|
||||
a: "Alice",
|
||||
@@ -322,7 +326,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
});
|
||||
|
||||
it("should track individual member id with createDisplayNameBehavior", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
const BOB = "@bob:example.com";
|
||||
const CARL = "@carl:example.com";
|
||||
@@ -356,8 +360,8 @@ describe("MatrixMemberMetadata", () => {
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(BOB);
|
||||
const carl$ = metadataStore.createDisplayNameBehavior$(CARL);
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
|
||||
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
|
||||
|
||||
expectObservable(bob$).toBe("abc-", {
|
||||
a: undefined,
|
||||
@@ -378,7 +382,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
});
|
||||
|
||||
it("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
|
||||
"@bob2:example.org",
|
||||
@@ -411,12 +415,18 @@ describe("MatrixMemberMetadata", () => {
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ =
|
||||
metadataStore.createDisplayNameBehavior$("@bob:example.org");
|
||||
const bob2$ =
|
||||
metadataStore.createDisplayNameBehavior$("@bob2:example.org");
|
||||
const carol$ =
|
||||
metadataStore.createDisplayNameBehavior$("@carol:example.org");
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob:example.org",
|
||||
);
|
||||
const bob2$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob2:example.org",
|
||||
);
|
||||
const carol$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@carol:example.org",
|
||||
);
|
||||
expectObservable(bob$).toBe("ab", {
|
||||
a: "Bob",
|
||||
b: "Bob (@bob:example.org)",
|
||||
@@ -517,7 +527,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
}
|
||||
|
||||
it("should use avatar url from room members", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
});
|
||||
@@ -536,11 +546,15 @@ describe("MatrixMemberMetadata", () => {
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const local$ =
|
||||
metadataStore.createAvatarUrlBehavior$("@local:example.com");
|
||||
const local$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
const alice$ =
|
||||
metadataStore.createAvatarUrlBehavior$("@alice:example.com");
|
||||
const alice$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(local$).toBe("a", {
|
||||
a: "mxc://example.com/@local:example.com",
|
||||
@@ -558,7 +572,7 @@ describe("MatrixMemberMetadata", () => {
|
||||
});
|
||||
|
||||
it("should update on avatar change and user join/leave", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
fakeMemberWith({ userId: "@carl:example.com" });
|
||||
fakeMemberWith({ userId: "@bob:example.com" });
|
||||
const memberships$ = behavior("ab-d", {
|
||||
@@ -585,9 +599,14 @@ describe("MatrixMemberMetadata", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const bob$ = metadataStore.createAvatarUrlBehavior$("@bob:example.com");
|
||||
const carl$ =
|
||||
metadataStore.createAvatarUrlBehavior$("@carl:example.com");
|
||||
const bob$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@bob:example.com",
|
||||
);
|
||||
const carl$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@carl:example.com",
|
||||
);
|
||||
expectObservable(bob$).toBe("a---", {
|
||||
a: "mxc://example.com/@bob:example.com",
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
} from "../../../utils/displayname";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
|
||||
export type RoomMemberMap = Map<
|
||||
string,
|
||||
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||
@@ -67,6 +65,7 @@ export const memberDisplaynames$ = (
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): Behavior<Map<string, string>> => {
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
// This map tracks userIds that at some point needed disambiguation.
|
||||
// This is a memory leak bound to the number of participants.
|
||||
// A call application will always increase the memory if there have been more members in a call.
|
||||
@@ -115,8 +114,14 @@ export const createMatrixMemberMetadata$ = (
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): {
|
||||
createDisplayNameBehavior$: (userId: string) => Behavior<string | undefined>;
|
||||
createAvatarUrlBehavior$: (userId: string) => Behavior<string | undefined>;
|
||||
createDisplayNameBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
createAvatarUrlBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
displaynameMap$: Behavior<Map<string, string>>;
|
||||
avatarMap$: Behavior<Map<string, string | undefined>>;
|
||||
} => {
|
||||
@@ -136,13 +141,13 @@ export const createMatrixMemberMetadata$ = (
|
||||
),
|
||||
);
|
||||
return {
|
||||
createDisplayNameBehavior$: (userId: string) =>
|
||||
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
displaynameMap$.pipe(
|
||||
map((displaynameMap) => displaynameMap.get(userId)),
|
||||
),
|
||||
),
|
||||
createAvatarUrlBehavior$: (userId: string) =>
|
||||
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
roomMembers$.pipe(
|
||||
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
162
src/state/IOSControlledAudioOutput.test.ts
Normal file
162
src/state/IOSControlledAudioOutput.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -26,7 +26,7 @@ export function oneOnOnePortraitLayout(
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOnePortraitLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
update.registerSpotlight([media.spotlight], true);
|
||||
update.registerSpotlight([media.spotlight], true, "transparent");
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user