mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c678087b3 | |||
| b6a57b5545 |
@@ -10,28 +10,6 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
|
||||||
- ✨(frontend) add configurable documentation menu item
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
|
||||||
- 🐛(summary) properly detect when failure webhook should be sent
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
|
||||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
|
||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
|
||||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
|
||||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(backend) allow searching the recording admin table by owner email
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
- ✨(all) allow forcing SSO display name for authenticated users
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
@@ -56,7 +34,6 @@ and this project adheres to
|
|||||||
- 🩹(backend) identify externally provisioned users to PostHog
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
- ♿️(frontend) focus side panel container on open #1452
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
- 🐛(summary) whisper call error handling
|
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ echo " - src/backend/pyproject.toml"
|
|||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
echo " - src/agents/uv.lock"
|
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "Next steps:"
|
print_warning "Next steps:"
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
|||||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
|||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||||
|
|
||||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "^26.3.2",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.2",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.2",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.4",
|
"livekit-agents==1.6.4",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -398,9 +398,6 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
"documentation_url": values.Value(
|
|
||||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
|
||||||
),
|
|
||||||
"external_home_url": values.Value(
|
"external_home_url": values.Value(
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+26
-32
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
@@ -1028,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.46.6",
|
"version": "1.45.8",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1049,18 +1049,12 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
|
||||||
"version": "0.10.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
|
||||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -2421,9 +2415,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2442,12 +2436,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.1"
|
"@tanstack/query-core": "5.101.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8125,13 +8119,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.20.0",
|
"version": "2.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.46.6",
|
"@livekit/protocol": "1.45.8",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -9136,13 +9130,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.395.0",
|
"version": "1.391.2",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.38.0",
|
"@posthog/core": "^1.35.3",
|
||||||
"@posthog/types": "^1.391.1",
|
"@posthog/types": "^1.390.2",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
|
|||||||
@@ -114,10 +114,6 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overlayIn: {
|
|
||||||
from: { opacity: 0 },
|
|
||||||
to: { opacity: 0.6 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
documentation_url?: string
|
|
||||||
external_home_url?: string
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
|
|||||||
@@ -7,20 +7,36 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: { width: '40px', height: '40px' },
|
subtitles: {
|
||||||
list: { width: '32px', height: '32px' },
|
width: '40px',
|
||||||
placeholder: { width: '100%', height: '100%' },
|
height: '40px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: { border: '2px solid white' },
|
true: {
|
||||||
|
border: '2px solid white',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -33,35 +49,32 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = ({
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
name,
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
bgColor,
|
||||||
return (
|
context,
|
||||||
<div
|
notification,
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style,
|
||||||
className={avatar({ context, notification })}
|
...props
|
||||||
{...props}
|
}: AvatarProps) => {
|
||||||
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className={avatar({ context, notification })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({
|
||||||
|
marginTop: '-0.3rem',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<svg
|
{initial}
|
||||||
viewBox="0 0 100 100"
|
</span>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
)
|
||||||
>
|
}
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="50"
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize="52"
|
|
||||||
fontWeight="500"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Avatar.displayName = 'Avatar'
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { ChatMessages } from './ChatMessages'
|
|
||||||
import { ChatTextArea } from './ChatTextArea'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const ChatContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
flexGrow: 1,
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ChatMessagesContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TextContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0.75rem',
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Chat = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatContainer>
|
|
||||||
<TextContainer>
|
|
||||||
<Text variant="sm">{t('disclaimer')}</Text>
|
|
||||||
</TextContainer>
|
|
||||||
<ChatMessagesContainer>
|
|
||||||
<ChatMessages />
|
|
||||||
</ChatMessagesContainer>
|
|
||||||
<ChatTextArea />
|
|
||||||
</ChatContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { ChatRow } from '@/stores/chat'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
|
||||||
import { ChatMessageBody } from './ChatMessageBody'
|
|
||||||
|
|
||||||
const StyledContainer = styled('li', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageProps = {
|
|
||||||
item: ChatRow
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
|
||||||
const time = new Date(item.timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
return (
|
|
||||||
<StyledContainer
|
|
||||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
|
||||||
>
|
|
||||||
{!item.hideMetadata && (
|
|
||||||
<ChatMessageMetadata
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
identity={item.identity}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ChatMessageBody message={item.message} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
|
||||||
|
|
||||||
export const ChatMessageBody = React.memo(
|
|
||||||
({ message }: ChatMessageBodyProps) => {
|
|
||||||
const formattedMessage = useMemo(() => {
|
|
||||||
return formatChatMessageLinks(message)
|
|
||||||
}, [message])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
'& .lk-chat-link': {
|
|
||||||
color: 'blue',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{formattedMessage}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
const StyledContainer = styled('span', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
|
||||||
|
|
||||||
export const ChatMessageMetadata = React.memo(
|
|
||||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
|
||||||
const time = new Date(timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
|
|
||||||
const { names } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const currentDisplayName = useMemo(() => {
|
|
||||||
if (identity) return names[identity] ?? identity
|
|
||||||
}, [names, identity])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<Text bold={true} variant="sm">
|
|
||||||
{currentDisplayName}
|
|
||||||
</Text>
|
|
||||||
<Text variant="smNote" wrap="no">
|
|
||||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
|
||||||
</Text>
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
ListBox,
|
|
||||||
ListBoxItem,
|
|
||||||
ListLayout,
|
|
||||||
Virtualizer,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ChatMessage } from './ChatMessage.tsx'
|
|
||||||
import { useLayoutEffect, useRef } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
|
|
||||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
|
||||||
// rendered height of each row; this value is only used to size the
|
|
||||||
// scrollbar before rows have been measured.
|
|
||||||
const ESTIMATED_ROW_HEIGHT = 56
|
|
||||||
const BOTTOM_THRESHOLD_PX = 32
|
|
||||||
|
|
||||||
export const ChatMessages = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
const { rows: items } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
|
||||||
const stick = useRef(true)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = listRef.current
|
|
||||||
if (!el) return
|
|
||||||
const pin = () => {
|
|
||||||
if (stick.current) el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
const onScroll = () => {
|
|
||||||
stick.current =
|
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(pin)
|
|
||||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
|
||||||
// time an estimated row height is replaced by a measured one.
|
|
||||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
|
||||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
|
||||||
ro.observe(el)
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
|
||||||
pin()
|
|
||||||
return () => {
|
|
||||||
ro.disconnect()
|
|
||||||
el.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer
|
|
||||||
layout={ListLayout}
|
|
||||||
layoutOptions={{
|
|
||||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
|
||||||
gap: 4,
|
|
||||||
padding: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListBox
|
|
||||||
ref={listRef}
|
|
||||||
aria-label={t('messagesLabel', 'Chat messages')}
|
|
||||||
items={items}
|
|
||||||
selectionMode="none"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item: ChatRow) => (
|
|
||||||
<ListBoxItem style={{ width: '100%' }}>
|
|
||||||
<ChatMessage item={item} />
|
|
||||||
</ListBoxItem>
|
|
||||||
)}
|
|
||||||
</ListBox>
|
|
||||||
</Virtualizer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
|
||||||
import { ref } from 'valtio'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
|
||||||
import type { ChatMessage } from '@livekit/components-core'
|
|
||||||
import {
|
|
||||||
LocalParticipant,
|
|
||||||
Participant,
|
|
||||||
RemoteParticipant,
|
|
||||||
RoomEvent,
|
|
||||||
} from 'livekit-client'
|
|
||||||
|
|
||||||
export const ChatProvider = () => {
|
|
||||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
|
||||||
const { send, chatMessages, isSending } = useChat()
|
|
||||||
const { isChatOpen } = useSidePanel()
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetChatStore()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
|
||||||
useEffect(() => {
|
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
|
||||||
// (see https://github.com/livekit/components-js/issues/1158)
|
|
||||||
// Remove this once we refactor chat to use the new text stream approach
|
|
||||||
const latestMessage = chatMessages.slice(-1)[0]
|
|
||||||
if (!latestMessage) return
|
|
||||||
const from = latestMessage.from as
|
|
||||||
| RemoteParticipant
|
|
||||||
| LocalParticipant
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
|
||||||
}, [chatMessages, room])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
|
||||||
appendRow(chatMessages[i])
|
|
||||||
}
|
|
||||||
}, [chatMessages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.send = ref(send)
|
|
||||||
}, [send])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.isSending = isSending
|
|
||||||
}, [isSending])
|
|
||||||
|
|
||||||
// Set the unread messages count
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatMessages.length === 0) return
|
|
||||||
const last = chatMessages[chatMessages.length - 1]
|
|
||||||
if (isChatOpen) {
|
|
||||||
lastReadMsgAt.current = last.timestamp
|
|
||||||
chatStore.unreadMessages = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatStore.unreadMessages = chatMessages.filter(
|
|
||||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
|
||||||
).length
|
|
||||||
}, [chatMessages, isChatOpen])
|
|
||||||
|
|
||||||
// Listen to participant name changes
|
|
||||||
useEffect(() => {
|
|
||||||
const setName = (p: Participant) => {
|
|
||||||
chatStore.names[p.identity] = p.name || p.identity
|
|
||||||
}
|
|
||||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
|
||||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
|
||||||
|
|
||||||
type ChatSubmitButtonProps = {
|
|
||||||
handleSubmit: () => Promise<void>
|
|
||||||
isDisabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatSubmitButton = React.memo(
|
|
||||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
variant="tertiaryText"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t('button.label')}
|
|
||||||
>
|
|
||||||
<RiSendPlane2Fill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TextArea } from '@/primitives'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import React, { useCallback, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import {
|
|
||||||
chatStore,
|
|
||||||
clearTextAreaValue,
|
|
||||||
persistTextAreaValue,
|
|
||||||
} from '@/stores/chat'
|
|
||||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
margin: '0.75rem 0 1.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
backgroundColor: 'gray.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ChatTextArea = () => {
|
|
||||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = inputRef.current
|
|
||||||
if (!el) return
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
el.focus({ preventScroll: true })
|
|
||||||
const end = el.value.length
|
|
||||||
el.setSelectionRange(end, end)
|
|
||||||
})
|
|
||||||
return () => cancelAnimationFrame(raf)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
const text = chatStore.textAreaValue
|
|
||||||
if (!send || !text) return
|
|
||||||
await send(text)
|
|
||||||
inputRef?.current?.focus({ preventScroll: true })
|
|
||||||
clearTextAreaValue()
|
|
||||||
}, [send, inputRef])
|
|
||||||
|
|
||||||
const isDisabled = !textAreaValue.trim() || isSending
|
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
|
||||||
return
|
|
||||||
e.preventDefault()
|
|
||||||
await handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
value={textAreaValue}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onChange={(e) => {
|
|
||||||
persistTextAreaValue(e.target.value)
|
|
||||||
}}
|
|
||||||
fieldSizing={'content'}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
height: 'auto',
|
|
||||||
maxHeight: '240px',
|
|
||||||
minHeight: `34px`,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
padding: '7px 10px',
|
|
||||||
}}
|
|
||||||
placeholderStyle="strong"
|
|
||||||
spellCheck={false}
|
|
||||||
maxLength={2000}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
|
||||||
aria-label={t('textArea.label')}
|
|
||||||
/>
|
|
||||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
|
||||||
|
|
||||||
interface CarouselLayoutState {
|
|
||||||
orientation: CarouselOrientation
|
|
||||||
maxVisibleTiles: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarouselLayoutObserverProps {
|
|
||||||
asideEl: React.RefObject<HTMLDivElement>
|
|
||||||
orientation?: CarouselOrientation
|
|
||||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselLayoutObserver = ({
|
|
||||||
orientation,
|
|
||||||
asideEl,
|
|
||||||
onLayoutChange,
|
|
||||||
}: CarouselLayoutObserverProps) => {
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
|
|
||||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
|
||||||
// container size hovers around a breakpoint. A ref (not state) because
|
|
||||||
// updating it must not trigger a re-render.
|
|
||||||
const prevTilesRef = React.useRef(0)
|
|
||||||
|
|
||||||
const carouselOrientation: CarouselOrientation =
|
|
||||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
|
||||||
|
|
||||||
const tileSpan =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
|
||||||
|
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const availableSpan =
|
|
||||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
|
||||||
|
|
||||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
|
|
||||||
let maxVisibleTiles: number
|
|
||||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
|
||||||
// Within the dead zone: keep the previous count.
|
|
||||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
|
||||||
} else {
|
|
||||||
maxVisibleTiles = Math.round(tilesThatFit)
|
|
||||||
prevTilesRef.current = tilesThatFit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply cosmetic layout output straight to the DOM.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = asideEl.current
|
|
||||||
if (!el) return
|
|
||||||
el.dataset.lkOrientation = carouselOrientation
|
|
||||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
|
||||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
|
||||||
|
|
||||||
// Report upward only what the parent actually needs for
|
|
||||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
|
||||||
useEffect(() => {
|
|
||||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
|
||||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -107,42 +40,52 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
const tileSpan =
|
||||||
orientation: orientation ?? 'vertical',
|
carouselOrientation === 'vertical'
|
||||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
})
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
// Stable callback + identity check: the parent only re-renders when the
|
const tilesThatFit =
|
||||||
// derived layout genuinely changed, not on every resize tick.
|
carouselOrientation === 'vertical'
|
||||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
setLayout((prev) =>
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
prev.orientation === next.orientation &&
|
|
||||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
|
||||||
? prev
|
|
||||||
: next
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
|
maxVisibleTiles = Math.round(prevTiles)
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside
|
||||||
<CarouselLayoutObserver
|
key={carouselOrientation}
|
||||||
asideEl={asideEl}
|
className="lk-carousel"
|
||||||
orientation={orientation}
|
ref={asideEl}
|
||||||
onLayoutChange={handleLayoutChange}
|
{...props}
|
||||||
/>
|
>
|
||||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
</aside>
|
||||||
<aside
|
|
||||||
key={layout.orientation}
|
|
||||||
className="lk-carousel"
|
|
||||||
ref={asideEl}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,36 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
|
||||||
|
|
||||||
interface GridLayoutObserverProps {
|
|
||||||
gridEl: React.RefObject<HTMLDivElement>
|
|
||||||
trackCount: number
|
|
||||||
onMaxTilesChange: (maxTiles: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that runs the layout calculation in isolation and
|
|
||||||
* reports the resulting tile capacity upward.
|
|
||||||
*
|
|
||||||
* `useGridLayout` re-renders its host on every layout recalculation
|
|
||||||
* (e.g. container resizes). Rendering it in a null child means only this
|
|
||||||
* component churns; the parent `GridLayout` re-renders solely when
|
|
||||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
|
||||||
*/
|
|
||||||
const GridLayoutObserver = ({
|
|
||||||
gridEl,
|
|
||||||
trackCount,
|
|
||||||
onMaxTilesChange,
|
|
||||||
}: GridLayoutObserverProps) => {
|
|
||||||
const { layout } = useGridLayout(gridEl, trackCount)
|
|
||||||
useEffect(() => {
|
|
||||||
onMaxTilesChange(layout.maxTiles)
|
|
||||||
}, [onMaxTilesChange, layout.maxTiles])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -67,15 +37,14 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = useRef<HTMLDivElement>(null)
|
const gridEl = React.createRef<HTMLDivElement>()
|
||||||
const [maxTiles, setMaxTiles] = useState(1)
|
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const pagination = usePagination(maxTiles, tracks)
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -88,13 +57,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
<GridLayoutObserver
|
|
||||||
gridEl={gridEl}
|
|
||||||
trackCount={tracks.length}
|
|
||||||
onMaxTilesChange={setMaxTiles}
|
|
||||||
/>
|
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > maxTiles && (
|
{tracks.length > layout.maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
|
||||||
|
|
||||||
export const PinAnnouncer = () => {
|
|
||||||
usePinAnnouncements()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
|
||||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
|
||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
|
||||||
log,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export const StageLayout = () => {
|
|
||||||
const lastAutoFocusedScreenShareTrack =
|
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
|
||||||
|
|
||||||
const tracks = useTracks(
|
|
||||||
[
|
|
||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
|
||||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
|
||||||
],
|
|
||||||
{ updateOnlyOn: [], onlySubscribed: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const screenShareTracks = tracks
|
|
||||||
.filter(isTrackReference)
|
|
||||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
|
|
||||||
const carouselTracks = tracks.filter(
|
|
||||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
|
||||||
)
|
|
||||||
|
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
|
||||||
useEffect(() => {
|
|
||||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
|
||||||
if (
|
|
||||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
|
||||||
lastAutoFocusedScreenShareTrack.current === null
|
|
||||||
) {
|
|
||||||
log.debug('Auto set screen share focus:', {
|
|
||||||
newScreenShareTrack: screenShareTracks[0],
|
|
||||||
})
|
|
||||||
setPinnedTrack(screenShareTracks[0])
|
|
||||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
|
||||||
} else if (
|
|
||||||
lastAutoFocusedScreenShareTrack.current &&
|
|
||||||
!screenShareTracks.some(
|
|
||||||
(track) =>
|
|
||||||
track.publication.trackSid ===
|
|
||||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
log.debug('Auto clearing screen share focus.')
|
|
||||||
clearPinnedTrack()
|
|
||||||
lastAutoFocusedScreenShareTrack.current = null
|
|
||||||
}
|
|
||||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
|
||||||
const updatedFocusTrack = tracks.find(
|
|
||||||
(tr) =>
|
|
||||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
|
||||||
tr.source === pinnedTrackRef.source
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
updatedFocusTrack !== pinnedTrackRef &&
|
|
||||||
isTrackReference(updatedFocusTrack)
|
|
||||||
) {
|
|
||||||
setPinnedTrack(updatedFocusTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
screenShareTracks
|
|
||||||
.map(
|
|
||||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
|
||||||
)
|
|
||||||
.join(),
|
|
||||||
pinnedTrackRef?.publication?.trackSid,
|
|
||||||
tracks,
|
|
||||||
])
|
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!pinnedTrackRef ? (
|
|
||||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
|
||||||
<ParticipantTile />
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParticipantTile />
|
|
||||||
</CarouselLayout>
|
|
||||||
<FocusLayout trackRef={pinnedTrackRef} />
|
|
||||||
</FocusLayoutContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
export const usePinAnnouncements = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const getAnnouncementName = useCallback(
|
|
||||||
(participant?: Participant | null) => {
|
|
||||||
if (!participant) return tRooms('participants.unknown')
|
|
||||||
return participant.isLocal
|
|
||||||
? tRooms('participants.you')
|
|
||||||
: getParticipantName(participant)
|
|
||||||
},
|
|
||||||
[tRooms]
|
|
||||||
)
|
|
||||||
|
|
||||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 1. unpin
|
|
||||||
if (!pinnedIdentity) {
|
|
||||||
const lastIdentity = lastPinnedIdentityRef.current
|
|
||||||
if (!lastIdentity) return
|
|
||||||
|
|
||||||
const lastParticipant =
|
|
||||||
room.localParticipant.identity === lastIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(lastIdentity)
|
|
||||||
|
|
||||||
announce(
|
|
||||||
lastParticipant?.isLocal
|
|
||||||
? t('self.unpin')
|
|
||||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
|
||||||
)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. same pin → do nothing
|
|
||||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
|
||||||
|
|
||||||
// 3. new pin
|
|
||||||
const participant =
|
|
||||||
room.localParticipant.identity === pinnedIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(pinnedIdentity)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = pinnedIdentity
|
|
||||||
|
|
||||||
announce(
|
|
||||||
participant?.isLocal
|
|
||||||
? t('self.pin')
|
|
||||||
: t('pin', { name: getAnnouncementName(participant) })
|
|
||||||
)
|
|
||||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { useEffect, useReducer, useRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tripwire for speaker promotion.
|
|
||||||
*
|
|
||||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
|
||||||
* re-render of the host component only when an active speaker has none of
|
|
||||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
|
||||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
|
||||||
* state and performs the actual swap.
|
|
||||||
*
|
|
||||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
|
||||||
*
|
|
||||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
|
||||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
|
||||||
*/
|
|
||||||
export function useSpeakerPromotionTrigger(
|
|
||||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
|
||||||
) {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
|
||||||
|
|
||||||
// Refs so the listener reads current values without re-subscribing
|
|
||||||
// and without itself being a render dependency.
|
|
||||||
const tilesRef = useRef(sortedTiles)
|
|
||||||
tilesRef.current = sortedTiles
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
|
||||||
const tiles = tilesRef.current
|
|
||||||
const hiddenSpeakerExists = speakers.some(
|
|
||||||
(speaker) =>
|
|
||||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
|
||||||
)
|
|
||||||
if (hiddenSpeakerExists) {
|
|
||||||
forceRender()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
state,
|
state,
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
@@ -37,13 +38,18 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
source: Source.Camera,
|
source: Source.Camera,
|
||||||
}
|
}
|
||||||
|
const pinParticipant = () => {
|
||||||
|
layoutContext?.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference,
|
||||||
|
})
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
setPinnedTrack(trackReference)
|
pinParticipant()
|
||||||
closeButtonProps.onPress?.(e)
|
closeButtonProps.onPress?.(e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
ConnectionQualityIndicator,
|
|
||||||
LockLockedIcon,
|
|
||||||
ScreenShareIcon,
|
|
||||||
useIsEncrypted,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
|
||||||
import { ParticipantName } from './ParticipantName'
|
|
||||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
|
||||||
|
|
||||||
export const ParticipantMetadata = ({
|
|
||||||
displayedName,
|
|
||||||
participant,
|
|
||||||
isScreenShare,
|
|
||||||
}: {
|
|
||||||
displayedName: string
|
|
||||||
participant: Participant
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const isEncrypted = useIsEncrypted(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lk-participant-metadata">
|
|
||||||
<HStack gap={0.25}>
|
|
||||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
|
||||||
<RaisedHandMetadataWrapper
|
|
||||||
participant={participant}
|
|
||||||
enabled={!isScreenShare}
|
|
||||||
>
|
|
||||||
{isScreenShare && (
|
|
||||||
<ScreenShareIcon
|
|
||||||
style={{
|
|
||||||
maxWidth: '20px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
displayedName={displayedName}
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</RaisedHandMetadataWrapper>
|
|
||||||
</HStack>
|
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { CSSProperties } from 'react'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const participantNameStyles: CSSProperties = {
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantNameScreenShareStyles: CSSProperties = {
|
|
||||||
...participantNameStyles,
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantName = React.memo(
|
|
||||||
({
|
|
||||||
displayedName,
|
|
||||||
isScreenShare = false,
|
|
||||||
}: {
|
|
||||||
displayedName?: string
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
|
||||||
if (isScreenShare) {
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
|
||||||
{displayedName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantName.displayName = 'ParticipantName'
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
containerType: 'size',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledAvatarWrapper = styled('div', {
|
|
||||||
base: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
aspectRatio: '1 / 1',
|
|
||||||
width: 'min(90cqmin, 160px)',
|
|
||||||
fontSize: 'min(27cqmin, 48px)',
|
|
||||||
'[data-lk-speaking="true"] &': {
|
|
||||||
animation: 'pulse 1s infinite',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
|
||||||
color: string
|
|
||||||
displayedNamed: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantPlaceholder = React.memo(
|
|
||||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
|
||||||
const backgroundGradient = useMemo(
|
|
||||||
() => getParticipantBackgroundGradient(color),
|
|
||||||
[color]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<StyledParticipantPlaceHolder
|
|
||||||
style={{
|
|
||||||
backgroundColor: color,
|
|
||||||
backgroundImage: backgroundGradient,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StyledAvatarWrapper>
|
|
||||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
|
||||||
</StyledAvatarWrapper>
|
|
||||||
</StyledParticipantPlaceHolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
useRaisedHand,
|
|
||||||
useRaisedHandPosition,
|
|
||||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { RiHand } from '@remixicon/react'
|
|
||||||
|
|
||||||
const PositionInQueue = React.memo(
|
|
||||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
|
||||||
if (!positionInQueue) return
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
|
||||||
>
|
|
||||||
<span>{positionInQueue}</span>
|
|
||||||
<RiHand
|
|
||||||
color="black"
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
marginLeft: '0.1rem',
|
|
||||||
minWidth: '16px',
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationName: 'wave_hand',
|
|
||||||
animationIterationCount: '2',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
PositionInQueue.displayName = 'PositionInQueue'
|
|
||||||
|
|
||||||
const RaisedHandActiveItem = ({
|
|
||||||
participant,
|
|
||||||
targetRef,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
targetRef: RefObject<HTMLElement | null>
|
|
||||||
}) => {
|
|
||||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
|
||||||
participant,
|
|
||||||
})
|
|
||||||
useEffect(() => {
|
|
||||||
const el = targetRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
|
||||||
el.style.color = 'black'
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
el.style.backgroundColor = ''
|
|
||||||
el.style.color = ''
|
|
||||||
}
|
|
||||||
}, [targetRef, firstInQueue])
|
|
||||||
|
|
||||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RaisedHandMetadataWrapper = ({
|
|
||||||
participant,
|
|
||||||
enabled = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
enabled?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) => {
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const { isHandRaised } = useRaisedHand({ participant })
|
|
||||||
const showHand = isHandRaised && enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="lk-participant-metadata-item"
|
|
||||||
style={{
|
|
||||||
padding: '0.1rem 0.25rem',
|
|
||||||
transition: 'background 200ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showHand && (
|
|
||||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const EffectsButton = React.memo(() => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
tooltip={t('effects')}
|
|
||||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
EffectsButton.displayName = 'EffectsButton'
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const FocusButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
|
||||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
|
||||||
>
|
|
||||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
|
||||||
participant: participant,
|
|
||||||
source: Track.Source.Microphone,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
isDisabled={isMuted}
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
onPress={() => setIsAlertOpen(true)}
|
|
||||||
tooltip={t('muteParticipant', { name })}
|
|
||||||
>
|
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|
||||||
import { FocusButton } from './FocusButton'
|
|
||||||
import { EffectsButton } from './EffectsButton'
|
|
||||||
import { MuteButton } from './MuteButton'
|
|
||||||
import { ZoomButton } from './ZoomButton'
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
|
||||||
children: ReactNode
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|
||||||
const [active, setActive] = useState(false)
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
|
||||||
clearIdleTimer()
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setActive(false)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
|
||||||
setActive(true)
|
|
||||||
armIdleTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearIdleTimer, [])
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '0',
|
|
||||||
top: '0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
})}
|
|
||||||
data-visible={isVisible || undefined}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
onMouseEnter={handleActivity}
|
|
||||||
onMouseMove={handleActivity}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isVisible && children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
|
||||||
trackRef,
|
|
||||||
hasKeyboardFocus,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}) => {
|
|
||||||
const participant = trackRef.participant
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
|
||||||
const isLocal = participant.isLocal
|
|
||||||
const canMute = useCanMute(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
opacity: 0.6,
|
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
|
||||||
_hover: {
|
|
||||||
opacity: 0.95,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HStack gap={0.5} padding={0.5}>
|
|
||||||
<FocusButton trackRef={trackRef} />
|
|
||||||
{!isScreenShare ? (
|
|
||||||
<>
|
|
||||||
{isLocal ? (
|
|
||||||
<EffectsButton />
|
|
||||||
) : (
|
|
||||||
canMute && <MuteButton participant={participant} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</FadeOverlay>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiFullscreenLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { ReactNode, useId, useState } from 'react'
|
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
|
||||||
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'greyscale.250',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 .625rem 0.9375rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Header = styled('button', {
|
|
||||||
base: {
|
|
||||||
minHeight: '2.5rem',
|
|
||||||
paddingX: '1.25rem',
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
gap: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
fontSize: '1rem',
|
|
||||||
transition: 'background 200ms',
|
|
||||||
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
|
||||||
_hover: { backgroundColor: 'greyscale.50' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { borderRadius: '7px' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Chevron = styled(RiArrowUpSLine, {
|
|
||||||
base: { transition: 'transform 200ms', flexShrink: 0 },
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { transform: 'rotate(180deg)' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const List = styled(VStack, {
|
|
||||||
base: {
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
alignItems: 'start',
|
|
||||||
minHeight: 0,
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
paddingX: '1rem',
|
|
||||||
gap: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ParticipantsCollapsibleSectionProps = {
|
|
||||||
heading: string
|
|
||||||
count: number
|
|
||||||
action?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsCollapsibleSection = ({
|
|
||||||
heading,
|
|
||||||
count,
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
}: ParticipantsCollapsibleSectionProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(true)
|
|
||||||
const listId = useId()
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header
|
|
||||||
type="button"
|
|
||||||
isOpen={isOpen}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls={listId}
|
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
<HStack justify="space-between" width="100%">
|
|
||||||
<span>{heading}</span>
|
|
||||||
<span>{count}</span>
|
|
||||||
</HStack>
|
|
||||||
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
|
||||||
</Header>
|
|
||||||
{isOpen && (
|
|
||||||
<List id={listId} role="list">
|
|
||||||
{action}
|
|
||||||
{children}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useRemoteParticipants } from '@livekit/components-react'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiInfinityLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
const badgeStyles = css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-.25rem',
|
|
||||||
right: '-.25rem',
|
|
||||||
width: '1.25rem',
|
|
||||||
height: '1.25rem',
|
|
||||||
backgroundColor: 'gray',
|
|
||||||
borderRadius: '50%',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Isolated so participant join/leave events only re-render the badge,
|
|
||||||
* not the button, tooltip or shortcut registration.
|
|
||||||
*/
|
|
||||||
export const ParticipantsCount = React.memo(
|
|
||||||
({ describedById }: { describedById: string }) => {
|
|
||||||
const { t } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'controls.participants',
|
|
||||||
})
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [],
|
|
||||||
})
|
|
||||||
const count = (remoteParticipants?.length ?? 0) + 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span id={describedById} className={srOnly}>
|
|
||||||
{t('count', { count })}
|
|
||||||
</span>
|
|
||||||
<div className={badgeStyles} aria-hidden>
|
|
||||||
{count < 100 ? count : <RiInfinityLine size={10} />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ParticipantsCount.displayName = 'ParticipantsCount'
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import {
|
|
||||||
useRemoteParticipants,
|
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import { Div, H } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantRow } from './ParticipantRow'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
|
||||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
|
||||||
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
|
||||||
import { RaisedHandRow } from './RaisedHandRow'
|
|
||||||
|
|
||||||
const JoinedParticipantsSections = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
|
||||||
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
|
||||||
// are room-wide and fire for the local participant too, so this hook re-emits
|
|
||||||
// (and we re-render) on local changes, despite its name.
|
|
||||||
//
|
|
||||||
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
|
||||||
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
|
||||||
// local events waking this component up, raising your own hand would not
|
|
||||||
// update the UI. Do not "optimise" these away.
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [
|
|
||||||
RoomEvent.ParticipantNameChanged,
|
|
||||||
RoomEvent.ParticipantAttributesChanged,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const allParticipants = [
|
|
||||||
room.localParticipant,
|
|
||||||
...[...remoteParticipants].sort((a, b) =>
|
|
||||||
(a.name || a.identity).localeCompare(b.name || b.identity)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const participantsWithRaisedHands = allParticipants
|
|
||||||
.filter((p) => !!p.attributes.handRaisedAt)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
|
||||||
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
|
||||||
return (
|
|
||||||
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
|
||||||
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{participantsWithRaisedHands.length > 0 && (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('raisedHands')}
|
|
||||||
count={participantsWithRaisedHands.length}
|
|
||||||
action={
|
|
||||||
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{participantsWithRaisedHands.map((p) => (
|
|
||||||
<RaisedHandRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)}
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('contributors')}
|
|
||||||
count={allParticipants.length}
|
|
||||||
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
|
||||||
>
|
|
||||||
{allParticipants.map((p) => (
|
|
||||||
<ParticipantRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsList = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Div overflowY="scroll">
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
className={css({
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'greyscale.600',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
marginBottom: '0.83em',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('subheading').toUpperCase()}
|
|
||||||
</H>
|
|
||||||
<WaitingParticipantsSection />
|
|
||||||
<JoinedParticipantsSections />
|
|
||||||
</Div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { RiPushpin2Fill } from '@remixicon/react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
|
|
||||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
|
||||||
const cameraTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const screenShareTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.ScreenShare),
|
|
||||||
source: Source.ScreenShare,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus =
|
|
||||||
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
|
||||||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
|
||||||
|
|
||||||
if (!inFocus) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
height: '18px',
|
|
||||||
width: '18px',
|
|
||||||
borderRadius: '100%',
|
|
||||||
background: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-2px',
|
|
||||||
right: '-4px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiPushpin2Fill size={14} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
|
||||||
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
|
||||||
|
|
||||||
export const WaitingParticipantsSection = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
|
||||||
|
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
|
||||||
useWaitingParticipants()
|
|
||||||
|
|
||||||
if (waitingParticipants?.length == 0) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('title')}
|
|
||||||
count={waitingParticipants.length}
|
|
||||||
>
|
|
||||||
{waitingParticipants.map((p) => (
|
|
||||||
<WaitingParticipantRow
|
|
||||||
key={p.id}
|
|
||||||
participant={p}
|
|
||||||
onAction={handleParticipantEntry}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipScreenShareLayoutProps = {
|
type PipScreenShareLayoutProps = {
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useTrackToggle } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
|
|
||||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'pictureInPicture',
|
keyPrefix: 'pictureInPicture',
|
||||||
})
|
})
|
||||||
const { enabled: isScreenSharing } = useTrackToggle({
|
const { localParticipant } = useLocalParticipant()
|
||||||
source: Track.Source.ScreenShare,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!isScreenSharing ? { inert: '' } : {})}
|
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
|
|||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useSettingsDialog,
|
||||||
SettingsDialogExtendedKey,
|
SettingsDialogExtendedKey,
|
||||||
useTranscriptionLanguage,
|
useTranscriptionLanguage,
|
||||||
} from '@/features/settings'
|
} from '@/features/settings'
|
||||||
@@ -34,7 +35,6 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -48,6 +48,8 @@ export const TranscriptSidePanel = () => {
|
|||||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||||
useTranscriptionLanguage()
|
useTranscriptionLanguage()
|
||||||
|
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -68,36 +67,35 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = React.memo(
|
export const ActiveSpeaker = ({
|
||||||
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
isSpeaking,
|
||||||
return (
|
pushToTalk,
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
}: ActiveSpeakerProps) => {
|
||||||
<StyledChild
|
return (
|
||||||
pushToTalk={pushToTalk}
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '300ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '300ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
style={{
|
pushToTalk={pushToTalk}
|
||||||
animationDelay: '100ms',
|
active={isSpeaking}
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '100ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '500ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '500ms',
|
||||||
</StyledContainer>
|
}}
|
||||||
)
|
/>
|
||||||
}
|
</StyledContainer>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
kind: MediaDeviceKind | null
|
kind: MediaDeviceKind | null
|
||||||
@@ -302,7 +303,13 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{showInviteDialog && !isMobile && (
|
||||||
|
<InviteDialog
|
||||||
|
isOpen={showInviteDialog}
|
||||||
|
onOpenChange={setShowInviteDialog}
|
||||||
|
onClose={() => setShowInviteDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
{...mediaDeviceError}
|
{...mediaDeviceError}
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, P, Bold } from '@/primitives'
|
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -39,9 +39,7 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -60,161 +58,163 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
if (!showInviteDialog) return null
|
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog>
|
<StyledRACDialog {...props}>
|
||||||
<VStack
|
{({ close }) => (
|
||||||
alignItems="left"
|
<VStack
|
||||||
justify="start"
|
alignItems="left"
|
||||||
gap={0}
|
justify="start"
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
gap={0}
|
||||||
>
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
>
|
||||||
{t('heading')}
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
</Heading>
|
{t('heading')}
|
||||||
<Div position="absolute" top="5" right="5">
|
</Heading>
|
||||||
<Button
|
<Div position="absolute" top="5" right="5">
|
||||||
invisible
|
<Button
|
||||||
variant="tertiaryText"
|
invisible
|
||||||
size="xs"
|
variant="tertiaryText"
|
||||||
onPress={() => {
|
size="xs"
|
||||||
setShowInviteDialog(false)
|
onPress={() => {
|
||||||
}}
|
props.onClose?.()
|
||||||
aria-label={t('closeDialog')}
|
close()
|
||||||
>
|
}}
|
||||||
<RiCloseLine />
|
aria-label={t('closeDialog')}
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
<P>{t('description')}</P>
|
|
||||||
{isTelephonyReadyForUse ? (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<RiCloseLine />
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
</Button>
|
||||||
</Text>
|
</Div>
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
<P>{t('description')}</P>
|
||||||
<Button
|
{isTelephonyReadyForUse ? (
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
|
||||||
square
|
|
||||||
size={'sm'}
|
|
||||||
onPress={copyRoomUrlToClipboard}
|
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
>
|
|
||||||
{isRoomUrlCopied ? (
|
|
||||||
<RiCheckLine aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<div
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
className={css({
|
||||||
{telephony?.internationalPhoneNumber}
|
display: 'flex',
|
||||||
</Text>
|
alignItems: 'center',
|
||||||
<Text as="p" wrap="pretty">
|
justifyContent: 'space-between',
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
})}
|
||||||
{formatPinCode(roomData?.pin_code)}
|
>
|
||||||
</Text>
|
<Text as="p" wrap="pretty">
|
||||||
</div>
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? (
|
||||||
|
<RiCheckLine aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
size="sm"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
{t('copyUrl')}
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
) : (
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
<Button
|
<HStack>
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
<div
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copyUrl')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
|
||||||
<HStack>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primary.200',
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiSpam2Fill
|
|
||||||
size={22}
|
|
||||||
className={css({
|
className={css({
|
||||||
fill: 'primary.500',
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
</div>
|
<RiSpam2Fill
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
size={22}
|
||||||
{t('permissions')}
|
className={css({
|
||||||
</Text>
|
fill: 'primary.500',
|
||||||
</HStack>
|
})}
|
||||||
)}
|
/>
|
||||||
</VStack>
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { ParticipantTile } from './ParticipantTile'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
+5
-6
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
|||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import { useSize } from '../hooks/useResizeObserver'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { LocalParticipant } from 'livekit-client'
|
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -17,6 +17,7 @@ export const FullScreenShareWarning = ({
|
|||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
@@ -56,10 +57,8 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
const participant = trackReference.participant
|
if (!localParticipant.isScreenShareEnabled) return
|
||||||
if (!(participant instanceof LocalParticipant)) return
|
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||||
if (!participant.isScreenShareEnabled) return
|
|
||||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
@@ -94,6 +94,7 @@ const Description = () => {
|
|||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
return (
|
return (
|
||||||
<P>
|
<P>
|
||||||
{t('settingsPrefix')}{' '}
|
{t('settingsPrefix')}{' '}
|
||||||
|
|||||||
+7
-9
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,17 +25,15 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
hint?: string
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||||
* typically when an element receives keyboard focus.
|
* typically when an element receives keyboard focus.
|
||||||
*/
|
*/
|
||||||
export const KeyboardShortcutHint = React.memo(
|
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||||
({ hint }: KeyboardShortcutHintProps) => {
|
children,
|
||||||
return <Hint>{hint}</Hint>
|
}) => {
|
||||||
}
|
return <Hint>{children}</Hint>
|
||||||
)
|
}
|
||||||
|
|
||||||
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { PinMenuItem } from './items/PinMenuItem'
|
import { PinMenuItem } from './PinMenuItem'
|
||||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
import { RemoveMenuItem } from './RemoveMenuItem'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
+4
-17
@@ -1,28 +1,15 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
const trackRef = useMemo(() => {
|
const { toggle, inFocus } = useFocusToggleParticipant(participant)
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -30,7 +17,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
onAction={toggle}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useParticipantInfo } from '@livekit/components-react'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
const participantNameStyles: CSSProperties = {
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantNameScreenShareStyles: CSSProperties = {
|
||||||
|
...participantNameStyles,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantName = ({
|
||||||
|
participant,
|
||||||
|
isScreenShare = false,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||||
|
|
||||||
|
const { identity, name } = useParticipantInfo({ participant })
|
||||||
|
const displayedName = name != '' ? name : identity
|
||||||
|
|
||||||
|
if (isScreenShare) {
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||||
|
{t('screenShare', { name: displayedName })}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||||
|
{displayedName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { useIsSpeaking } from '@livekit/components-react'
|
||||||
|
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ParticipantPlaceholderProps = {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantPlaceholder = ({
|
||||||
|
participant,
|
||||||
|
}: ParticipantPlaceholderProps) => {
|
||||||
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
|
const participantColor = getParticipantColor(participant)
|
||||||
|
const backgroundGradient = useMemo(
|
||||||
|
() => getParticipantBackgroundGradient(participantColor),
|
||||||
|
[participantColor]
|
||||||
|
)
|
||||||
|
|
||||||
|
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||||
|
const { width, height } = useSize(placeholderEl)
|
||||||
|
|
||||||
|
const minDimension = Math.min(width, height)
|
||||||
|
const avatarSize = useMemo(
|
||||||
|
() => Math.min(Math.round(minDimension * 0.9), 160),
|
||||||
|
[minDimension]
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledParticipantPlaceHolder
|
||||||
|
ref={placeholderEl}
|
||||||
|
style={{
|
||||||
|
backgroundColor: participantColor,
|
||||||
|
backgroundImage: backgroundGradient,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
width: '80%',
|
||||||
|
maxWidth: `${avatarSize}px`,
|
||||||
|
fontSize: `${initialSize}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
||||||
|
<Avatar
|
||||||
|
name={participant.name}
|
||||||
|
bgColor={participantColor}
|
||||||
|
context="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StyledParticipantPlaceHolder>
|
||||||
|
)
|
||||||
|
}
|
||||||
+103
-31
@@ -1,32 +1,39 @@
|
|||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
LockLockedIcon,
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
|
ScreenShareIcon,
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
|
useIsEncrypted,
|
||||||
|
useMaybeLayoutContext,
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
useParticipantInfo,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
|
isTrackReferencePinned,
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
|
||||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
@@ -66,12 +73,16 @@ export const ParticipantTile: (
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const trackReference = useEnsureTrackRef(trackRef)
|
const trackReference = useEnsureTrackRef(trackRef)
|
||||||
|
|
||||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||||
htmlProps,
|
htmlProps,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
|
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
|
|
||||||
const handleSubscribe = React.useCallback(
|
const handleSubscribe = React.useCallback(
|
||||||
@@ -79,25 +90,28 @@ export const ParticipantTile: (
|
|||||||
if (
|
if (
|
||||||
trackReference.source &&
|
trackReference.source &&
|
||||||
!subscribed &&
|
!subscribed &&
|
||||||
layoutStore.pinnedTrackRef &&
|
layoutContext &&
|
||||||
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
layoutContext.pin.dispatch &&
|
||||||
|
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||||
) {
|
) {
|
||||||
clearPinnedTrack()
|
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackReference]
|
[trackReference, layoutContext]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { isHandRaised } = useRaisedHand({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantName = getParticipantName(trackReference.participant)
|
||||||
|
|
||||||
const { identity, name } = useParticipantInfo({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
const participantName = name || identity || 'Unknown'
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
@@ -124,9 +138,7 @@ export const ParticipantTile: (
|
|||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
|
||||||
)}
|
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{isTrackReference(trackReference) &&
|
||||||
@@ -148,16 +160,76 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
participant={trackReference.participant}
|
||||||
displayedNamed={participantName}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<ParticipantMetadata
|
<div className="lk-participant-metadata">
|
||||||
displayedName={participantName}
|
<HStack gap={0.25}>
|
||||||
isScreenShare={isScreenShare}
|
{!isScreenShare && (
|
||||||
participant={trackReference.participant}
|
<MutedMicIndicator
|
||||||
/>
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="lk-participant-metadata-item"
|
||||||
|
style={{
|
||||||
|
padding: '0.1rem 0.25rem',
|
||||||
|
backgroundColor:
|
||||||
|
isHandRaised && !isScreenShare
|
||||||
|
? firstInQueue
|
||||||
|
? '#fde047'
|
||||||
|
: 'white'
|
||||||
|
: undefined,
|
||||||
|
color:
|
||||||
|
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||||
|
transition: 'background 200ms ease, color 400ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isHandRaised && !isScreenShare && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{positionInQueue}</span>
|
||||||
|
<RiHand
|
||||||
|
color="black"
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
marginLeft: '0.1rem',
|
||||||
|
minWidth: '16px',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isScreenShare && (
|
||||||
|
<ScreenShareIcon
|
||||||
|
style={{
|
||||||
|
maxWidth: '20px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEncrypted && !isScreenShare && (
|
||||||
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -169,13 +241,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint>
|
||||||
hint={t('toolbarHint', {
|
{t('toolbarHint', {
|
||||||
shortcut: formatShortcutLabel(
|
shortcut: formatShortcutLabel(
|
||||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
/>
|
</KeyboardShortcutHint>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import {
|
||||||
|
RiFullscreenLine,
|
||||||
|
RiImageCircleAiFill,
|
||||||
|
RiMicLine,
|
||||||
|
RiMicOffLine,
|
||||||
|
RiPushpin2Line,
|
||||||
|
RiUnpinLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import {
|
||||||
|
useFocusToggle,
|
||||||
|
useTrackMutedIndicator,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { useFullScreen } from '../hooks/useFullScreen'
|
||||||
|
import { type Participant, Track } from 'livekit-client'
|
||||||
|
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
|
|
||||||
|
const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FocusButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { mergedProps, inFocus } = useFocusToggle({
|
||||||
|
trackRef,
|
||||||
|
props: {},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
||||||
|
>
|
||||||
|
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EffectsButton = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
tooltip={t('effects')}
|
||||||
|
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||||
|
>
|
||||||
|
<RiImageCircleAiFill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
participant: participant,
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
isDisabled={isMuted}
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
onPress={() => setIsAlertOpen(true)}
|
||||||
|
tooltip={t('muteParticipant', { name })}
|
||||||
|
>
|
||||||
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
|
export const ParticipantTileFocus = ({
|
||||||
|
trackRef,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}) => {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const [opacity, setOpacity] = useState(0)
|
||||||
|
|
||||||
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
// Wait for next frame to ensure element is mounted
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setOpacity(0.6)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setOpacity(0)
|
||||||
|
}
|
||||||
|
}, [isVisible])
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsIdleRef(true)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
setIsIdleRef(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = trackRef.participant
|
||||||
|
|
||||||
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
|
const isLocal = trackRef.participant.isLocal
|
||||||
|
|
||||||
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
})}
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{isVisible && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
transition: 'opacity 200ms linear',
|
||||||
|
zIndex: 1,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
_hover: {
|
||||||
|
opacity: '0.95 !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
style={{ opacity }}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={0.5}
|
||||||
|
className={css({
|
||||||
|
padding: '0.5rem',
|
||||||
|
_hover: {
|
||||||
|
opacity: '1 !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FocusButton trackRef={trackRef} />
|
||||||
|
{!isScreenShare ? (
|
||||||
|
<>
|
||||||
|
{participant.isLocal ? (
|
||||||
|
<EffectsButton />
|
||||||
|
) : (
|
||||||
|
canMute && <MuteButton participant={participant} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
|
||||||
|
|
||||||
export const RoomMetadataSynchronizer = () => {
|
|
||||||
useSyncLiveKitMetadata()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,10 @@ import { text } from '@/primitives/Text'
|
|||||||
import { Button, Div } from '@/primitives'
|
import { Button, Div } from '@/primitives'
|
||||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import React, { ReactNode, useCallback, useRef } from 'react'
|
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||||
import { Chat } from '@/features/chat/components/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
@@ -218,7 +218,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isEffectsOpen}>
|
<Panel isOpen={isEffectsOpen}>
|
||||||
<Effects />
|
<Effects />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isChatOpen}>
|
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ReceivedChatMessage } from '@livekit/components-core'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import type { MessageFormatter } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||||
|
entry: ReceivedChatMessage
|
||||||
|
hideMetadata?: boolean
|
||||||
|
messageFormatter?: MessageFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatEntry: (
|
||||||
|
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
||||||
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
ChatEntryProps
|
||||||
|
>(function ChatEntry(
|
||||||
|
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const formattedMessage = React.useMemo(() => {
|
||||||
|
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||||
|
}, [entry.message, messageFormatter])
|
||||||
|
const time = new Date(entry.timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
})}
|
||||||
|
ref={ref}
|
||||||
|
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||||
|
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{!hideMetadata && (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text bold={true} variant="sm">
|
||||||
|
{entry.from?.name ?? entry.from?.identity}
|
||||||
|
</Text>
|
||||||
|
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
||||||
|
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
'& .lk-chat-link': {
|
||||||
|
color: 'blue',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedMessage}
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Button, TextArea } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
import { useState, useEffect, RefObject } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
const MAX_ROWS = 6
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
inputRef: RefObject<HTMLTextAreaElement>
|
||||||
|
onSubmit: (text: string) => void
|
||||||
|
isSending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInput = ({
|
||||||
|
inputRef,
|
||||||
|
onSubmit,
|
||||||
|
isSending,
|
||||||
|
}: ChatInputProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [rows, setRows] = useState(1)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit(text)
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = !text.trim() || isSending
|
||||||
|
|
||||||
|
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDisabled) handleSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resize = () => {
|
||||||
|
if (!inputRef.current) return
|
||||||
|
|
||||||
|
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
||||||
|
const previousRows = inputRef.current.rows
|
||||||
|
inputRef.current.rows = 1
|
||||||
|
|
||||||
|
const currentRows = Math.floor(
|
||||||
|
inputRef.current.scrollHeight / textAreaLineHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentRows === previousRows) {
|
||||||
|
inputRef.current.rows = currentRows
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRows >= MAX_ROWS) {
|
||||||
|
inputRef.current.rows = MAX_ROWS
|
||||||
|
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRows < MAX_ROWS) {
|
||||||
|
inputRef.current.style.overflowY = 'hidden'
|
||||||
|
} else {
|
||||||
|
inputRef.current.style.overflowY = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize()
|
||||||
|
}, [text, inputRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
className={css({
|
||||||
|
margin: '0.75rem 0 1.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
submitOnEnter(e)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
|
placeholder={t('textArea.placeholder')}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value)
|
||||||
|
}}
|
||||||
|
rows={rows || 1}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: `34px`,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
padding: '7px 10px',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
}}
|
||||||
|
placeholderStyle={'strong'}
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={2000}
|
||||||
|
aria-label={t('textArea.label')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={t('button.label')}
|
||||||
|
>
|
||||||
|
<RiSendPlane2Fill />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-1
@@ -2,7 +2,7 @@ import { Button } from '@/primitives'
|
|||||||
import { RiSettings3Line } from '@remixicon/react'
|
import { RiSettings3Line } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
export const SettingsButton = ({
|
export const SettingsButton = ({
|
||||||
settingTab,
|
settingTab,
|
||||||
@@ -12,6 +12,7 @@ export const SettingsButton = ({
|
|||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import useLongPress from '@/features/shortcuts/useLongPress'
|
|||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
import {
|
import {
|
||||||
useIsSpeaking,
|
useIsSpeaking,
|
||||||
|
useLocalParticipant,
|
||||||
useMaybeRoomContext,
|
useMaybeRoomContext,
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
@@ -169,7 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActiveSpeakerWrapper = () => {
|
const ActiveSpeakerWrapper = () => {
|
||||||
const room = useRoomContext()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
const isSpeaking = useIsSpeaking(localParticipant)
|
||||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||||
}
|
}
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
import { RiBookOpenLine } from '@remixicon/react'
|
|
||||||
import { MenuItem } from 'react-aria-components'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
|
|
||||||
export const DocumentationMenuItem = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
|
||||||
const { data } = useConfig()
|
|
||||||
|
|
||||||
if (!data?.documentation_url) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
href={data.documentation_url}
|
|
||||||
target="_blank"
|
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
|
||||||
>
|
|
||||||
<RiBookOpenLine size={20} />
|
|
||||||
{t('documentation')}
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-2
@@ -5,7 +5,6 @@ import { SettingsMenuItem } from './SettingsMenuItem'
|
|||||||
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
||||||
import { EffectsMenuItem } from './EffectsMenuItem'
|
import { EffectsMenuItem } from './EffectsMenuItem'
|
||||||
import { SupportMenuItem } from './SupportMenuItem'
|
import { SupportMenuItem } from './SupportMenuItem'
|
||||||
import { DocumentationMenuItem } from './DocumentationMenuItem'
|
|
||||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||||
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
||||||
@@ -29,7 +28,6 @@ export const OptionsMenuItems = () => {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<MenuSection>
|
<MenuSection>
|
||||||
<SupportMenuItem />
|
<SupportMenuItem />
|
||||||
<DocumentationMenuItem />
|
|
||||||
<FeedbackMenuItem />
|
<FeedbackMenuItem />
|
||||||
<SettingsMenuItem />
|
<SettingsMenuItem />
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
|
|||||||
+2
-1
@@ -2,10 +2,11 @@ import { RiSettings3Line } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
export const SettingsMenuItem = () => {
|
export const SettingsMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
+21
-31
@@ -4,47 +4,27 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { Text } from '@/primitives/Text'
|
import { Text } from '@/primitives/Text'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
import { useLowerHandParticipant } from '@/features/rooms/api/lowerHandParticipant'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
|
||||||
|
|
||||||
const ActionButton = ({
|
|
||||||
participant,
|
|
||||||
name,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
name: string
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
variant="greyscale"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => lowerHandParticipant(participant)}
|
|
||||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
|
||||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
|
||||||
data-attr="participants-lower-hand"
|
|
||||||
>
|
|
||||||
<RiHand />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HandRaisedListItemProps = {
|
type HandRaisedListItemProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
export const HandRaisedListItem = ({
|
||||||
|
participant,
|
||||||
|
}: HandRaisedListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -89,9 +69,19 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<AdminOrOwnerOnly>
|
{isAdminOrOwner && (
|
||||||
<ActionButton participant={participant} name={name} />
|
<Button
|
||||||
</AdminOrOwnerOnly>
|
square
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => lowerHandParticipant(participant)}
|
||||||
|
aria-label={t('participants.lowerParticipantHand', { name })}
|
||||||
|
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||||
|
data-attr="participants-lower-hand"
|
||||||
|
>
|
||||||
|
<RiHand />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+5
-14
@@ -1,22 +1,23 @@
|
|||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
|
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
|
||||||
|
|
||||||
type LowerAllHandsButtonProps = {
|
type LowerAllHandsButtonProps = {
|
||||||
participants: Array<Participant>
|
participants: Array<Participant>
|
||||||
}
|
}
|
||||||
|
|
||||||
const LowerAllHandsButtonInner = ({
|
export const LowerAllHandsButton = ({
|
||||||
participants,
|
participants,
|
||||||
}: LowerAllHandsButtonProps) => {
|
}: LowerAllHandsButtonProps) => {
|
||||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
if (!participants.length) return null
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
if (!isAdminOrOwner) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -35,13 +36,3 @@ const LowerAllHandsButtonInner = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LowerAllHandsButton = ({
|
|
||||||
participants,
|
|
||||||
}: LowerAllHandsButtonProps) => {
|
|
||||||
return (
|
|
||||||
<AdminOrOwnerOnly>
|
|
||||||
<LowerAllHandsButtonInner participants={participants} />
|
|
||||||
</AdminOrOwnerOnly>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+5
-3
@@ -16,11 +16,11 @@ import Source = Track.Source
|
|||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -93,7 +93,9 @@ type ParticipantListItemProps = {
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
export const ParticipantListItem = ({
|
||||||
|
participant,
|
||||||
|
}: ParticipantListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
return (
|
return (
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
import { ReactNode, useState } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { ToggleButton } from 'react-aria-components'
|
||||||
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const ToggleHeader = styled(ToggleButton, {
|
||||||
|
base: {
|
||||||
|
minHeight: '40px', //fixme hardcoded value
|
||||||
|
paddingRight: '.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
transition: 'background 200ms',
|
||||||
|
borderTopRadius: '7px',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 .625rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ListContainer = styled(VStack, {
|
||||||
|
base: {
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderTopColor: 'greyscale.250',
|
||||||
|
alignItems: 'start',
|
||||||
|
overflowY: 'scroll',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
paddingX: '1rem',
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ParticipantsCollapsableListProps<T> = {
|
||||||
|
heading: string
|
||||||
|
participants: Array<T>
|
||||||
|
renderParticipant: (participant: T) => JSX.Element
|
||||||
|
action?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticipantsCollapsableList<T>({
|
||||||
|
heading,
|
||||||
|
participants,
|
||||||
|
renderParticipant,
|
||||||
|
action,
|
||||||
|
}: ParticipantsCollapsableListProps<T>) {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
|
||||||
|
name: heading,
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ToggleHeader
|
||||||
|
isSelected={isOpen}
|
||||||
|
aria-label={label}
|
||||||
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
borderRadius: !isOpen ? '7px' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
justify="space-between"
|
||||||
|
className={css({
|
||||||
|
margin: '0 1.25rem',
|
||||||
|
width: '100%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</div>
|
||||||
|
<div>{participants?.length || 0}</div>
|
||||||
|
</HStack>
|
||||||
|
<RiArrowUpSLine
|
||||||
|
size={32}
|
||||||
|
style={{
|
||||||
|
transform: isOpen ? 'rotate(-180deg)' : undefined,
|
||||||
|
transition: 'transform 200ms',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ToggleHeader>
|
||||||
|
{isOpen && (
|
||||||
|
<ListContainer>
|
||||||
|
{action}
|
||||||
|
{participants.map((participant) => renderParticipant(participant))}
|
||||||
|
</ListContainer>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useParticipants } from '@livekit/components-react'
|
||||||
|
|
||||||
|
import { Div, H } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
|
||||||
|
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
|
||||||
|
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
|
||||||
|
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
|
||||||
|
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
||||||
|
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
|
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||||
|
|
||||||
|
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||||
|
export const ParticipantsList = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
|
||||||
|
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||||
|
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||||
|
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
|
||||||
|
const participants = useParticipants()
|
||||||
|
|
||||||
|
const sortedRemoteParticipants = participants
|
||||||
|
.slice(1)
|
||||||
|
.sort((participantA, participantB) => {
|
||||||
|
const nameA = participantA.name || participantA.identity
|
||||||
|
const nameB = participantB.name || participantB.identity
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedParticipants = [
|
||||||
|
participants[0], // first participant returned by the hook, is always the local one
|
||||||
|
...sortedRemoteParticipants,
|
||||||
|
]
|
||||||
|
|
||||||
|
const raisedHandParticipants = participants
|
||||||
|
.filter((participant) => !!participant.attributes.handRaisedAt)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.attributes.handRaisedAt)
|
||||||
|
const dateB = new Date(b.attributes.handRaisedAt)
|
||||||
|
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
||||||
|
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
||||||
|
return timeA - timeB
|
||||||
|
})
|
||||||
|
|
||||||
|
const { waitingParticipants, handleParticipantEntry } =
|
||||||
|
useWaitingParticipants()
|
||||||
|
|
||||||
|
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||||
|
return (
|
||||||
|
<Div overflowY="scroll">
|
||||||
|
<H
|
||||||
|
lvl={2}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
marginBottom: '0.83em',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('subheading').toUpperCase()}
|
||||||
|
</H>
|
||||||
|
{waitingParticipants?.length > 0 && (
|
||||||
|
<Div marginBottom=".9375rem">
|
||||||
|
<ParticipantsCollapsableList<WaitingParticipant>
|
||||||
|
heading={t('waiting.title')}
|
||||||
|
participants={waitingParticipants}
|
||||||
|
renderParticipant={(participant) => (
|
||||||
|
<WaitingParticipantListItem
|
||||||
|
key={participant.id}
|
||||||
|
participant={participant}
|
||||||
|
onAction={handleParticipantEntry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
action={<></>}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
)}
|
||||||
|
{raisedHandParticipants.length > 0 && (
|
||||||
|
<Div marginBottom=".9375rem">
|
||||||
|
<ParticipantsCollapsableList<Participant>
|
||||||
|
heading={t('raisedHands')}
|
||||||
|
participants={raisedHandParticipants}
|
||||||
|
renderParticipant={(participant) => (
|
||||||
|
<HandRaisedListItem
|
||||||
|
key={participant.identity}
|
||||||
|
participant={participant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
action={
|
||||||
|
<LowerAllHandsButton participants={raisedHandParticipants} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
)}
|
||||||
|
<ParticipantsCollapsableList<Participant>
|
||||||
|
heading={t('contributors')}
|
||||||
|
participants={sortedParticipants}
|
||||||
|
renderParticipant={(participant) => (
|
||||||
|
<ParticipantListItem
|
||||||
|
key={participant.identity}
|
||||||
|
participant={participant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
action={<MuteEveryoneButton participants={sortedRemoteParticipants} />}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||||
|
import { ToggleButton } from '@/primitives'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useParticipants } from '@livekit/components-react'
|
||||||
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
|
export const ParticipantsToggle = ({
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: ToggleButtonProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context could not be used due to inconsistent refresh behavior.
|
||||||
|
* The 'numParticipant' property on the room only updates when the room's metadata changes,
|
||||||
|
* resulting in a delay compared to the participant list's actual refresh rate.
|
||||||
|
*/
|
||||||
|
const participants = useParticipants()
|
||||||
|
const numParticipants = participants?.length
|
||||||
|
const announcedCount =
|
||||||
|
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||||
|
|
||||||
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
|
||||||
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'toggle-participants',
|
||||||
|
handler: toggleParticipants,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-block',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||||
|
<ToggleButton
|
||||||
|
square
|
||||||
|
variant="primaryTextDark"
|
||||||
|
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||||
|
count: announcedCount,
|
||||||
|
})}.`}
|
||||||
|
isSelected={isParticipantsOpen}
|
||||||
|
aria-expanded={isParticipantsOpen}
|
||||||
|
onPress={(e) => {
|
||||||
|
toggleParticipants()
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
|
data-attr={`controls-participants-${tooltipLabel}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RiGroupLine />
|
||||||
|
</ToggleButton>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-.25rem',
|
||||||
|
right: '-.25rem',
|
||||||
|
width: '1.25rem',
|
||||||
|
height: '1.25rem',
|
||||||
|
backgroundColor: 'gray',
|
||||||
|
borderRadius: '50%',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
aria-hidden={true}
|
||||||
|
>
|
||||||
|
{numParticipants < 100 ? (
|
||||||
|
numParticipants || 1
|
||||||
|
) : (
|
||||||
|
<RiInfinityLine size={10} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import { RiPushpin2Fill } from '@remixicon/react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
||||||
|
const { inFocus } = useFocusToggleParticipant(participant)
|
||||||
|
|
||||||
|
if (!inFocus) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
height: '18px',
|
||||||
|
width: '18px',
|
||||||
|
borderRadius: '100%',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-2px',
|
||||||
|
right: '-4px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiPushpin2Fill size={14} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-2
@@ -3,10 +3,10 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
|
||||||
export const WaitingParticipantRow = ({
|
export const WaitingParticipantListItem = ({
|
||||||
participant,
|
participant,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useCallback, useId } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiGroupLine } from '@remixicon/react'
|
|
||||||
import { ToggleButton, type ToggleButtonProps } from '@/primitives/ToggleButton'
|
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { ParticipantsCount } from '@/features/participants/components/ParticipantsCount'
|
|
||||||
|
|
||||||
const containerStyles = css({ position: 'relative', display: 'inline-block' })
|
|
||||||
|
|
||||||
export const ParticipantsToggle = ({
|
|
||||||
onPress,
|
|
||||||
...props
|
|
||||||
}: ToggleButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
|
||||||
const countId = useId()
|
|
||||||
|
|
||||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'toggle-participants',
|
|
||||||
handler: toggleParticipants,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handlePress = useCallback(
|
|
||||||
(e: Parameters<NonNullable<ToggleButtonProps['onPress']>>[0]) => {
|
|
||||||
toggleParticipants()
|
|
||||||
onPress?.(e)
|
|
||||||
},
|
|
||||||
[toggleParticipants, onPress]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={containerStyles}>
|
|
||||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
|
||||||
<ToggleButton
|
|
||||||
square
|
|
||||||
variant="primaryTextDark"
|
|
||||||
aria-label={t(tooltipLabel)}
|
|
||||||
aria-describedby={countId}
|
|
||||||
isSelected={isParticipantsOpen}
|
|
||||||
aria-expanded={isParticipantsOpen}
|
|
||||||
onPress={handlePress}
|
|
||||||
data-attr={`controls-participants-${tooltipLabel}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RiGroupLine />
|
|
||||||
</ToggleButton>
|
|
||||||
</VisualOnlyTooltip>
|
|
||||||
<ParticipantsCount describedById={countId} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { TrackSource } from '@livekit/protocol'
|
import type { TrackSource } from '@livekit/protocol'
|
||||||
import { useLocalParticipantPermissions } from '@livekit/components-react'
|
import {
|
||||||
|
useLocalParticipant,
|
||||||
|
useParticipantPermissions,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
|
||||||
export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
||||||
const permissions = useLocalParticipantPermissions()
|
const { localParticipant } = useLocalParticipant()
|
||||||
|
const permissions = useParticipantPermissions({
|
||||||
|
participant: localParticipant,
|
||||||
|
})
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
permissions?.canPublish &&
|
permissions?.canPublish &&
|
||||||
permissions?.canPublishSources?.includes(trackSource)
|
permissions?.canPublishSources?.includes(trackSource)
|
||||||
|
|||||||
+8
-18
@@ -1,35 +1,27 @@
|
|||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
|
||||||
import {
|
import {
|
||||||
useRemoteParticipants,
|
useRemoteParticipants,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
|
||||||
|
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
import { isMobileBrowser } from '@livekit/components-core'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
|
||||||
|
|
||||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const ConnectionObserver = () => {
|
export const useConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const connectionStartTimeRef = useRef<number | null>(null)
|
const connectionStartTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
|
||||||
const isMobile = isMobileBrowser()
|
|
||||||
|
|
||||||
const isAdvancedConnectionObserverEnabled =
|
const isAdvancedConnectionObserverEnabled =
|
||||||
!isMobile && isAnalyticsEnabled && featureEnabled
|
useIsAdvancedConnectionObserverEnabled()
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
@@ -240,6 +232,4 @@ export const ConnectionObserver = () => {
|
|||||||
connectionStartTimeRef.current = null
|
connectionStartTimeRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums.ts'
|
||||||
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled.ts'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
|
||||||
|
export const useIsAdvancedConnectionObserverEnabled = () => {
|
||||||
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||||
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
|
const isMobile = isMobileBrowser()
|
||||||
|
return !isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
@@ -43,9 +43,7 @@ export const usePublishSourcesManager = () => {
|
|||||||
const roomId = data?.slug
|
const roomId = data?.slug
|
||||||
|
|
||||||
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
||||||
const remoteParticipants = useRemoteParticipants({
|
const remoteParticipants = useRemoteParticipants()
|
||||||
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
|
||||||
})
|
|
||||||
|
|
||||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||||
(participant) => !getParticipantIsRoomAdmin(participant)
|
(participant) => !getParticipantIsRoomAdmin(participant)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Participant, RoomEvent } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
useRoomContext,
|
|
||||||
useParticipantAttribute,
|
useParticipantAttribute,
|
||||||
useParticipantInfo,
|
useParticipants,
|
||||||
useRemoteParticipants,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -14,55 +12,28 @@ type useRaisedHandProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||||
const room = useRoomContext()
|
const { isHandRaised } = useRaisedHand({ participant })
|
||||||
const { identity } = useParticipantInfo({ participant })
|
|
||||||
const localIdentity = room.localParticipant.identity
|
|
||||||
|
|
||||||
const localHandRaisedAt = useParticipantAttribute('handRaisedAt', {
|
const participants = useParticipants()
|
||||||
participant: room.localParticipant,
|
|
||||||
})
|
|
||||||
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
|
||||||
})
|
|
||||||
|
|
||||||
const raisedHands = useMemo(() => {
|
|
||||||
const byIdentity = new Map<string, number>()
|
|
||||||
|
|
||||||
const add = (id: string, value?: string) => {
|
|
||||||
const time = new Date(value ?? '').getTime()
|
|
||||||
if (Number.isNaN(time)) return
|
|
||||||
const existing = byIdentity.get(id)
|
|
||||||
if (existing === undefined || time < existing) byIdentity.set(id, time)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localIdentity) add(localIdentity, localHandRaisedAt)
|
|
||||||
|
|
||||||
remoteParticipants.forEach((p) =>
|
|
||||||
add(p.identity, p.attributes.handRaisedAt)
|
|
||||||
)
|
|
||||||
|
|
||||||
return byIdentity
|
|
||||||
}, [remoteParticipants, localIdentity, localHandRaisedAt])
|
|
||||||
|
|
||||||
const sortedHands = useMemo(
|
|
||||||
() =>
|
|
||||||
[...raisedHands.entries()]
|
|
||||||
.map(([identity, time]) => ({ identity, time }))
|
|
||||||
.sort(
|
|
||||||
(a, b) => a.time - b.time || a.identity.localeCompare(b.identity)
|
|
||||||
),
|
|
||||||
[raisedHands]
|
|
||||||
)
|
|
||||||
|
|
||||||
const positionInQueue = useMemo(() => {
|
const positionInQueue = useMemo(() => {
|
||||||
const index = sortedHands.findIndex((h) => h.identity === identity)
|
if (!isHandRaised) return
|
||||||
return index === -1 ? undefined : index + 1
|
|
||||||
}, [sortedHands, identity])
|
return (
|
||||||
|
participants
|
||||||
|
.filter((p) => !!p.attributes.handRaisedAt)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.attributes.handRaisedAt)
|
||||||
|
const dateB = new Date(b.attributes.handRaisedAt)
|
||||||
|
return dateA.getTime() - dateB.getTime()
|
||||||
|
})
|
||||||
|
.findIndex((p) => p.identity === participant.identity) + 1
|
||||||
|
)
|
||||||
|
}, [participants, participant, isHandRaised])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
positionInQueue,
|
positionInQueue,
|
||||||
firstInQueue: positionInQueue === 1,
|
firstInQueue: positionInQueue == 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -11,16 +11,16 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets initial video quality for new participants as they join.
|
* This hook sets initial video quality for new participants as they join.
|
||||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
*/
|
*/
|
||||||
export const VideoResolutionSubscription = () => {
|
export const useVideoResolutionSubscription = () => {
|
||||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
|
|
||||||
const handleTrackPublished = (
|
const handleTrackPublished = (
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant
|
_participant: RemoteParticipant
|
||||||
@@ -41,10 +41,9 @@ export const VideoResolutionSubscription = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
}
|
}
|
||||||
}, [room, videoSubscribeQuality])
|
}, [room, videoSubscribeQuality])
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
formatChatMessageLinks,
|
||||||
|
useChat,
|
||||||
|
useParticipants,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { chatStore } from '@/stores/chat'
|
||||||
|
import { Div, Text } from '@/primitives'
|
||||||
|
import { ChatInput } from '../components/chat/Input'
|
||||||
|
import { ChatEntry } from '../components/chat/Entry'
|
||||||
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import {
|
||||||
|
type LocalParticipant,
|
||||||
|
type RemoteParticipant,
|
||||||
|
RoomEvent,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
|
|
||||||
|
export interface ChatProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||||
|
* in the room. Only users who are in the room at the time of dispatch will receive the message.
|
||||||
|
*/
|
||||||
|
export function Chat({ ...props }: ChatProps) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
|
||||||
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
const ulRef = React.useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { send, chatMessages, isSending } = useChat()
|
||||||
|
|
||||||
|
const { isChatOpen, isSidePanelOpen } = useSidePanel()
|
||||||
|
const chatSnap = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
// Keep track of the element that opened the chat so we can restore focus
|
||||||
|
// when the chat panel is closed.
|
||||||
|
useRestoreFocus(isChatOpen, {
|
||||||
|
// Avoid layout "jump" during the side panel slide-in animation.
|
||||||
|
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||||
|
onOpened: () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
preventScroll: true,
|
||||||
|
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||||
|
const participants = useParticipants()
|
||||||
|
|
||||||
|
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||||
|
const previousMessageCount = React.useRef(0)
|
||||||
|
|
||||||
|
async function handleSubmit(text: string) {
|
||||||
|
if (!send || !text) return
|
||||||
|
await send(text)
|
||||||
|
inputRef?.current?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
// (see https://github.com/livekit/components-js/issues/1158)
|
||||||
|
// Remove this once we refactor chat to use the new text stream approach
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!chatMessages || chatMessages.length <= previousMessageCount.current)
|
||||||
|
return
|
||||||
|
const msg = chatMessages.slice(-1)[0]
|
||||||
|
const from = msg.from as RemoteParticipant | LocalParticipant | undefined
|
||||||
|
room.emit(RoomEvent.ChatMessage, msg, from)
|
||||||
|
previousMessageCount.current = chatMessages.length
|
||||||
|
}, [chatMessages, room])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (chatMessages.length > 0 && ulRef.current) {
|
||||||
|
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
|
||||||
|
}
|
||||||
|
}, [ulRef, chatMessages])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (chatMessages.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isChatOpen &&
|
||||||
|
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
|
||||||
|
) {
|
||||||
|
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
|
||||||
|
chatStore.unreadMessages = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadMessageCount = chatMessages.filter(
|
||||||
|
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
|
||||||
|
).length
|
||||||
|
|
||||||
|
if (
|
||||||
|
unreadMessageCount > 0 &&
|
||||||
|
chatSnap.unreadMessages !== unreadMessageCount
|
||||||
|
) {
|
||||||
|
chatStore.unreadMessages = unreadMessageCount
|
||||||
|
}
|
||||||
|
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
|
||||||
|
|
||||||
|
const renderedMessages = React.useMemo(() => {
|
||||||
|
return chatMessages.map((msg, idx, allMsg) => {
|
||||||
|
const hideMetadata =
|
||||||
|
idx >= 1 &&
|
||||||
|
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
|
||||||
|
allMsg[idx - 1].from === msg.from
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatEntry
|
||||||
|
key={msg.id ?? idx}
|
||||||
|
hideMetadata={hideMetadata}
|
||||||
|
entry={msg}
|
||||||
|
messageFormatter={formatChatMessageLinks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// This ensures that the chat message list is updated to reflect any changes in participant information.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chatMessages, participants])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div
|
||||||
|
display={'flex'}
|
||||||
|
padding={'0 1.5rem'}
|
||||||
|
flexGrow={1}
|
||||||
|
flexDirection={'column'}
|
||||||
|
minHeight={0}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
className={css({
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('disclaimer')}
|
||||||
|
</Text>
|
||||||
|
<Div
|
||||||
|
flexGrow={1}
|
||||||
|
flexDirection={'column'}
|
||||||
|
minHeight={0}
|
||||||
|
overflowY="scroll"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
className="lk-list lk-chat-messages"
|
||||||
|
ref={ulRef}
|
||||||
|
role="log"
|
||||||
|
aria-relevant="additions"
|
||||||
|
>
|
||||||
|
{renderedMessages}
|
||||||
|
</ul>
|
||||||
|
</Div>
|
||||||
|
<ChatInput
|
||||||
|
inputRef={inputRef}
|
||||||
|
onSubmit={(e) => handleSubmit(e)}
|
||||||
|
isSending={isSending}
|
||||||
|
/>
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||||
import { ParticipantsToggle } from '../../components/controls/ParticipantsToggle'
|
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { LinkButton } from '@/primitives'
|
import { LinkButton } from '@/primitives'
|
||||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||||
@@ -24,7 +24,7 @@ import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton
|
|||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ export function MobileControlBar({
|
|||||||
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const { toggleEffects } = useSidePanel()
|
const { toggleEffects } = useSidePanel()
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||||
import { ParticipantsToggle } from '../../components/controls/ParticipantsToggle'
|
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||||
import { InfoToggle } from '../../components/controls/InfoToggle'
|
import { InfoToggle } from '../../components/controls/InfoToggle'
|
||||||
import { AdminToggle } from '../../components/AdminToggle'
|
import { AdminToggle } from '../../components/AdminToggle'
|
||||||
import { useSize } from '../../hooks/useResizeObserver'
|
import { useSize } from '../../hooks/useResizeObserver'
|
||||||
import { useState, RefObject, useEffect } from 'react'
|
import { useState, RefObject } from 'react'
|
||||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
@@ -66,32 +66,12 @@ export const LateralMenu = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreakpointObserverProps {
|
|
||||||
containerRef: RefObject<HTMLDivElement>
|
|
||||||
onWideChange: (isWide: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const BreakpointObserver = ({
|
|
||||||
containerRef,
|
|
||||||
onWideChange,
|
|
||||||
}: BreakpointObserverProps) => {
|
|
||||||
const { width } = useSize(containerRef)
|
|
||||||
const isWide = width > CONTROL_BAR_BREAKPOINT
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onWideChange(isWide)
|
|
||||||
}, [isWide, onWideChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MoreOptions = ({
|
export const MoreOptions = ({
|
||||||
parentElement,
|
parentElement,
|
||||||
}: {
|
}: {
|
||||||
parentElement: RefObject<HTMLDivElement>
|
parentElement: RefObject<HTMLDivElement>
|
||||||
}) => {
|
}) => {
|
||||||
const [isWide, setIsWide] = useState(false)
|
const { width: parentWidth } = useSize(parentElement)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={css({
|
className={css({
|
||||||
@@ -103,11 +83,11 @@ export const MoreOptions = ({
|
|||||||
paddingRight: '0.25rem',
|
paddingRight: '0.25rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<BreakpointObserver
|
{parentWidth > CONTROL_BAR_BREAKPOINT ? (
|
||||||
containerRef={parentElement}
|
<NavigationControls />
|
||||||
onWideChange={setIsWide}
|
) : (
|
||||||
/>
|
<LateralMenu />
|
||||||
{isWide ? <NavigationControls /> : <LateralMenu />}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user