diff --git a/.github/labels.yml b/.github/labels.yml index 063787d96..0747a55b7 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -102,6 +102,9 @@ - name: "backport-candidate" description: "Something that is a candidate for backport to a particular release branch" color: "0B8D85" +- name: "Claude-Review" + description: "Request code review from Claude" + color: "e57157" - name: "customer-retainer" color: "F44A5F" - name: "dependencies" diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml new file mode 100644 index 000000000..1ddbeadaa --- /dev/null +++ b/.github/workflows/claude.yaml @@ -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 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c2ba3b394..c59ba4024 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,6 +1,12 @@ name: Lint, format & type check on: pull_request: {} + workflow_call: + inputs: + matrix-js-sdk-sha: + type: string + required: false + description: The matrix-js-sdk commit to build against instead of the locked one jobs: lint: name: Lint, format & type check @@ -9,6 +15,8 @@ jobs: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable @@ -22,6 +30,7 @@ jobs: run: "scripts/layered.sh --ignore-pnpmfile" env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Formatting run: "pnpm run format:check" - name: i18n diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d29697e1d..5e2703f67 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,25 @@ on: pull_request: {} push: branches: [main] + workflow_call: + inputs: + matrix-js-sdk-sha: + type: string + required: false + description: The matrix-js-sdk commit to build against instead of the locked one + disable_coverage: + type: boolean + required: false + default: false + description: Skip generating and uploading coverage reports + disable_e2e_tests: + type: boolean + required: false + default: false + description: Skip the end-to-end tests + secrets: + CODECOV_TOKEN: + required: false jobs: vitest: name: Run unit tests @@ -11,6 +30,8 @@ jobs: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable @@ -24,6 +45,7 @@ jobs: run: "scripts/layered.sh --ignore-pnpmfile" env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Get Playwright version run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV - name: Cache Playwright binaries @@ -37,8 +59,11 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm playwright install --with-deps - name: Vitest - run: "pnpm run test:coverage" + run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE" + env: + ENABLE_COVERAGE: ${{ !inputs.disable_coverage }} - name: Upload to codecov + if: ${{ !inputs.disable_coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -47,11 +72,14 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests + if: ${{ !inputs.disable_e2e_tests }} timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable @@ -64,6 +92,7 @@ jobs: run: scripts/layered.sh --ignore-pnpmfile env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Get Playwright version run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV - name: Cache Playwright binaries diff --git a/README.md b/README.md index 5f1ed6bd2..2cbe2d127 100644 --- a/README.md +++ b/README.md @@ -306,9 +306,10 @@ directory target it rather than the repository; run them from the repository root. The host imports the component from `@element-hq/element-call-component` and the stylesheet from `@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 -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 component is large, and a host will usually load it lazily, only once a diff --git a/component/package.json b/component/package.json index ee81cd51b..0d45eb4d7 100644 --- a/component/package.json +++ b/component/package.json @@ -40,7 +40,6 @@ "peerDependencies": { "@vector-im/compound-design-tokens": "^11.0.0", "@vector-im/compound-web": "^10.0.0", - "livekit-client": "^2.18.1", "matrix-js-sdk": "*", "react": "^19", "react-dom": "^19" diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 2e570de44..889b94bd2 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # 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: # 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 diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index fab783c19..59b0940e3 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -76,8 +76,10 @@ Code that builds in only one is a bug. - `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; `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. + `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 host for the component, never by `src/base.css`. Two entry points: `index.tsx` (the component) and `api.ts` (types, enums and diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts index 8e1a6e589..19bc2e08b 100644 --- a/playwright/component/harness.ts +++ b/playwright/component/harness.ts @@ -92,6 +92,12 @@ export async function expectWithin( outer: Locator, ): Promise { 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 outerBox = await outer.boundingBox(); if (innerBox === null || outerBox === null) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2a220751..aad060c8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,10 +132,10 @@ importers: version: 10.3.1(react@19.2.8) '@vector-im/compound-design-tokens': 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': 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': 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)) @@ -222,7 +222,7 @@ importers: version: 10.6.1(postcss@8.5.26) postcss-selector-parser: specifier: ^7.1.1 - version: 7.1.3 + version: 7.1.6 posthog-js: specifier: 1.408.2 version: 1.408.2 @@ -3223,8 +3223,8 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vector-im/compound-design-tokens@11.0.0': - resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==} + '@vector-im/compound-design-tokens@11.1.0': + resolution: {integrity: sha512-vkMcy/G5VBlVle8umQNpctKHqZITS8XOQprsgcbSGRmwW080Z+TXV/vPsEXhgq8JKdxURkLhua0zQUufCeo46A==} peerDependencies: '@adobe/leonardo-contrast-colors': ^1.0.0 '@types/react': '*' @@ -5160,8 +5160,8 @@ packages: peerDependencies: postcss: ^8.4 - postcss-selector-parser@7.1.3: - resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -6397,9 +6397,9 @@ snapshots: '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': 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-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)': dependencies: @@ -6505,9 +6505,9 @@ snapshots: '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': 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-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': dependencies: @@ -6609,7 +6609,7 @@ snapshots: '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': dependencies: 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)': dependencies: @@ -6653,13 +6653,13 @@ snapshots: dependencies: 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: - 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: - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/utilities@2.0.0(postcss@8.5.26)': dependencies: @@ -8667,12 +8667,12 @@ snapshots: '@use-gesture/core': 10.3.1 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: '@types/react': 19.2.18 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: '@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) @@ -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-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) - '@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 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): dependencies: 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): 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-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-value-parser: 4.2.0 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): dependencies: 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): dependencies: @@ -10717,12 +10717,12 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 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): dependencies: 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): dependencies: @@ -10734,12 +10734,12 @@ snapshots: postcss-focus-visible@10.0.1(postcss@8.5.26): dependencies: 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): dependencies: 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): dependencies: @@ -10771,10 +10771,10 @@ snapshots: postcss-nesting@13.0.2(postcss@8.5.26): dependencies: - '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3) - '@csstools/selector-specificity': 5.0.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.6) 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): dependencies: @@ -10872,7 +10872,7 @@ snapshots: postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): dependencies: 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): dependencies: @@ -10881,9 +10881,9 @@ snapshots: postcss-selector-not@8.0.1(postcss@8.5.26): dependencies: 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: cssesc: 3.0.0 util-deprecate: 1.0.2 diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs index 42d9235df..e088682d7 100644 --- a/scripts/check-component-externals.mjs +++ b/scripts/check-component-externals.mjs @@ -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 * 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 * 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 @@ -44,7 +44,6 @@ const MUST_BE_EXTERNAL = [ "react", "react-dom", "matrix-js-sdk", - "livekit-client", "@vector-im/compound-web", "@vector-im/compound-design-tokens", ]; diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9d7ee3001..120d57533 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -373,7 +373,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { "intent:", intent, "\nproperties:", - redact(properties, "password"), + redact(properties, "password", "posthogUserId"), "configuration:", configuration, ); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 4a2bd8ba8..0b152a519 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -83,7 +83,7 @@ div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div { flex: 0 0 auto; } -.reactionButton { +.reactionPopupMenu .reactionsMenu .reactionButton { padding: var(--reaction-button-padding); border-radius: var(--cpd-radius-pill-effect); font-size: var(--reaction-button-fontsize); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 72c142fd8..709420236 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -30,7 +30,6 @@ import { scan, startWith, Subject, - switchAll, switchMap, switchScan, take, @@ -85,6 +84,7 @@ import { type ReactionOption, } from "../../reactions"; import { shallowEquals as shallowArrayEquals } from "../../utils/array"; +import { shallowEquals as shallowObjectEquals } from "../../utils/object"; import { type MediaDevices } from "../MediaDevices"; import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; @@ -94,17 +94,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { - layoutShallowEquals, type Alignment, - type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneDesktopLayoutMedia, - type OneOnOneMobileLayoutMedia, - type SpotlightExpandedLayoutMedia, - type SpotlightLandscapeLayoutMedia, - type SpotlightPortraitLayoutMedia, + type WindowMode, } from "../layout-types.ts"; +import { + chooseSpotlightSpeaker, + computeLayoutMedia, + computeSpotlight, + type OneOnOneMedia, +} from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -263,21 +263,10 @@ export function callViewModelOptionsFromParams( export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; 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 // with the interface 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 { layout: Layout | null; overflowing: boolean; @@ -964,33 +953,16 @@ export function createCallViewModel$( mediaItems.length === 0 ? of([]) : combineLatest( - mediaItems.map((m) => - m.speaking$.pipe(map((s) => [m, s] as const)), + mediaItems.map((media) => + media.speaking$.pipe(map((speaking) => ({ media, speaking }))), ), ), ), scan< - (readonly [UserMediaViewModel, boolean])[], + { media: UserMediaViewModel; speaking: boolean }[], UserMediaViewModel | undefined, undefined - >((prev, mediaItems) => { - // 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), + >(chooseSpotlightSpeaker, undefined), ), ); @@ -1034,33 +1006,12 @@ export function createCallViewModel$( ), ); - const spotlightAndPip$ = scope.behavior<{ - spotlight: MediaViewModel[]; - pip$: Observable; - }>( - ringingMedia$.pipe( - switchMap((ringingMedia) => { - 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 spotlightAndPip$ = scope.behavior( + combineLatest( + [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$], + (ringing, screenShares, speaker, localPip) => + computeSpotlight({ ringing, screenShares, speaker, localPip }), + ).pipe(distinctUntilChanged(shallowObjectEquals)), ); const spotlight$ = scope.behavior( @@ -1118,56 +1069,7 @@ export function createCallViewModel$( hasRemoteScreenShares$, ); - const gridLayoutMedia$: Observable = combineLatest( - [grid$, spotlight$], - (grid, spotlight) => ({ - type: "grid", - edgeToEdge: false, - spotlight: spotlight.some((vm) => vm.type === "screen share") - ? spotlight - : undefined, - grid, - }), - ); - - const spotlightLandscapeLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-landscape", - edgeToEdge, - spotlight, - grid, - })); - - const spotlightPortraitLayoutMedia$: Observable = - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-portrait", - edgeToEdge: false, - spotlight, - grid, - })); - - const spotlightExpandedLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - 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( + const oneOnOneLayoutMedia$: Behavior = scope.behavior( combineLatest([userMedia$, screenShares$]).pipe( switchMap(([userMedia, screenShares]) => { // One-on-one layout only supports 2 user media, no screen shares @@ -1205,47 +1107,12 @@ export function createCallViewModel$( ), ); - const oneOnOneDesktopLayoutMedia$: Observable = + const localVideoEnabled$ = scope.behavior( oneOnOneLayoutMedia$.pipe( - map((media) => { - if (media === null) return null; - 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 = - 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 = spotlight$.pipe( - map((spotlight) => ({ - type: "pip", - edgeToEdge: platform !== "desktop", - spotlight, - })), + switchMap((media) => + media === null ? of(false) : media.local.videoEnabled$, + ), + ), ); spotlight$ @@ -1280,66 +1147,38 @@ export function createCallViewModel$( * The media to be used to produce a layout. */ const layoutMedia$ = scope.behavior( - windowMode$.pipe( - switchMap((windowMode) => { - switch (windowMode) { - case "normal": - return layoutSwitchVm.layout$.pipe( - switchMap((layout) => { - switch (layout) { - case "grid": - return oneOnOneDesktopLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), - ), - ); - case "spotlight": - return spotlightExpanded$.pipe( - switchMap((expanded) => - expanded - ? spotlightExpandedLayoutMedia$(false) - : spotlightLandscapeLayoutMedia$(false), - ), - ); - } - }), - ); - 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), - ), - ); - 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$; - } - }), + // We deliberately don't use combineLatest here. Several of the inputs + // below derive from the same source. So one upstream change cascades + // through them one at a time. combineLatest would see each step of the + // cascade and emit layouts built from inputs that never coexisted. + // Instead, we treat the emissions as a mere "something changed" signal + // and read every input's current value. The derived behaviors were + // subscribed before this one and a BehaviorSubject updates its value + // before notifying. So by the time the first signal arrives every value + // is already consistent. The duplicate signals from the rest of the + // cascade are dropped by distinctUntilChanged. + merge( + windowMode$, + layoutSwitchVm.layout$, + spotlightExpanded$, + oneOnOneLayoutMedia$, + localVideoEnabled$, + spotlightAndPip$, + grid$, + ).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", + }), + ), + distinctUntilChanged(shallowObjectEquals), ), ); @@ -1640,7 +1479,7 @@ export function createCallViewModel$( layoutInternals$.pipe( map(({ layout }) => layout), // Drop redundant layout updates before they would hit React. - distinctUntilChanged(layoutShallowEquals), + distinctUntilChanged(shallowObjectEquals), ), ); diff --git a/src/state/LayoutSwitchViewModel.ts b/src/state/LayoutSwitchViewModel.ts index 6e61abaaa..f495c6275 100644 --- a/src/state/LayoutSwitchViewModel.ts +++ b/src/state/LayoutSwitchViewModel.ts @@ -14,7 +14,7 @@ import { switchAll, } from "rxjs"; -import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; +import { type WindowMode } from "./layout-types.ts"; import { constant, type Behavior } from "./Behavior.ts"; import { type ObservableScope } from "./ObservableScope.ts"; diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts deleted file mode 100644 index 468c15bb5..000000000 --- a/src/state/layout-types.test.ts +++ /dev/null @@ -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({ - 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)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..3d80b361e 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -16,7 +16,13 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.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 { type: "grid"; @@ -141,30 +147,3 @@ export type Layout = | OneOnOneDesktopLayout | OneOnOneMobileLayout | 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; -} diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts new file mode 100644 index 000000000..afa159877 --- /dev/null +++ b/src/state/layoutMedia.test.ts @@ -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, + ): ReturnType => + 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); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts new file mode 100644 index 000000000..56dec9979 --- /dev/null +++ b/src/state/layoutMedia.ts @@ -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( + 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 }; +} diff --git a/src/utils/object.test.ts b/src/utils/object.test.ts new file mode 100644 index 000000000..6b55ac7f3 --- /dev/null +++ b/src/utils/object.test.ts @@ -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)); diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 000000000..7fc7b5b1d --- /dev/null +++ b/src/utils/object.ts @@ -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( + a: Readonly, + b: Readonly, +): 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; +} diff --git a/vite-component.config.ts b/vite-component.config.ts index 93e661acd..fa6ce4168 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { defineConfig } from "vite"; 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 // application embedding it rather than served as a page of its own. @@ -88,7 +88,6 @@ export default defineConfig(({ mode }) => { "react/compiler-runtime", "react-dom", "react-dom/client", - "livekit-client", "matrix-js-sdk", "matrix-js-sdk/lib/browser-index", "matrix-js-sdk/lib/client",