Merge branch 'main' into HEAD

This commit is contained in:
Robin
2026-10-09 01:43:18 +02:00
22 changed files with 762 additions and 367 deletions
+3
View File
@@ -102,6 +102,9 @@
- name: "backport-candidate" - name: "backport-candidate"
description: "Something that is a candidate for backport to a particular release branch" description: "Something that is a candidate for backport to a particular release branch"
color: "0B8D85" color: "0B8D85"
- name: "Claude-Review"
description: "Request code review from Claude"
color: "e57157"
- name: "customer-retainer" - name: "customer-retainer"
color: "F44A5F" color: "F44A5F"
- name: "dependencies" - name: "dependencies"
+90
View File
@@ -0,0 +1,90 @@
name: Claude
on:
pull_request:
types: [labeled]
permissions:
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
id-token: write
jobs:
review:
name: Review
# Only run on labelling and skip forks.
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# The review looks at the individual commits, not just the combined diff
fetch-depth: 0
persist-credentials: false
- name: Review
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
with:
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
# The token used here must grant read access to code, issues, and metadata as well as
# read and write access to pull requests.
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
use_sticky_comment: true
track_progress: true
prompt: |
Review this pull request under the following (and no further) angles:
1. Scope:
- Does the PR do one thing?
- Is the PR free of unrelated fixes and drive-by refactorings?
- If the PR is large, could it reasonably be split into smaller PRs
that still deliver value?
2. Commits:
- Is the work split into self-contained commits of reasonable size?
- Are the commits arranged in a sensible order?
- Have required refactorings been split out into separate commits?
- Is the history free of "wip", checkpoint and fixup-style commits
which only correct an earlier commit in the same PR?
3. Code:
- Are the code changes functionally correct?
- Do the changes introduce new security vulnerabilities or metadata
leaks?
- Is the new code performant enough?
- Don't check anything that is already validated via other GitHub
workflows like linting, formatting or tests passing.
Read the commits themselves with `git log` and `git diff` against
the base branch of the PR.
Drop any findings that are outside the above review angles or that don't
require resolution. Grade the severity of each finding as either "🟡 low",
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
finding.
Report the graded findings and resolutions from the "Code" angle as inline
comments. Add a footer "_-- Finding from automated review_" to each inline
comment.
Finally, report your overall result as PR comment. Start your message with
"# Automated review". Then add one section per review angle with:
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
- A bullet list summarizing the remaining non-inlined graded findings and
resolutions (as nested bullets).
Be brief and concise in all your communication. Do not mention what you have
not covered and don't bring up issues that have already been fixed.
Use Markdown links for anything that can be navigated to on GitHub such
as files, commits, issues, pull requests, workflow runs, etc.
claude_args: |
--model claude-fable-5-1
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
--max-turns 50
+9
View File
@@ -1,6 +1,12 @@
name: Lint, format & type check name: Lint, format & type check
on: on:
pull_request: {} pull_request: {}
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
jobs: jobs:
lint: lint:
name: Lint, format & type check name: Lint, format & type check
@@ -9,6 +15,8 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false persist-credentials: false
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
@@ -22,6 +30,7 @@ jobs:
run: "scripts/layered.sh --ignore-pnpmfile" run: "scripts/layered.sh --ignore-pnpmfile"
env: env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Formatting - name: Formatting
run: "pnpm run format:check" run: "pnpm run format:check"
- name: i18n - name: i18n
+30 -1
View File
@@ -3,6 +3,25 @@ on:
pull_request: {} pull_request: {}
push: push:
branches: [main] branches: [main]
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
disable_coverage:
type: boolean
required: false
default: false
description: Skip generating and uploading coverage reports
disable_e2e_tests:
type: boolean
required: false
default: false
description: Skip the end-to-end tests
secrets:
CODECOV_TOKEN:
required: false
jobs: jobs:
vitest: vitest:
name: Run unit tests name: Run unit tests
@@ -11,6 +30,8 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false persist-credentials: false
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
@@ -24,6 +45,7 @@ jobs:
run: "scripts/layered.sh --ignore-pnpmfile" run: "scripts/layered.sh --ignore-pnpmfile"
env: env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version - name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries - name: Cache Playwright binaries
@@ -37,8 +59,11 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps run: pnpm playwright install --with-deps
- name: Vitest - name: Vitest
run: "pnpm run test:coverage" run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
env:
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
- name: Upload to codecov - name: Upload to codecov
if: ${{ !inputs.disable_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -47,11 +72,14 @@ jobs:
fail_ci_if_error: true fail_ci_if_error: true
playwright: playwright:
name: Run end-to-end tests name: Run end-to-end tests
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false persist-credentials: false
- name: Enable Corepack - name: Enable Corepack
run: corepack enable run: corepack enable
@@ -64,6 +92,7 @@ jobs:
run: scripts/layered.sh --ignore-pnpmfile run: scripts/layered.sh --ignore-pnpmfile
env: env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version - name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries - name: Cache Playwright binaries
+3 -2
View File
@@ -306,9 +306,10 @@ directory target it rather than the repository; run them from the repository
root. The host imports the component from root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from `@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`, `@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk`, `livekit-client`, `@vector-im/compound-web` and `react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them `@vector-im/compound-design-tokens` itself, since the bundle leaves them
external. Compound's stylesheet are the host's to load too: the component shares external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips) the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a The component is large, and a host will usually load it lazily, only once a
-1
View File
@@ -40,7 +40,6 @@
"peerDependencies": { "peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0", "@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.0.0", "@vector-im/compound-web": "^10.0.0",
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*", "matrix-js-sdk": "*",
"react": "^19", "react": "^19",
"react-dom": "^19" "react-dom": "^19"
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web: element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:588f0b4ef516310331432e885920ed722bcafa577b1dd30429394c423d3e2848 image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
element-web-1: element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:588f0b4ef516310331432e885920ed722bcafa577b1dd30429394c423d3e2848 image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
+3 -1
View File
@@ -76,8 +76,10 @@ Code that builds in only one is a bug.
- `build:component` — `@element-hq/element-call-component`, sources in `component/` - `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README; (its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` / `pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `livekit-client` / `@vector-im/compound-web` / `matrix-js-sdk` / `@vector-im/compound-web` /
`@vector-im/compound-design-tokens` subpath the externals list omits. `@vector-im/compound-design-tokens` subpath the externals list omits.
`livekit-client` is bundled on purpose: no host imports it, and the pinned
version travels with the component.
Compound's stylesheets are loaded by `src/index.css` for the page and by the Compound's stylesheets are loaded by `src/index.css` for the page and by the
host for the component, never by `src/base.css`. Two entry host for the component, never by `src/base.css`. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and points: `index.tsx` (the component) and `api.ts` (types, enums and
+6
View File
@@ -92,6 +92,12 @@ export async function expectWithin(
outer: Locator, outer: Locator,
): Promise<void> { ): Promise<void> {
await expect(inner).toBeVisible(); await expect(inner).toBeVisible();
// Wait for animations to settle.
await inner.evaluate(async (element) => {
for (const animation of element.getAnimations({ subtree: true }))
await animation.finished;
});
const innerBox = await inner.boundingBox(); const innerBox = await inner.boundingBox();
const outerBox = await outer.boundingBox(); const outerBox = await outer.boundingBox();
if (innerBox === null || outerBox === null) if (innerBox === null || outerBox === null)
+33 -33
View File
@@ -132,10 +132,10 @@ importers:
version: 10.3.1(react@19.2.8) version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens': '@vector-im/compound-design-tokens':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.0(@types/react@19.2.18)(react@19.2.8) version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web': '@vector-im/compound-web':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -222,7 +222,7 @@ importers:
version: 10.6.1(postcss@8.5.26) version: 10.6.1(postcss@8.5.26)
postcss-selector-parser: postcss-selector-parser:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.3 version: 7.1.6
posthog-js: posthog-js:
specifier: 1.408.2 specifier: 1.408.2
version: 1.408.2 version: 1.408.2
@@ -3223,8 +3223,8 @@ packages:
peerDependencies: peerDependencies:
react: '>= 16.8.0' react: '>= 16.8.0'
'@vector-im/compound-design-tokens@11.0.0': '@vector-im/compound-design-tokens@11.1.0':
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==} resolution: {integrity: sha512-vkMcy/G5VBlVle8umQNpctKHqZITS8XOQprsgcbSGRmwW080Z+TXV/vPsEXhgq8JKdxURkLhua0zQUufCeo46A==}
peerDependencies: peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0 '@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*' '@types/react': '*'
@@ -5160,8 +5160,8 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.4 postcss: ^8.4
postcss-selector-parser@7.1.3: postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==} resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
@@ -6397,9 +6397,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies: dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)': '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies: dependencies:
@@ -6505,9 +6505,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies: dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies: dependencies:
@@ -6609,7 +6609,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)': '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies: dependencies:
@@ -6653,13 +6653,13 @@ snapshots:
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)': '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
dependencies: dependencies:
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)': '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
dependencies: dependencies:
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
'@csstools/utilities@2.0.0(postcss@8.5.26)': '@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies: dependencies:
@@ -8667,12 +8667,12 @@ snapshots:
'@use-gesture/core': 10.3.1 '@use-gesture/core': 10.3.1
react: 19.2.8 react: 19.2.8
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)': '@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies: optionalDependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
react: 19.2.8 react: 19.2.8
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies: dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8684,7 +8684,7 @@ snapshots:
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8) '@vector-im/compound-design-tokens': 11.1.0(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1 classnames: 2.5.1
react: 19.2.8 react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -9216,13 +9216,13 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26): css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
css-has-pseudo@7.0.3(postcss@8.5.26): css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies: dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
css-prefers-color-scheme@10.0.0(postcss@8.5.26): css-prefers-color-scheme@10.0.0(postcss@8.5.26):
@@ -10666,7 +10666,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26): postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-clamp@4.1.0(postcss@8.5.26): postcss-clamp@4.1.0(postcss@8.5.26):
dependencies: dependencies:
@@ -10717,12 +10717,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4 '@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26): postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-double-position-gradients@6.0.4(postcss@8.5.26): postcss-double-position-gradients@6.0.4(postcss@8.5.26):
dependencies: dependencies:
@@ -10734,12 +10734,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26): postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-focus-within@9.0.1(postcss@8.5.26): postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-font-variant@5.0.0(postcss@8.5.26): postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies: dependencies:
@@ -10771,10 +10771,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26): postcss-nesting@13.0.2(postcss@8.5.26):
dependencies: dependencies:
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3) '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-opacity-percentage@3.0.0(postcss@8.5.26): postcss-opacity-percentage@3.0.0(postcss@8.5.26):
dependencies: dependencies:
@@ -10872,7 +10872,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26): postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
dependencies: dependencies:
@@ -10881,9 +10881,9 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26): postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies: dependencies:
postcss: 8.5.26 postcss: 8.5.26
postcss-selector-parser: 7.1.3 postcss-selector-parser: 7.1.6
postcss-selector-parser@7.1.3: postcss-selector-parser@7.1.6:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
+1 -2
View File
@@ -9,7 +9,7 @@ Please see LICENSE in the repository root for full details.
* Checks that the component build leaves the packages a host must supply to * Checks that the component build leaves the packages a host must supply to
* the host. * the host.
* *
* A host application already has React, the Matrix SDK, LiveKit and Compound, * A host application already has React, the Matrix SDK and Compound,
* and a second copy of any of them is worse than dead weight: React would hold * and a second copy of any of them is worse than dead weight: React would hold
* two sets of hooks, the Matrix client would run two sync loops, and a second * two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class * Compound would style the tooltips it floats into the host's body with class
@@ -44,7 +44,6 @@ const MUST_BE_EXTERNAL = [
"react", "react",
"react-dom", "react-dom",
"matrix-js-sdk", "matrix-js-sdk",
"livekit-client",
"@vector-im/compound-web", "@vector-im/compound-web",
"@vector-im/compound-design-tokens", "@vector-im/compound-design-tokens",
]; ];
+1 -1
View File
@@ -373,7 +373,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"intent:", "intent:",
intent, intent,
"\nproperties:", "\nproperties:",
redact(properties, "password"), redact(properties, "password", "posthogUserId"),
"configuration:", "configuration:",
configuration, configuration,
); );
+1 -1
View File
@@ -83,7 +83,7 @@ div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
flex: 0 0 auto; flex: 0 0 auto;
} }
.reactionButton { .reactionPopupMenu .reactionsMenu .reactionButton {
padding: var(--reaction-button-padding); padding: var(--reaction-button-padding);
border-radius: var(--cpd-radius-pill-effect); border-radius: var(--cpd-radius-pill-effect);
font-size: var(--reaction-button-fontsize); font-size: var(--reaction-button-fontsize);
+55 -216
View File
@@ -30,7 +30,6 @@ import {
scan, scan,
startWith, startWith,
Subject, Subject,
switchAll,
switchMap, switchMap,
switchScan, switchScan,
take, take,
@@ -85,6 +84,7 @@ import {
type ReactionOption, type ReactionOption,
} from "../../reactions"; } from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array"; import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { type MediaDevices } from "../MediaDevices"; import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior"; import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType"; import { E2eeType } from "../../e2ee/e2eeType";
@@ -94,17 +94,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { type HostBridge, nullHostBridge } from "../../HostBridge";
import { import {
layoutShallowEquals,
type Alignment, type Alignment,
type GridLayoutMedia,
type Layout, type Layout,
type LayoutMedia, type LayoutMedia,
type OneOnOneDesktopLayoutMedia, type WindowMode,
type OneOnOneMobileLayoutMedia,
type SpotlightExpandedLayoutMedia,
type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia,
} from "../layout-types.ts"; } from "../layout-types.ts";
import {
chooseSpotlightSpeaker,
computeLayoutMedia,
computeSpotlight,
type OneOnOneMedia,
} from "../layoutMedia.ts";
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { Epoch, type ObservableScope } from "../ObservableScope.ts"; import { Epoch, type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
@@ -263,21 +263,10 @@ export function callViewModelOptionsFromParams(
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500; export const THROTTLE_SOUND_EFFECT_MS = 500;
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3;
// How long the footer should be shown for when hovering over or interacting // How long the footer should be shown for when hovering over or interacting
// with the interface // with the interface
const showFooterMs = 4000; const showFooterMs = 4000;
/**
* The general shape of the space the call is drawn in. Called a window because
* that is what it is in the standalone app; for a component it is the container
* the host gave us, which may be a small corner of a large window.
*/
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState { interface LayoutScanState {
layout: Layout | null; layout: Layout | null;
overflowing: boolean; overflowing: boolean;
@@ -964,33 +953,16 @@ export function createCallViewModel$(
mediaItems.length === 0 mediaItems.length === 0
? of([]) ? of([])
: combineLatest( : combineLatest(
mediaItems.map((m) => mediaItems.map((media) =>
m.speaking$.pipe(map((s) => [m, s] as const)), media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
), ),
), ),
), ),
scan< scan<
(readonly [UserMediaViewModel, boolean])[], { media: UserMediaViewModel; speaking: boolean }[],
UserMediaViewModel | undefined, UserMediaViewModel | undefined,
undefined undefined
>((prev, mediaItems) => { >(chooseSpotlightSpeaker, undefined),
// Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] =
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia!
: // Otherwise, select any remote user who is speaking
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.local)?.[0]);
}, undefined),
), ),
); );
@@ -1034,33 +1006,12 @@ export function createCallViewModel$(
), ),
); );
const spotlightAndPip$ = scope.behavior<{ const spotlightAndPip$ = scope.behavior(
spotlight: MediaViewModel[]; combineLatest(
pip$: Observable<UserMediaViewModel | undefined>; [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
}>( (ringing, screenShares, speaker, localPip) =>
ringingMedia$.pipe( computeSpotlight({ ringing, screenShares, speaker, localPip }),
switchMap((ringingMedia) => { ).pipe(distinctUntilChanged(shallowObjectEquals)),
if (ringingMedia !== null)
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
return screenShares$.pipe(
switchMap((screenShares) => {
if (screenShares.length > 0)
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
return spotlightSpeaker$.pipe(
map((speaker) => ({
spotlight: speaker ? [speaker] : [],
// Hide PiP if redundant (i.e. if local user is already in spotlight)
pip$: localUserMediaForPip$.pipe(
map((m) => (m === speaker ? undefined : m)),
),
})),
);
}),
);
}),
),
); );
const spotlight$ = scope.behavior<MediaViewModel[]>( const spotlight$ = scope.behavior<MediaViewModel[]>(
@@ -1118,56 +1069,7 @@ export function createCallViewModel$(
hasRemoteScreenShares$, hasRemoteScreenShares$,
); );
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest( const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
[grid$, spotlight$],
(grid, spotlight) => ({
type: "grid",
edgeToEdge: false,
spotlight: spotlight.some((vm) => vm.type === "screen share")
? spotlight
: undefined,
grid,
}),
);
const spotlightLandscapeLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightLandscapeLayoutMedia> =>
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
edgeToEdge,
spotlight,
grid,
}));
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait",
edgeToEdge: false,
spotlight,
grid,
}));
const spotlightExpandedLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightExpandedLayoutMedia> =>
spotlightAndPip$.pipe(
switchMap(({ spotlight, pip$ }) =>
pip$.pipe(
map((pip) => ({
type: "spotlight-expanded" as const,
edgeToEdge,
spotlight,
pip: pip ?? undefined,
})),
),
),
);
const oneOnOneLayoutMedia$: Behavior<{
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
} | null> = scope.behavior(
combineLatest([userMedia$, screenShares$]).pipe( combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => { switchMap(([userMedia, screenShares]) => {
// One-on-one layout only supports 2 user media, no screen shares // One-on-one layout only supports 2 user media, no screen shares
@@ -1205,47 +1107,12 @@ export function createCallViewModel$(
), ),
); );
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> = const localVideoEnabled$ = scope.behavior<boolean>(
oneOnOneLayoutMedia$.pipe( oneOnOneLayoutMedia$.pipe(
map((media) => { switchMap((media) =>
if (media === null) return null; media === null ? of(false) : media.local.videoEnabled$,
return media.remote.type === "ringing" ),
? { ),
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}),
);
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) => {
if (media === null) return of(null);
return media.local.videoEnabled$.pipe(
map((videoEnabled) => ({
type: "one-on-one-mobile" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: videoEnabled ? media.local : undefined,
})),
);
}),
);
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
map((spotlight) => ({
type: "pip",
edgeToEdge: platform !== "desktop",
spotlight,
})),
); );
spotlight$ spotlight$
@@ -1280,66 +1147,38 @@ export function createCallViewModel$(
* The media to be used to produce a layout. * The media to be used to produce a layout.
*/ */
const layoutMedia$ = scope.behavior<LayoutMedia>( const layoutMedia$ = scope.behavior<LayoutMedia>(
windowMode$.pipe( // We deliberately don't use combineLatest here. Several of the inputs
switchMap((windowMode) => { // below derive from the same source. So one upstream change cascades
switch (windowMode) { // through them one at a time. combineLatest would see each step of the
case "normal": // cascade and emit layouts built from inputs that never coexisted.
return layoutSwitchVm.layout$.pipe( // Instead, we treat the emissions as a mere "something changed" signal
switchMap((layout) => { // and read every input's current value. The derived behaviors were
switch (layout) { // subscribed before this one and a BehaviorSubject updates its value
case "grid": // before notifying. So by the time the first signal arrives every value
return oneOnOneDesktopLayoutMedia$.pipe( // is already consistent. The duplicate signals from the rest of the
switchMap((oneOnOne) => // cascade are dropped by distinctUntilChanged.
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), merge(
), windowMode$,
); layoutSwitchVm.layout$,
case "spotlight": spotlightExpanded$,
return spotlightExpanded$.pipe( oneOnOneLayoutMedia$,
switchMap((expanded) => localVideoEnabled$,
expanded spotlightAndPip$,
? spotlightExpandedLayoutMedia$(false) grid$,
: spotlightLandscapeLayoutMedia$(false), ).pipe(
), map(() =>
); computeLayoutMedia({
} windowMode: windowMode$.value,
layoutMode: layoutSwitchVm.layout$.value,
spotlightExpanded: spotlightExpanded$.value,
oneOnOne: oneOnOneLayoutMedia$.value,
localVideoEnabled: localVideoEnabled$.value,
...spotlightAndPip$.value,
grid: grid$.value,
desktop: platform === "desktop",
}), }),
);
case "narrow":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm.type === "screen share")
? spotlightPortraitLayoutMedia$
: gridLayoutMedia$,
).pipe(switchAll())
: of(oneOnOne),
), ),
); distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
case "flat":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia$(true);
case "spotlight":
return spotlightExpandedLayoutMedia$(true);
}
}),
)
: of(oneOnOne),
),
);
case "pip":
return pipLayoutMedia$;
}
}),
), ),
); );
@@ -1640,7 +1479,7 @@ export function createCallViewModel$(
layoutInternals$.pipe( layoutInternals$.pipe(
map(({ layout }) => layout), map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React. // Drop redundant layout updates before they would hit React.
distinctUntilChanged<Layout>(layoutShallowEquals), distinctUntilChanged(shallowObjectEquals<Layout>),
), ),
); );
+1 -1
View File
@@ -14,7 +14,7 @@ import {
switchAll, switchAll,
} from "rxjs"; } from "rxjs";
import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; import { type WindowMode } from "./layout-types.ts";
import { constant, type Behavior } from "./Behavior.ts"; import { constant, type Behavior } from "./Behavior.ts";
import { type ObservableScope } from "./ObservableScope.ts"; import { type ObservableScope } from "./ObservableScope.ts";
-74
View File
@@ -1,74 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test, expect } from "vitest";
import {
type Alignment,
layoutShallowEquals,
type Layout,
} from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { BehaviorSubject } from "rxjs";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
pipAlignment$,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: spotlightTile,
grid: [gridTile],
setVisibleTiles: () => {},
};
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
true,
));
test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: gridTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: gridTile },
spotlightExpanded,
),
).toBe(false);
});
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid],
}),
).toBe(true));
test("layoutShallowEquals detects grid arrays with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid, gridTile],
}),
).toBe(false));
+7 -28
View File
@@ -16,7 +16,13 @@ import {
type SpotlightTileViewModel, type SpotlightTileViewModel,
} from "./TileViewModel.ts"; } from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts"; import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
/**
* The general shape of the space the call is drawn in. Called a window because
* that is what it is in the standalone app; for a component it is the container
* the host gave us, which may be a small corner of a large window.
*/
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
export interface GridLayoutMedia { export interface GridLayoutMedia {
type: "grid"; type: "grid";
@@ -141,30 +147,3 @@ export type Layout =
| OneOnOneDesktopLayout | OneOnOneDesktopLayout
| OneOnOneMobileLayout | OneOnOneMobileLayout
| PipLayout; | PipLayout;
/**
* Tests whether the top-level properties and array elements of layout `a` are
* equal to those of layout `b`. Useful for deduping redundant layout updates.
*/
export function layoutShallowEquals(a: Layout, b: Layout): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!(key in b)) return false;
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
// Special case for arrays so we can detect when the grid tiles arrays are
// essentially the same.
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
}
return true;
}
+220
View File
@@ -0,0 +1,220 @@
/*
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 { describe, expect, it } from "vitest";
import {
chooseSpotlightSpeaker,
computeLayoutMedia,
computeSpotlight,
type LayoutMediaInputs,
} from "./layoutMedia";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel";
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel";
// The layout functions only look at `type` and `local`, so stubs suffice
const local = { type: "user", local: true } as LocalUserMediaViewModel;
const alice = { type: "user", local: false } as RemoteUserMediaViewModel;
const bob = { type: "user", local: false } as RemoteUserMediaViewModel;
const carol = { type: "user", local: false } as RemoteUserMediaViewModel;
const screenShare = {
type: "screen share",
local: false,
} as ScreenShareViewModel;
const ringing = { type: "ringing" } as RingingMediaViewModel;
describe("chooseSpotlightSpeaker", () => {
const items = (
...speaking: boolean[]
): {
media: RemoteUserMediaViewModel | LocalUserMediaViewModel;
speaking: boolean;
}[] =>
[local, alice, bob].map((media, i) => ({
media,
speaking: speaking[i] ?? false,
}));
it("prefers a remote speaker over the local user", () => {
expect(chooseSpotlightSpeaker(undefined, items(true, false, true))).toBe(
bob,
);
});
it("falls back to any remote user, then the local user", () => {
expect(chooseSpotlightSpeaker(undefined, items())).toBe(alice);
expect(
chooseSpotlightSpeaker(undefined, [{ media: local, speaking: false }]),
).toBe(local);
expect(chooseSpotlightSpeaker(undefined, [])).toBeUndefined();
});
it("sticks with the previous speaker while they keep speaking", () => {
expect(chooseSpotlightSpeaker(bob, items(false, true, true))).toBe(bob);
});
it("sticks with the previous speaker when nobody is speaking", () => {
expect(chooseSpotlightSpeaker(bob, items())).toBe(bob);
});
it("switches when someone else speaks and the previous speaker is silent", () => {
expect(chooseSpotlightSpeaker(bob, items(false, true, false))).toBe(alice);
});
it("forgets a previous speaker who left, and never sticks to the local user", () => {
expect(chooseSpotlightSpeaker(carol, items())).toBe(alice);
expect(chooseSpotlightSpeaker(local, items())).toBe(alice);
});
});
describe("computeSpotlight", () => {
it("spotlights ringing media with the local user in the PiP", () => {
expect(
computeSpotlight({
ringing,
screenShares: [screenShare],
speaker: alice,
localPip: local,
}),
).toEqual({ spotlight: [ringing], pip: local });
});
it("spotlights screen shares with the speaker in the PiP", () => {
expect(
computeSpotlight({
ringing: null,
screenShares: [screenShare],
speaker: alice,
localPip: local,
}),
).toEqual({ spotlight: [screenShare], pip: alice });
});
it("otherwise spotlights the speaker and hides a redundant PiP", () => {
expect(
computeSpotlight({
ringing: null,
screenShares: [],
speaker: alice,
localPip: local,
}),
).toEqual({ spotlight: [alice], pip: local });
expect(
computeSpotlight({
ringing: null,
screenShares: [],
speaker: local,
localPip: local,
}),
).toEqual({ spotlight: [local], pip: undefined });
});
});
describe("computeLayoutMedia", () => {
const base: LayoutMediaInputs = {
windowMode: "normal",
layoutMode: "grid",
spotlightExpanded: false,
oneOnOne: null,
localVideoEnabled: true,
spotlight: [alice],
grid: [local, alice, bob],
pip: local,
desktop: false,
};
const layout = (
overrides: Partial<LayoutMediaInputs>,
): ReturnType<typeof computeLayoutMedia> =>
computeLayoutMedia({ ...base, ...overrides });
it("uses a plain grid unless a screen share needs the spotlight", () => {
expect(layout({})).toEqual({
type: "grid",
edgeToEdge: false,
spotlight: undefined,
grid: base.grid,
});
expect(layout({ spotlight: [screenShare] }).spotlight).toEqual([
screenShare,
]);
});
it("switches between landscape and expanded spotlight", () => {
expect(layout({ layoutMode: "spotlight" })).toMatchObject({
type: "spotlight-landscape",
edgeToEdge: false,
});
expect(
layout({ layoutMode: "spotlight", spotlightExpanded: true }),
).toEqual({
type: "spotlight-expanded",
edgeToEdge: false,
spotlight: [alice],
pip: local,
});
});
it("puts the remote user in the desktop one-on-one spotlight, or the local user when ringing", () => {
expect(layout({ oneOnOne: { local, remote: alice } })).toEqual({
type: "one-on-one-desktop",
edgeToEdge: false,
spotlight: alice,
pip: local,
});
expect(layout({ oneOnOne: { local, remote: ringing } })).toMatchObject({
spotlight: local,
pip: ringing,
});
});
it("uses a mobile one-on-one layout in narrow and flat windows, hiding the PiP without video", () => {
const oneOnOne = { local, remote: alice };
expect(layout({ windowMode: "narrow", oneOnOne })).toEqual({
type: "one-on-one-mobile",
edgeToEdge: true,
spotlight: alice,
pip: local,
});
expect(
layout({ windowMode: "flat", oneOnOne, localVideoEnabled: false }),
).toMatchObject({ type: "one-on-one-mobile", pip: undefined });
});
it("uses a portrait spotlight in narrow windows for big calls or screen shares", () => {
expect(layout({ windowMode: "narrow" }).type).toBe("grid");
expect(
layout({ windowMode: "narrow", grid: [local, alice, bob, carol] }).type,
).toBe("spotlight-portrait");
expect(
layout({ windowMode: "narrow", spotlight: [screenShare] }).type,
).toBe("spotlight-portrait");
});
it("uses edge-to-edge spotlight layouts in flat windows", () => {
expect(layout({ windowMode: "flat" })).toMatchObject({
type: "spotlight-landscape",
edgeToEdge: true,
});
expect(
layout({ windowMode: "flat", layoutMode: "spotlight" }),
).toMatchObject({
type: "spotlight-expanded",
edgeToEdge: true,
});
});
it("uses the PiP layout, edge to edge except on desktop", () => {
expect(layout({ windowMode: "pip" })).toEqual({
type: "pip",
edgeToEdge: true,
spotlight: [alice],
});
expect(layout({ windowMode: "pip", desktop: true }).edgeToEdge).toBe(false);
});
});
+216
View File
@@ -0,0 +1,216 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LayoutMode } from "./LayoutSwitchViewModel.ts";
import {
type GridLayoutMedia,
type LayoutMedia,
type OneOnOneDesktopLayoutMedia,
type OneOnOneMobileLayoutMedia,
type SpotlightExpandedLayoutMedia,
type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia,
type WindowMode,
} from "./layout-types.ts";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts";
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
/**
* This is the number of participants that we think constitutes a "small" call
* on mobile. No spotlight tile should be shown below this threshold.
*/
const smallMobileCallThreshold = 3;
/**
* Picks who to spotlight based on who is speaking, preferring the previous
* pick so that the spotlight doesn't flip eagerly between speakers.
*
* @param prev The previous pick, if any.
* @param items All user media in the call along with whether each is speaking.
*/
export function chooseSpotlightSpeaker<M extends { local: boolean }>(
prev: M | undefined,
items: readonly { media: M; speaking: boolean }[],
): M | undefined {
// Only remote users that are still in the call should be sticky
const sticky = items.find((i) => i.media === prev && !i.media.local);
// If the previous speaker is still speaking, stick with them
if (sticky?.speaking) return sticky.media;
return (
// Otherwise, select any remote user who is speaking
items.find((i) => !i.media.local && i.speaking)?.media ??
// Otherwise, stick with the person who was last speaking
sticky?.media ??
// Otherwise, spotlight an arbitrary remote user
items.find((i) => !i.media.local)?.media ??
// Otherwise, spotlight the local user
items.find((i) => i.media.local)?.media
);
}
export interface SpotlightInputs {
ringing: RingingMediaViewModel | null;
screenShares: ScreenShareViewModel[];
speaker: UserMediaViewModel | undefined;
/** Local user media suitable for showing in a PiP, if any. */
localPip: UserMediaViewModel | undefined;
}
/**
* Decides what goes in the spotlight, and what goes in the picture-in-picture
* tile of the expanded spotlight layout.
*/
export function computeSpotlight({
ringing,
screenShares,
speaker,
localPip,
}: SpotlightInputs): {
spotlight: MediaViewModel[];
pip: UserMediaViewModel | undefined;
} {
if (ringing !== null) return { spotlight: [ringing], pip: localPip };
if (screenShares.length > 0) return { spotlight: screenShares, pip: speaker };
return {
spotlight: speaker ? [speaker] : [],
// Hide PiP if redundant (i.e. if local user is already in spotlight)
pip: localPip === speaker ? undefined : localPip,
};
}
export interface OneOnOneMedia {
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
}
export interface LayoutMediaInputs {
windowMode: WindowMode;
/** The layout chosen with the layout switch. */
layoutMode: LayoutMode;
spotlightExpanded: boolean;
/** Set when the call qualifies for a one-on-one layout. */
oneOnOne: OneOnOneMedia | null;
localVideoEnabled: boolean;
spotlight: MediaViewModel[];
grid: UserMediaViewModel[];
pip: UserMediaViewModel | undefined;
desktop: boolean;
}
/**
* Decides which layout to use and which media goes where in it.
*/
export function computeLayoutMedia({
windowMode,
layoutMode,
spotlightExpanded,
oneOnOne,
localVideoEnabled,
spotlight,
grid,
pip,
desktop,
}: LayoutMediaInputs): LayoutMedia {
switch (windowMode) {
case "normal":
if (layoutMode === "grid")
return oneOnOne === null
? gridLayoutMedia(spotlight, grid)
: oneOnOneDesktopLayoutMedia(oneOnOne);
return spotlightExpanded
? spotlightExpandedLayoutMedia(false, spotlight, pip)
: spotlightLandscapeLayoutMedia(false, spotlight, grid);
case "narrow":
if (oneOnOne !== null)
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight)
? spotlightPortraitLayoutMedia(spotlight, grid)
: gridLayoutMedia(spotlight, grid);
case "flat":
if (oneOnOne !== null)
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
// Yes, grid mode actually gets you a "spotlight" layout in this window
// mode.
return layoutMode === "grid"
? spotlightLandscapeLayoutMedia(true, spotlight, grid)
: spotlightExpandedLayoutMedia(true, spotlight, pip);
case "pip":
return { type: "pip", edgeToEdge: !desktop, spotlight };
}
}
function hasScreenShare(spotlight: MediaViewModel[]): boolean {
return spotlight.some((vm) => vm.type === "screen share");
}
function gridLayoutMedia(
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): GridLayoutMedia {
return {
type: "grid",
edgeToEdge: false,
spotlight: hasScreenShare(spotlight) ? spotlight : undefined,
grid,
};
}
function oneOnOneDesktopLayoutMedia(
media: OneOnOneMedia,
): OneOnOneDesktopLayoutMedia {
return media.remote.type === "ringing"
? {
type: "one-on-one-desktop",
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-desktop",
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}
function spotlightExpandedLayoutMedia(
edgeToEdge: boolean,
spotlight: MediaViewModel[],
pip: UserMediaViewModel | undefined,
): SpotlightExpandedLayoutMedia {
return { type: "spotlight-expanded", edgeToEdge, spotlight, pip };
}
function spotlightLandscapeLayoutMedia(
edgeToEdge: boolean,
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): SpotlightLandscapeLayoutMedia {
return { type: "spotlight-landscape", edgeToEdge, spotlight, grid };
}
function oneOnOneMobileLayoutMedia(
media: OneOnOneMedia,
localVideoEnabled: boolean,
): OneOnOneMobileLayoutMedia {
return {
type: "one-on-one-mobile",
edgeToEdge: true,
spotlight: media.remote,
pip: localVideoEnabled ? media.local : undefined,
};
}
function spotlightPortraitLayoutMedia(
spotlight: MediaViewModel[],
grid: UserMediaViewModel[],
): SpotlightPortraitLayoutMedia {
return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid };
}
+40
View File
@@ -0,0 +1,40 @@
/*
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 { test, expect } from "vitest";
import { shallowEquals } from "./object";
interface Thing {
type: string;
items: object[];
value: number;
extra?: object;
}
const item = {};
const withArray: Thing = { type: "a", items: [item], value: 1 };
test("shallowEquals considers an object to be equal to its shallow clone", () =>
expect(shallowEquals(withArray, { ...withArray })).toBe(true));
test("shallowEquals detects a missing key", () => {
expect(shallowEquals(withArray, { ...withArray, extra: item })).toBe(false);
expect(shallowEquals({ ...withArray, extra: item }, withArray)).toBe(false);
});
test("shallowEquals detects a differing value", () =>
expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false));
test("shallowEquals considers arrays with equal contents to be equal", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items] }),
).toBe(true));
test("shallowEquals detects arrays with different contents", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }),
).toBe(false));
+38
View File
@@ -0,0 +1,38 @@
/*
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 { shallowEquals as arrayShallowEquals } from "./array.ts";
/**
* Determine whether two objects are equal by shallow comparison of their
* top-level properties. Array-valued properties are themselves compared
* shallowly, so two objects holding distinct arrays with the same elements are
* considered equal.
*/
export function shallowEquals<T extends object>(
a: Readonly<T>,
b: Readonly<T>,
): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!(key in b)) return false;
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
}
return true;
}
+1 -2
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config.ts"; import { vitePluginsConfig } from "./vite.config.ts";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot.ts";
// Config for Element Call as a React component, to be imported by an // Config for Element Call as a React component, to be imported by an
// application embedding it rather than served as a page of its own. // application embedding it rather than served as a page of its own.
@@ -88,7 +88,6 @@ export default defineConfig(({ mode }) => {
"react/compiler-runtime", "react/compiler-runtime",
"react-dom", "react-dom",
"react-dom/client", "react-dom/client",
"livekit-client",
"matrix-js-sdk", "matrix-js-sdk",
"matrix-js-sdk/lib/browser-index", "matrix-js-sdk/lib/browser-index",
"matrix-js-sdk/lib/client", "matrix-js-sdk/lib/client",