mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Merge branch 'main' into HEAD
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Generated
+33
-33
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
case "narrow":
|
oneOnOne: oneOnOneLayoutMedia$.value,
|
||||||
return oneOnOneMobileLayoutMedia$.pipe(
|
localVideoEnabled: localVideoEnabled$.value,
|
||||||
switchMap((oneOnOne) =>
|
...spotlightAndPip$.value,
|
||||||
oneOnOne === null
|
grid: grid$.value,
|
||||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
desktop: platform === "desktop",
|
||||||
grid.length > smallMobileCallThreshold ||
|
}),
|
||||||
spotlight.some((vm) => vm.type === "screen share")
|
),
|
||||||
? spotlightPortraitLayoutMedia$
|
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||||
: 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$;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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>),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user