Compare commits

...
Author SHA1 Message Date
Timo K 0331416942 Merge branch 'livekit' into toger5/implement-media-device-switcher 2026-05-11 11:34:22 +02:00
Timo K bb710e005c more testing 2026-05-08 10:12:47 +02:00
Timo K 183af5117e move call footer to viewModel interface approach 2026-05-07 18:10:46 +02:00
Timo K 95a0dcf389 add vitest storybook support 2026-05-07 18:10:15 +02:00
Timo K 3ab541549f readme yarn->pnpm 2026-05-07 18:09:56 +02:00
Timo K 1fbd7ac80f first step (starting to mix views and viewModels... this has to become
better)
2026-05-05 18:29:18 +02:00
Timo K 8213612135 hook up footer select actions 2026-05-05 16:31:32 +02:00
Timo K fbd6aa0315 add toggle example to default component 2026-05-05 16:23:22 +02:00
Timo K 4448948f9e Update pr-deploy.yaml 2026-05-05 15:53:40 +02:00
Timo K 130cc27cf5 Merge branch 'livekit' into toger5/media-device-switcher 2026-05-05 15:47:26 +02:00
Timo K 459d07004f add storybook to CI
only add storybook with storybook label

test names

another env name test

TestName

new default name

remove label condition

Update pr-deploy.yaml
2026-05-05 13:15:43 +02:00
Timo K 6985eb3a2b fix tests 2026-05-05 12:07:16 +02:00
Timo K f85cb947eb update styling (dark bg on menu open + chevron white + chevron up) 2026-05-05 11:57:35 +02:00
Timo K 63e2b4c549 Add tests 2026-05-05 11:01:38 +02:00
Timo K 86f5b9888c User button in footer 2026-04-30 18:11:35 +02:00
Timo K 2ad6125849 add MeidaMuteAndSwitchButton 2026-04-30 18:11:35 +02:00
19 changed files with 1574 additions and 285 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
framework: "@storybook/react-vite",
};
export default config;
+1 -1
View File
@@ -220,7 +220,7 @@ including federation:
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Local Element Call development `call.m.localhost` via `pnpm dev --host `
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
-1
View File
@@ -202,7 +202,6 @@
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",
+11 -5
View File
@@ -27,6 +27,8 @@
"i18n": "i18next",
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
"test": "vitest",
"test:storybook": "vitest --project=storybook",
"test:unit": "vitest --project=unit",
"test:coverage": "vitest --coverage",
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
@@ -59,8 +61,9 @@
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
"@storybook/addon-docs": "^10.3.6",
"@storybook/addon-vitest": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -83,7 +86,9 @@
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.5",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -98,7 +103,7 @@
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.3",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
@@ -116,6 +121,7 @@
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
"playwright": "^1.59.1",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.160.3",
@@ -128,7 +134,7 @@
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"storybook": "^10.3.3",
"storybook": "^10.3.6",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
@@ -141,7 +147,7 @@
"vite-plugin-node-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^4.0.18",
"vitest": "^4.1.5",
"vitest-axe": "^1.0.0-pre.3"
},
"pnpm": {
+211 -60
View File
@@ -82,11 +82,14 @@ importers:
specifier: ^3.0.0
version: 3.6.1
'@storybook/addon-docs':
specifier: ^10.3.3
version: 10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
specifier: ^10.3.6
version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@storybook/addon-vitest':
specifier: ^10.3.6
version: 10.3.6(@vitest/browser-playwright@4.1.5)(@vitest/browser@4.1.5)(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5)
'@storybook/react-vite':
specifier: ^10.3.3
version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
specifier: ^10.3.6
version: 10.3.6(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@stylistic/eslint-plugin':
specifier: ^3.0.0
version: 3.1.0(eslint@8.57.1)(typescript@5.9.3)
@@ -153,8 +156,14 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.0.1
version: 4.7.0(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@vitest/browser-playwright':
specifier: ^4.1.5
version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)
'@vitest/ui':
specifier: 4.1.5
version: 4.1.5(vitest@4.1.5)
babel-plugin-transform-vite-meta-env:
specifier: ^1.0.3
@@ -199,8 +208,8 @@ importers:
specifier: ^5.0.3
version: 5.0.3(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-storybook:
specifier: ^10.3.3
version: 10.3.5(eslint@8.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)
specifier: ^10.3.6
version: 10.3.6(eslint@8.57.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)
eslint-plugin-unicorn:
specifier: ^56.0.0
version: 56.0.1(eslint@8.57.1)
@@ -252,6 +261,9 @@ importers:
pako:
specifier: ^2.0.4
version: 2.1.0
playwright:
specifier: ^1.59.1
version: 1.59.1
postcss:
specifier: ^8.4.41
version: 8.5.11
@@ -289,8 +301,8 @@ importers:
specifier: ^1.42.1
version: 1.99.0
storybook:
specifier: ^10.3.3
version: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
specifier: ^10.3.6
version: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
typescript:
specifier: ^5.8.3
version: 5.9.3
@@ -328,8 +340,8 @@ importers:
specifier: ^3.6.0
version: 3.6.0(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest:
specifier: ^4.0.18
version: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
specifier: ^4.1.5
version: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest-axe:
specifier: ^1.0.0-pre.3
version: 1.0.0-pre.5(vitest@4.1.5)
@@ -941,6 +953,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
@@ -1874,6 +1889,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2679,23 +2697,41 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@storybook/addon-docs@10.3.5':
resolution: {integrity: sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==}
'@storybook/addon-docs@10.3.6':
resolution: {integrity: sha512-TvIdADVPtauxW0LzXIpIv7X6GxwetorhyNh+6+7MHC27XSBCWVxxRUwL63YeLlHTuXsIk0quG3b1xgwVRzWOJA==}
peerDependencies:
storybook: ^10.3.5
storybook: ^10.3.6
'@storybook/builder-vite@10.3.5':
resolution: {integrity: sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==}
'@storybook/addon-vitest@10.3.6':
resolution: {integrity: sha512-HXj7RrPJY+xzoNjL+xZu2oLw1fI5BA87Noh1NAXMPuECHR5R5fuRM/tTsJuIGXHFMO06FjSi/rekDIfCj1fL4w==}
peerDependencies:
storybook: ^10.3.5
'@vitest/browser': ^3.0.0 || ^4.0.0
'@vitest/browser-playwright': ^4.0.0
'@vitest/runner': ^3.0.0 || ^4.0.0
storybook: ^10.3.6
vitest: ^3.0.0 || ^4.0.0
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/runner':
optional: true
vitest:
optional: true
'@storybook/builder-vite@10.3.6':
resolution: {integrity: sha512-gpvR/sE4BcrFtmQZ+Ker7zD23oQzoVeqD9nF6cK6yzY+Q0svJXyX2EPmFG4y+EwygD5/vNzDpP84gGMut8VRwg==}
peerDependencies:
storybook: ^10.3.6
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@storybook/csf-plugin@10.3.5':
resolution: {integrity: sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==}
'@storybook/csf-plugin@10.3.6':
resolution: {integrity: sha512-9kBf7VRdRqTSIYo+rPtVn5yjYYyK8kP2QhEYx3oiXvfwy4RexmbJnhk/tXa/lNiTqukA1TqaWQ2+5MqF4fu6YQ==}
peerDependencies:
esbuild: ^0.28.0
rollup: '*'
storybook: ^10.3.5
storybook: ^10.3.6
vite: '*'
webpack: '*'
peerDependenciesMeta:
@@ -2717,27 +2753,27 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@storybook/react-dom-shim@10.3.5':
resolution: {integrity: sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==}
'@storybook/react-dom-shim@10.3.6':
resolution: {integrity: sha512-/Tu1gPu+Fw+zOnAGmxRmOD30FX3a04LxcTAKflEtdpmtIMVR5bA3qpjy+f5YhoyDCecbXyKmL1OeIU2FIIZHqQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.3.5
storybook: ^10.3.6
'@storybook/react-vite@10.3.5':
resolution: {integrity: sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==}
'@storybook/react-vite@10.3.6':
resolution: {integrity: sha512-tySQRc+8q7V2NkylQMNJjDV8zXy6tkxb8oDqw/DIhHhI9Xn77MTKVZ8Cihbo5NMm7HYTB6xDKr6wqdSMgdufYQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.3.5
storybook: ^10.3.6
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@storybook/react@10.3.5':
resolution: {integrity: sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==}
'@storybook/react@10.3.6':
resolution: {integrity: sha512-oZQZ6xayWe5IdHmFUTL0TL8rX/gpNNh9gWhT2vzW5eeUvlkVG/RBKdsja6Ndrk2s1D9vcnwiI6r6CNXy3IEEmg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^10.3.5
storybook: ^10.3.6
typescript: '>= 4.9.x'
peerDependenciesMeta:
typescript:
@@ -3173,6 +3209,17 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/browser-playwright@4.1.5':
resolution: {integrity: sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==}
peerDependencies:
playwright: '*'
vitest: 4.1.5
'@vitest/browser@4.1.5':
resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==}
peerDependencies:
vitest: 4.1.5
'@vitest/coverage-v8@4.1.5':
resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
peerDependencies:
@@ -3217,6 +3264,11 @@ packages:
'@vitest/spy@4.1.5':
resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==}
'@vitest/ui@4.1.5':
resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==}
peerDependencies:
vitest: 4.1.5
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
@@ -4165,11 +4217,11 @@ packages:
eslint: ^8.0.0
typescript: '>=4.0.0'
eslint-plugin-storybook@10.3.5:
resolution: {integrity: sha512-rEFkfU3ypF44GpB4tiJ9EFDItueoGvGi3+weLHZax2ON2MB7VIDsxdSUGvIU5tMURg+oWYlpzCyLm4TpDq2deA==}
eslint-plugin-storybook@10.3.6:
resolution: {integrity: sha512-8udrL+Rmp5LFaZvgRe4J226X1MYls25bWCyHuzR5X8s2qbFTryX+wKC+o/0Ato4A1AvwnDg8OOMPc6yWJ9JpcA==}
peerDependencies:
eslint: '>=8'
storybook: ^10.3.5
storybook: ^10.3.6
eslint-plugin-unicorn@56.0.1:
resolution: {integrity: sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog==}
@@ -4311,6 +4363,9 @@ packages:
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@6.0.1:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
@@ -5154,6 +5209,10 @@ packages:
resolution: {integrity: sha512-Q9wJ/xhzeD9Wua1MwDN2v3ah3HENsUVSlzzL9Qw149cL9hHZkXtQGl3Eq36BbdLV+/qUwaP1WtJQ+H/+Oxso8g==}
engines: {node: 20 || 22 || 24}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -5429,6 +5488,10 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6059,6 +6122,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -6116,14 +6183,17 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
storybook@10.3.5:
resolution: {integrity: sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==}
storybook@10.3.6:
resolution: {integrity: sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==}
hasBin: true
peerDependencies:
prettier: ^2 || ^3
vite-plus: ^0.1.15
peerDependenciesMeta:
prettier:
optional: true
vite-plus:
optional: true
stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
@@ -6295,6 +6365,10 @@ packages:
toggle-selection@1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
@@ -7634,6 +7708,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blazediff/core@1.9.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@codecov/bundler-plugin-core@1.9.1':
@@ -8473,6 +8549,8 @@ snapshots:
dependencies:
playwright: 1.59.1
'@polka/url@1.0.0-next.29': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -9139,15 +9217,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-docs@10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
'@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5)
'@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@storybook/csf-plugin': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
'@storybook/react-dom-shim': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ts-dedent: 2.2.0
transitivePeerDependencies:
- '@types/react'
@@ -9156,10 +9234,24 @@ snapshots:
- vite
- webpack
'@storybook/builder-vite@10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
'@storybook/addon-vitest@10.3.6(@vitest/browser-playwright@4.1.5)(@vitest/browser@4.1.5)(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5)':
dependencies:
'@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@storybook/global': 5.0.0
'@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
optionalDependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/runner': 4.1.5
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
transitivePeerDependencies:
- react
- react-dom
'@storybook/builder-vite@10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
dependencies:
'@storybook/csf-plugin': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ts-dedent: 2.2.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)
transitivePeerDependencies:
@@ -9167,9 +9259,9 @@ snapshots:
- rollup
- webpack
'@storybook/csf-plugin@10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
'@storybook/csf-plugin@10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
dependencies:
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.28.0
@@ -9183,25 +9275,25 @@ snapshots:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@storybook/react-dom-shim@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
'@storybook/react-dom-shim@10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))':
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@storybook/react-vite@10.3.5(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
'@storybook/react-vite@10.3.6(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@storybook/builder-vite': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)
'@storybook/builder-vite': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@storybook/react': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)
empathic: 2.0.0
magic-string: 0.30.21
react: 19.2.5
react-docgen: 8.0.3
react-dom: 19.2.5(react@19.2.5)
resolve: 1.22.12
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
tsconfig-paths: 4.2.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)
transitivePeerDependencies:
@@ -9211,15 +9303,15 @@ snapshots:
- typescript
- webpack
'@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)':
'@storybook/react@10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
'@storybook/react-dom-shim': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))
react: 19.2.5
react-docgen: 8.0.3
react-docgen-typescript: 2.4.0(typescript@5.9.3)
react-dom: 19.2.5(react@19.2.5)
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
@@ -9745,7 +9837,37 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
'@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)':
dependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
playwright: 1.59.1
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)':
dependencies:
'@blazediff/core': 1.9.1
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@vitest/utils': 4.1.5
magic-string: 0.30.21
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/coverage-v8@4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.5
@@ -9757,7 +9879,9 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
optionalDependencies:
'@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/expect@3.2.4':
dependencies:
@@ -9810,6 +9934,17 @@ snapshots:
'@vitest/spy@4.1.5': {}
'@vitest/ui@4.1.5(vitest@4.1.5)':
dependencies:
'@vitest/utils': 4.1.5
fflate: 0.8.2
flatted: 3.4.2
pathe: 2.0.3
sirv: 3.0.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
@@ -10966,11 +11101,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-storybook@10.3.5(eslint@8.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3):
eslint-plugin-storybook@10.3.6(eslint@8.57.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.58.2(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
transitivePeerDependencies:
- supports-color
- typescript
@@ -11151,6 +11286,8 @@ snapshots:
fflate@0.4.8: {}
fflate@0.8.2: {}
file-entry-cache@6.0.1:
dependencies:
flat-cache: 3.2.0
@@ -12051,6 +12188,8 @@ snapshots:
mktemp@2.0.2: {}
mrmime@2.0.1: {}
ms@2.0.0: {}
ms@2.1.3: {}
@@ -12381,6 +12520,8 @@ snapshots:
pngjs@5.0.0: {}
pngjs@7.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.11):
@@ -13141,6 +13282,12 @@ snapshots:
signal-exit@4.1.0: {}
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
slash@3.0.0: {}
@@ -13195,7 +13342,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@storybook/global': 5.0.0
'@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -13429,6 +13576,8 @@ snapshots:
toggle-selection@1.0.6: {}
totalist@3.0.1: {}
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.86
@@ -13788,9 +13937,9 @@ snapshots:
axe-core: 4.11.3
chalk: 5.6.2
lodash-es: 4.18.1
vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest: 4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest@4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)):
vitest@4.1.5(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
@@ -13814,7 +13963,9 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
'@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)
'@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)
'@vitest/ui': 4.1.5(vitest@4.1.5)
jsdom: 26.1.0
transitivePeerDependencies:
- msw
-4
View File
@@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details.
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */
+118 -17
View File
@@ -5,20 +5,41 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { fn } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import {
useArgs,
useCallback,
useEffect,
} from "storybook/internal/preview-api";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterProps } from "./CallFooter";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { createMockedViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { GridMode } from "../state/CallViewModel/CallViewModel";
import { constant } from "../state/Behavior";
function CallFooterWrapper(props: FooterProps): ReactNode {
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
* - Add additional react context
* @param props
* @returns
*/
function CallFooterStoryWrapper(
props: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
},
): ReactNode {
const { children, ...vmProps } = props;
const vm = createMockedViewModel(vmProps);
return (
<div className={inCallViewStyles.inRoom}>
<div /*className={inCallViewStyles.inRoom}*/>
<ReactionsSenderContext
value={{
supportsReactions: false,
@@ -26,15 +47,15 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter {...props} />
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
);
}
const meta = {
component: CallFooterWrapper,
} satisfies Meta<typeof CallFooterWrapper>;
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -50,9 +71,10 @@ const fnArgType = {
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
hideLogo: true,
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
@@ -62,6 +84,7 @@ export const Default: Story = {
toggleVideo: fn(),
toggleScreenSharing: fn(),
hangup: fn(),
buttonSize: "lg",
},
parameters: {
layout: "fullscreen",
@@ -88,11 +111,29 @@ export const Default: Story = {
},
};
export const WithAudioAndVideoOptions: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
videoOptions: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
},
};
export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
showLogo: true,
},
};
@@ -103,6 +144,35 @@ export const AudioVideoEnabled: Story = {
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const WithAudioOutputSpeaker: Story = {
@@ -132,7 +202,38 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
@@ -140,7 +241,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
hideLogo: false,
showLogo: true,
},
};
@@ -169,7 +270,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -185,7 +286,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -199,7 +300,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +318,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -231,7 +332,7 @@ export const LobbyRecentButtonMobile: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
+396 -74
View File
@@ -7,13 +7,13 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { combineLatest, map } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { Switch } from "@vector-im/compound-web";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -29,35 +29,54 @@ import {
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
type CallViewModel,
type GridMode,
} from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import { mediaDeviceLabelToString } from "../settings/DeviceSelection";
import {
backgroundBlur as backgroundBlurSettings,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { type ViewModel, useViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { t } from "i18next";
export interface AudioOutputSwitcher {
targetOutput: string;
switch: () => void;
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
/** Children will only be visible if the component is wider than 5*/
children?: JSX.Element | JSX.Element[] | false;
export interface FooterSnapshot {
audioEnabled: boolean;
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
/* This is needed for WindowMode = "flat" */
hideControls?: boolean;
/** hide the entire footer*/
hidden?: boolean;
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
asPip?: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens.*/
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay?: boolean;
buttonSize: "md" | "lg";
showSettingsButton?: boolean;
showLayoutSwitcher?: boolean;
showLogoDebugContainer?: boolean;
showLogo?: boolean;
layoutMode?: GridMode;
/** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void;
@@ -65,7 +84,7 @@ export interface FooterProps {
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
/** Also controls if the audio button is visible */
/** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */
openSettings?: () => void;
@@ -75,46 +94,319 @@ export interface FooterProps {
reactionIdentifier?: string;
reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
audioOptions?: MenuOptions[];
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioButtonOption?: (deviceId: string) => void;
selectVideoButtonOption?: (option: string) => void;
videoToggles?: ToggleOption[];
}
export const CallFooter: FC<FooterProps> = ({
ref,
children,
asOverlay,
hidden,
hideControls,
hideLogo,
asPip,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
}) => {
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
"audioEnabled" | "toggleAudio" | "videoEnabled" | "toggleVideo"
> {
return {
audioEnabled: muteStates.audio.enabled$,
toggleAudio: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled: muteStates.video.enabled$,
toggleVideo: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
};
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions"
| "selectedAudio"
| "selectAudioButtonOption"
| "videoOptions"
| "selectedVideo"
| "selectVideoButtonOption"
| "videoToggles"
> {
return {
audioOptions: scope.behavior(
mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label: mediaDeviceLabelToString(label, (n) => "Audio Device " + n),
})),
),
),
),
selectedAudio: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption: constant(mediaDevices.audioInput.select),
videoOptions: scope.behavior(
mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label: mediaDeviceLabelToString(label, (n) => "Camera " + n),
})),
),
),
),
selectedVideo: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption: scope.behavior(
backgroundBlurSettings.value$.pipe(
map((current) => {
return (option: string) => {
if (option === "blur") {
backgroundBlurSettings.setValue(!current);
} else {
mediaDevices.videoInput.select(option);
}
};
}),
),
),
videoToggles: scope.behavior(
backgroundBlurSettings.value$.pipe(
map((blurActive) =>
supportsBackgroundProcessors()
? [{ id: "blur", enabled: blurActive, label: "Blur Background" }]
: [],
),
),
),
};
}
/**
* Creates the ViewModel for the CallFooter.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param vm - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined if the
* settings button should be hidden (e.g. when it is already shown in an app bar).
* @param hideControls - When true the button row is hidden (from URL param).
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
*/
export function createCallFooterViewModel(
scope: ObservableScope,
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const hideLogo = headerStyle !== HeaderStyle.Standard;
return {
...buildMuteBehaviors(scope, muteStates),
// ── Visibility / sizing ──────────────────────────────────────────────────
hideControls: constant(!showControls),
asOverlay: scope.behavior(
callModel.windowMode$.pipe(map((mode) => mode === "flat")),
),
buttonSize: scope.behavior(
callModel.layout$.pipe(
map((l) => (l.type === "pip" ? "md" : "lg") as "md" | "lg"),
),
),
showSettingsButton: scope.behavior(
combineLatest([callModel.layout$, callModel.showHeader$]).pipe(
map(
([l, showHeader]) =>
openSettings !== undefined &&
l.type !== "pip" &&
showControls &&
!(headerStyle === HeaderStyle.AppBar && showHeader),
),
),
),
showLayoutSwitcher: scope.behavior(
callModel.layout$.pipe(map((l) => l.type !== "pip" && showControls)),
),
showLogoDebugContainer: scope.behavior(
combineLatest([callModel.layout$, debugTileLayoutSetting.value$]).pipe(
map(([l, debugTile]) => l.type !== "pip" || (!hideLogo && !debugTile)),
),
),
showLogo: scope.behavior(
callModel.layout$.pipe(map((l) => !hideLogo && l.type !== "pip")),
),
// ── Layout mode ───────────────────────────────────────────────────────────
layoutMode: callModel.gridMode$,
setLayoutMode: constant(callModel.setGridMode),
// ── Screen sharing ────────────────────────────────────────────────────────
sharingScreen: callModel.sharingScreen$,
toggleScreenSharing: constant(callModel.toggleScreenSharing ?? undefined),
// ── Audio output ─────────────────────────────────────────────────────────
audioOutputSwitcher: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
// ── Actions ───────────────────────────────────────────────────────────────
openSettings: scope.behavior(
callModel.showHeader$.pipe(
map((showHeader) =>
headerStyle === HeaderStyle.AppBar && showHeader
? undefined
: openSettings,
),
),
),
hangup: constant(callModel.hangup),
// ── Reactions ─────────────────────────────────────────────────────────────
reactionIdentifier: constant(reactionIdentifier),
reactionData: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
// ── Debug ─────────────────────────────────────────────────────────────────
debugTileLayout: debugTileLayoutSetting.value$,
tileStoreGeneration: callModel.tileStoreGeneration$,
...buildDeviceBehaviors(scope, mediaDevices),
};
}
/**
* Creates a simplified ViewModel for the CallFooter used in the lobby
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
* a CallViewModel — it only needs mute states, device lists, and callbacks.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
*/
export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices),
// ── Visibility / sizing ───────────────────────────────────────────────────
hideControls: constant(false),
asOverlay: constant(false),
buttonSize: constant("lg"),
showSettingsButton: constant(openSettings !== undefined),
showLayoutSwitcher: constant(false),
showLogoDebugContainer: constant(showLogo),
showLogo: constant(showLogo),
// ── Layout mode (not applicable in lobby) ─────────────────────────────────
layoutMode: constant(undefined),
setLayoutMode: constant(undefined),
// ── Screen sharing (not applicable in lobby) ──────────────────────────────
sharingScreen: constant(undefined),
toggleScreenSharing: constant(undefined),
// ── Audio output (not applicable in lobby) ────────────────────────────────
audioOutputSwitcher: constant(undefined),
// ── Actions ───────────────────────────────────────────────────────────────
openSettings: constant(openSettings),
hangup: constant(hangup),
// ── Reactions (not applicable in lobby) ───────────────────────────────────
reactionIdentifier: constant(undefined),
reactionData: constant(undefined),
// ── Debug (not needed in lobby) ───────────────────────────────────────────
debugTileLayout: constant(false),
tileStoreGeneration: constant(0),
};
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const {
asOverlay,
hideControls,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
videoOptions,
selectedVideo,
audioOptions,
selectedAudio,
selectAudioButtonOption,
selectVideoButtonOption,
videoToggles,
buttonSize,
showSettingsButton,
showLogoDebugContainer,
showLogo,
} = useViewModel(vm);
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
const showLogo = !hideLogo && !asPip;
if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens.
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
// Add the settings button to the center group so it's visible on small
// screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push(
<SettingsButton
key="settings"
@@ -125,24 +417,60 @@ export const CallFooter: FC<FooterProps> = ({
);
}
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
/>,
);
} else {
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_videomute"
options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
/>,
);
} else {
buttons.push(
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
}
if (toggleScreenSharing !== undefined) {
buttons.push(
@@ -161,12 +489,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
@@ -219,7 +542,6 @@ export const CallFooter: FC<FooterProps> = ({
ref={ref}
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})}
>
<div className={styles.settingsLogoContainer}>
@@ -236,7 +558,7 @@ export const CallFooter: FC<FooterProps> = ({
{showLogoDebugContainer && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
{setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}
@@ -0,0 +1,37 @@
/*
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.
*/
.container {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 32px;
transition: background-color 0.2s ease-in-out;
}
.containerOpen {
background-color: var(--cpd-color-bg-action-primary-pressed);
}
.chevronIconOpen > svg {
color: var(--cpd-color-icon-on-solid-primary);
}
.menuButton {
width: 40px;
background-color: transparent !important;
}
.itemIcon {
color: var(--cpd-color-text-secondary);
}
.rotate {
animation: spinner 1.5s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,117 @@
/*
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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { fn, userEvent, within, expect } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
const meta = {
component: MediaMuteAndSwitchButton,
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
iconsAndLabels: {
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
enabledLabel: "Enabled",
disabledLabel: "Disabled",
optionsButtonLabel: "Options",
},
enabled: true,
options: [
{ label: "option 1", id: "1" },
{ label: "option 2", id: "2" },
],
selectedOption: "1",
onMuteClick: fn(),
onSelect: fn(),
},
};
export const AudioMute: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: false,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [
{
label: "example toggle",
id: "t0",
enabled: true,
},
],
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
// (both are TODO placeholders in the component). The mute button is first in the DOM.
const muteButton = canvas.getByLabelText("Unmute microphone");
await userEvent.click(muteButton);
await expect(args.onMuteClick).toHaveBeenCalled();
},
};
export const AudioUnmute: Story = {
args: {
title: "Microphone",
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [],
selectedOption: "2",
},
};
export const VideoMute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: false,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [],
selectedOption: "1",
},
};
export const VideoUnmute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: true,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [
{
label: "background blurring",
id: "background_blurring",
enabled: false,
},
],
selectedOption: "2",
},
};
@@ -0,0 +1,226 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState } from "react";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<MediaMuteAndSwitchButton title={"Switcher"} />,
);
expect(container).toMatchSnapshot();
});
test("renders correct audio and video labels", () => {
const renderLabels = (
type: "video" | "audio",
enabled: boolean,
): RenderResult => {
return render(
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
);
};
const renderAudioEndabled = renderLabels("audio", true);
const renderAudioDisabled = renderLabels("audio", false);
const renderVideoEnabled = renderLabels("video", true);
const renderVideoDisabled = renderLabels("video", false);
expect(
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("button", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
).toBeInTheDocument();
});
test("calls mute on mute press", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>,
);
await user.click(getByRole("button", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
test("calls select callback on menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelect).toHaveBeenCalledWith("mic2");
});
test("does not call select callback on already selected menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
expect(onSelect).not.toHaveBeenCalled();
});
test("renders menu spinner until selection updates for the component", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
const onSelectPressed = vi.fn();
const onOptionUpdated = vi.fn();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
);
}
const { getByRole } = render(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelectPressed).toHaveBeenCalled();
expect(onOptionUpdated).not.toHaveBeenCalled();
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
// so a spinner should appear on the mic2 item
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
// The currently-selected mic1 item should not have a spinner
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic1Item.querySelector(".rotate")).toBeNull();
await act(async () => {
// resolve the promise that acutally updates the select option.
resolve();
await promise;
});
expect(onOptionUpdated).toHaveBeenCalled();
// Spinner should now be gone since the selection has caught up
const mic2ItemAfter = screen.getByRole("menuitem", {
name: "Microphone 2",
});
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
});
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
});
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic2"
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});
+230
View File
@@ -0,0 +1,230 @@
/*
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 ComponentType, useState, type FC } from "react";
import {
Button,
Menu,
MenuItem,
ToggleMenuItem,
} from "@vector-im/compound-web";
import { t } from "i18next";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOffSolidIcon,
MicOnIcon,
MicOnSolidIcon,
SpinnerIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import styles from "./MediaMuteAndSwitchButton.module.css";
export interface MenuOptions {
label: string;
id: string;
}
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface IconsAndLabels {
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
enabledLabel: string;
disabledLabel: string;
optionsButtonLabel: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
/** If the Mute button is enabled */
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
/** The options available for the media device selector modal */
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* The available toggles (including there current state)
* The toggle state is not stored by this component.
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
*/
toggles?: ToggleOption[];
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
*/
onSelect?: (id: string) => void;
}
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
onMuteClick,
iconsAndLabels: iconsAndLabelsWithDefaultCases,
options,
selectedOption,
toggles,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let iconsAndLabels: IconsAndLabels | undefined;
switch (iconsAndLabelsWithDefaultCases) {
case "video":
iconsAndLabels = {
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
disabledLabel: t("stop_video_button_label"),
enabledLabel: t("start_video_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
case "audio":
iconsAndLabels = {
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
disabledLabel: t("mute_microphone_button_label"),
enabledLabel: t("unmute_microphone_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
default:
iconsAndLabels = iconsAndLabelsWithDefaultCases;
break;
}
const {
IconEnabled,
IconDisabled,
IconOptions,
disabledLabel,
enabledLabel,
optionsButtonLabel,
} = iconsAndLabels ?? {
IconEnabled: undefined,
IconDisabled: undefined,
IconOptions: undefined,
disabledLabel: undefined,
enabledLabel: undefined,
optionsButtonLabel: undefined,
};
{
logger.info(
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
selectedOption,
" !==",
"option.id",
" && ",
plannedSelection,
" === ",
"option.id",
);
}
return (
<div
className={classNames({
[styles.container]: true,
[styles.containerOpen]: menuOpen,
})}
>
{/* The mute button lives inside */}
<Button
iconOnly
Icon={enabled ? IconEnabled : IconDisabled}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
kind={enabled ? "secondary" : "primary"}
size="lg"
className={styles.button}
aria-label={enabled ? disabledLabel : enabledLabel}
/>
<Menu
title={title}
showTitle={true}
open={menuOpen}
onOpenChange={setMenuOpen}
side="top"
trigger={
<Button
iconOnly
className={classNames({
[styles.menuButton]: true,
[styles.chevronIconOpen]: menuOpen,
})}
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
kind={"tertiary"}
size="lg"
aria-label={optionsButtonLabel}
/>
}
>
{options?.map((option) => (
<MenuItem
hideChevron
label={option.label}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (option.id === selectedOption) return;
setPlannedSelection(option.id);
onSelect?.(option.id);
}}
key={option.id}
>
{selectedOption === option.id && (
<CheckIcon width={24} height={24} />
)}
{selectedOption !== option.id && plannedSelection === option.id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
))}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
onSelect?.(toggle.id);
e.preventDefault();
}}
checked={toggle.enabled}
key={toggle.id}
/>
))}
</Menu>
</div>
);
};
@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div>
<div
class="container"
>
<button
class="_button_1nw83_8 button _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
/>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
</div>
`;
+35 -48
View File
@@ -43,7 +43,6 @@ import { InviteButton } from "../button/InviteButton";
import {
type CallViewModel,
createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
@@ -68,11 +67,7 @@ import {
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
matrixRTCMode as matrixRTCModeSetting,
useSetting,
} from "../settings/settings";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
@@ -90,7 +85,10 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter } from "../components/CallFooter.tsx";
import {
CallFooter,
createCallFooterViewModel,
} from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
const logger = rootLogger.getChild("[InCallView]");
@@ -220,9 +218,7 @@ export const InCallView: FC<InCallViewProps> = ({
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const mediaDevices = useMediaDevices();
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -241,14 +237,10 @@ export const InCallView: FC<InCallViewProps> = ({
const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary
@@ -348,11 +340,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => new BehaviorSubject(defaultPipAlignment),
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
@@ -559,8 +546,34 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId,
);
const settingsButtonInAppBar =
headerStyle === HeaderStyle.AppBar && showHeader;
const footerScope = useMemo(() => new ObservableScope(), []);
useEffect(() => (): void => footerScope.end(), [footerScope]);
// Build the footer view-model once per stable set of domain-object references.
// The scalar inputs (reactionIdentifier) are derived from URL params and are
// effectively static for the call lifetime.
const footerVm = useMemo(
() =>
createCallFooterViewModel(
footerScope,
vm,
muteStates,
mediaDevices,
openSettings,
supportsReactions
? `${client.getUserId()}:${client.getDeviceId()}`
: undefined,
),
[
footerScope,
vm,
muteStates,
mediaDevices,
openSettings,
supportsReactions,
client,
],
);
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
@@ -571,33 +584,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<CallFooter
ref={footerRef}
hidden={!showFooter}
hideControls={!showControls}
asOverlay={windowMode === "flat"}
asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}
layoutMode={gridMode}
setLayoutMode={setGridMode}
audioEnabled={audioEnabled}
toggleAudio={toggleAudio ?? undefined}
videoEnabled={videoEnabled}
toggleVideo={toggleVideo ?? undefined}
sharingScreen={sharingScreen}
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
reactionData={supportsReactions ? vm : undefined}
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
// Only pass the openSettings function if the settings button is not in the app bar.
// If there is no fn the button will be hidden in the footer.
openSettings={settingsButtonInAppBar ? undefined : openSettings}
hangup={vm.hangup}
//Debug props
debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration}
/>
<>{showFooter && <CallFooter ref={footerRef} vm={footerVm} />}</>
);
const allConnections = useBehavior(vm.allConnections$);
+21 -11
View File
@@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useInitial } from "../useInitial";
import {
useTrackProcessor,
@@ -46,7 +47,10 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import {
CallFooter,
createLobbyFooterViewModel,
} from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props {
@@ -184,6 +188,21 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const footerScope = useInitial(() => new ObservableScope());
useEffect((): (() => void) => () => footerScope.end(), [footerScope]);
const footerVm = useInitial(() =>
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
openSettings,
!confineToRoom ? onLeaveClick : undefined,
// Logo and header are connected: only show the logo in SPA with header.
!hideHeader,
),
);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -227,16 +246,7 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<CallFooter
audioEnabled={audioEnabled}
videoEnabled={videoEnabled}
toggleAudio={toggleAudio ?? undefined}
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
>
<CallFooter vm={footerVm}>
{recentsButtonInFooter && recentsButton}
</CallFooter>
</div>
+31 -42
View File
@@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type ChangeEvent,
type FC,
type ReactElement,
type ReactNode,
useCallback,
useId,
} from "react";
import { type ChangeEvent, type FC, useCallback, useId } from "react";
import {
Heading,
InlineField,
@@ -20,8 +13,8 @@ import {
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import { t } from "i18next";
import {
type AudioOutputDeviceLabel,
@@ -37,12 +30,39 @@ interface Props {
numberedLabel: (number: number) => string;
}
export function mediaDeviceLabelToString(
label: DeviceLabel | AudioOutputDeviceLabel,
numberedLabel: (number: number) => string,
): string {
let labelText = "";
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText =
label.name === null
? t("settings.devices.default")
: t("settings.devices.default") + " (" + label.name + ")";
break;
case "speaker":
labelText = t("settings.devices.loudspeaker");
break;
case "earpiece":
labelText = t("settings.devices.handset");
break;
}
return labelText;
}
export const DeviceSelection: FC<Props> = ({
device,
title,
numberedLabel,
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
@@ -70,38 +90,7 @@ export const DeviceSelection: FC<Props> = ({
<Separator className={styles.separator} />
<div className={styles.options}>
{[...available].map(([id, label]) => {
let labelText: ReactNode;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
case "default":
labelText =
label.name === null ? (
t("settings.devices.default")
) : (
<Trans
i18nKey="settings.devices.default_named"
name={label.name}
>
Default{" "}
<span className={styles.secondary}>
({{ name: label.name } as unknown as ReactElement})
</span>
</Trans>
);
break;
case "speaker":
labelText = t("settings.devices.loudspeaker");
break;
case "earpiece":
labelText = t("settings.devices.handset");
break;
}
const labelText = mediaDeviceLabelToString(label, numberedLabel);
return (
<InlineField
key={id}
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 Element Software Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { useBehavior } from "../useBehavior";
import { type Behavior } from "./Behavior";
export type ViewModel<Snapshot> = {
[K in keyof Snapshot]: Behavior<Snapshot[K]>;
};
export function useViewModel<Snapshot>(vm: ViewModel<Snapshot>): Snapshot {
const snapshot = {} as Snapshot;
for (const key in vm) {
const value$ = vm[key];
// eslint-disable-next-line react-hooks/rules-of-hooks
snapshot[key] = useBehavior(value$);
}
return snapshot;
}
export function createMockedViewModel<Snapshot>(
snapshot: Snapshot,
): ViewModel<Snapshot> {
const vm = {} as ViewModel<Snapshot>;
for (const key in snapshot) {
vm[key] = new BehaviorSubject(snapshot[key]);
}
return vm;
}
+18 -12
View File
@@ -24,18 +24,10 @@ import react from "@vitejs/plugin-react";
import { realpathSync } from "fs";
import * as fs from "node:fs";
// https://vitejs.dev/config/
// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite)
export default ({
export const vitePluginsConfig = ({
mode,
packageType,
}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => {
}: Pick<ConfigEnv, "mode">): UserConfig => {
const env = loadEnv(mode, process.cwd());
// Environment variables with the VITE_ prefix are accessible at runtime.
// So, we set this to allow for build/package specific behavior.
// In future we might be able to do what is needed via code splitting at
// build time.
process.env.VITE_PACKAGE = packageType ?? "full";
const plugins: PluginOption[] = [
react(),
wasm(),
@@ -72,7 +64,7 @@ export default ({
);
}
if (!process.env.STORYBOOK) {
if (!process.env.STORYBOOK && !process.env.VITEST) {
plugins.push(
createHtmlPlugin({
entry: "src/main.tsx",
@@ -86,6 +78,20 @@ export default ({
);
}
return { plugins };
};
// https://vitejs.dev/config/
// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite)
export default ({
mode,
packageType,
}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => {
// Environment variables with the VITE_ prefix are accessible at runtime.
// So, we set this to allow for build/package specific behavior.
// In future we might be able to do what is needed via code splitting at
// build time.
process.env.VITE_PACKAGE = packageType ?? "full";
// The crypto WASM module is imported dynamically. Since it's common
// for developers to use a linked copy of matrix-js-sdk or Rust
// crypto (which could reside anywhere on their file system), Vite
@@ -102,6 +108,7 @@ export default ({
console.log("Allowed vite paths:", allow);
return {
...vitePluginsConfig({ mode }),
server: {
port: 3000,
fs: { allow },
@@ -136,7 +143,6 @@ export default ({
},
},
},
plugins,
resolve: {
alias: {
// matrix-widget-api has its transpiled lib/index.js as its entry point,
+43 -9
View File
@@ -1,20 +1,54 @@
import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import { vitePluginsConfig } from "./vite.config";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import path from "node:path";
import { fileURLToPath } from "node:url";
import viteConfig from "./vite.config";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig((configEnv) =>
mergeConfig(
viteConfig(configEnv),
vitePluginsConfig(configEnv),
defineConfig({
test: {
environment: "jsdom",
css: {
modules: {
classNameStrategy: "non-scoped",
projects: [
{
extends: true,
test: {
css: {
modules: {
classNameStrategy: "non-scoped",
},
},
setupFiles: ["src/vitest.setup.ts"],
environment: "jsdom",
// an example of file based convention,
// you don't have to follow it
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
name: "unit",
},
},
},
setupFiles: ["src/vitest.setup.ts"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
{
plugins: [
storybookTest({
// The location of your Storybook config, main.js|ts
configDir: path.join(dirname, ".storybook"),
}),
...vitePluginsConfig(configEnv).plugins!,
],
test: {
name: "storybook",
browser: {
enabled: true,
// Make sure to install Playwright
provider: playwright(),
headless: true,
instances: [{ browser: "chromium" }],
},
},
},
],
coverage: {
reporter: ["html", "json"],
include: ["src/**/*.{ts,tsx,js,jsx}"],