mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 |
@@ -8,14 +8,57 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(summary) report exception type in failure analytics
|
||||||
|
- ✨(frontend) add configurable documentation menu item
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||||
|
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||||
|
- ⬆️(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
|
||||||
|
- 📝(legal) update terms of service
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
|
- 🐛(summary) properly detect when failure webhook should be sent
|
||||||
|
- 🐛(backend) preserve recording metadata when updating room access
|
||||||
|
|
||||||
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||||
|
- ✨(addon) show add-in tools when creating meetings in shared calendars
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||||
|
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||||
|
- ♻️(frontend) refactor background processors to use the new API
|
||||||
|
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||||
|
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||||
|
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🩹(backend) identify externally provisioned users to PostHog
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
|
- ♿️(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
|
||||||
|
|
||||||
@@ -59,6 +102,7 @@ and this project adheres to
|
|||||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
- 🩹(frontend) enable screen share button in PiP #1458
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:22 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ 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,6 +344,7 @@ 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_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
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:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
FRONTEND_CUSTOM_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_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_CUSTOM_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_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_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.
|
||||||
|
|
||||||
> [!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.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
publiccodeYmlVersion: 0.5.0
|
||||||
|
name: LaSuite Meet
|
||||||
|
applicationSuite: LaSuite
|
||||||
|
url: https://github.com/suitenumerique/meet
|
||||||
|
releaseDate: 2026-07-22
|
||||||
|
platforms:
|
||||||
|
- web
|
||||||
|
organisation:
|
||||||
|
name: DINUM
|
||||||
|
uri: https://numerique.gouv.fr
|
||||||
|
fundedBy:
|
||||||
|
- name: Direction interministérielle du numérique (DINUM)
|
||||||
|
uri: https://www.numerique.gouv.fr
|
||||||
|
developmentStatus: stable
|
||||||
|
softwareType: standalone/web
|
||||||
|
intendedAudience:
|
||||||
|
countries:
|
||||||
|
- FR
|
||||||
|
description:
|
||||||
|
en:
|
||||||
|
localisedName: LaSuite Meet
|
||||||
|
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||||
|
longDescription: "Open Source video conference application, based on LiveKit,
|
||||||
|
Django and React. It is the official web video conference application of
|
||||||
|
French Ministries."
|
||||||
|
features:
|
||||||
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
|
- Support for multiple screen sharing streams
|
||||||
|
- Non-persistent, secure chat
|
||||||
|
- Meeting recording
|
||||||
|
- Meeting transcription & Summary
|
||||||
|
- Telephony integration
|
||||||
|
- Secure participation with robust authentication and access control
|
||||||
|
- Customizable frontend style
|
||||||
|
legal:
|
||||||
|
license: MIT
|
||||||
|
maintenance:
|
||||||
|
type: internal
|
||||||
|
contacts:
|
||||||
|
- name: "Samuel Paccoud"
|
||||||
|
email: samuel.paccoud@numerique.gouv.fr
|
||||||
|
affiliation: DINUM
|
||||||
|
- name: "Antoine Lebaud"
|
||||||
|
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||||
|
affiliation: DINUM
|
||||||
|
localisation:
|
||||||
|
localisationReady: true
|
||||||
|
availableLanguages:
|
||||||
|
- fr
|
||||||
|
- de
|
||||||
|
- en
|
||||||
|
- nl
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||||
<Version>0.0.2.0</Version>
|
<Version>1.0.0.0</Version>
|
||||||
<ProviderName>__APP_NAME__</ProviderName>
|
<ProviderName>__APP_NAME__</ProviderName>
|
||||||
<DefaultLocale>fr-FR</DefaultLocale>
|
<DefaultLocale>fr-FR</DefaultLocale>
|
||||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||||
@@ -205,5 +205,174 @@
|
|||||||
</bt:String>
|
</bt:String>
|
||||||
</bt:LongStrings>
|
</bt:LongStrings>
|
||||||
</Resources>
|
</Resources>
|
||||||
|
|
||||||
|
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
|
||||||
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
|
||||||
|
<Requirements>
|
||||||
|
<bt:Sets DefaultMinVersion="1.8">
|
||||||
|
<bt:Set Name="Mailbox"/>
|
||||||
|
</bt:Sets>
|
||||||
|
</Requirements>
|
||||||
|
<Hosts>
|
||||||
|
<Host xsi:type="MailHost">
|
||||||
|
<DesktopFormFactor>
|
||||||
|
<FunctionFile resid="Commands.Url"/>
|
||||||
|
<SupportsSharedFolders>true</SupportsSharedFolders>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgReadGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||||
|
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="apptComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||||
|
<Label resid="OpenSettings.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="OpenSettings.Label"/>
|
||||||
|
<Description resid="OpenSettings.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
</DesktopFormFactor>
|
||||||
|
</Host>
|
||||||
|
</Hosts>
|
||||||
|
<Resources>
|
||||||
|
<bt:Images>
|
||||||
|
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
|
||||||
|
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
|
||||||
|
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
|
||||||
|
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
|
||||||
|
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
|
||||||
|
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
|
||||||
|
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
|
||||||
|
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
|
||||||
|
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
|
||||||
|
</bt:Images>
|
||||||
|
<bt:Urls>
|
||||||
|
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
|
||||||
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
|
||||||
|
</bt:Urls>
|
||||||
|
<bt:ShortStrings>
|
||||||
|
<!-- Default (French) -->
|
||||||
|
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||||
|
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
|
||||||
|
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Open settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen"/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:ShortStrings>
|
||||||
|
<bt:LongStrings>
|
||||||
|
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:LongStrings>
|
||||||
|
</Resources>
|
||||||
|
</VersionOverrides>
|
||||||
</VersionOverrides>
|
</VersionOverrides>
|
||||||
</OfficeApp>
|
</OfficeApp>
|
||||||
|
|||||||
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.1",
|
"i18next": "^26.3.4",
|
||||||
"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.1",
|
"version": "26.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||||
"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.1",
|
"i18next": "26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
data-i18n="footer.feedback"
|
data-i18n="footer.feedback"
|
||||||
></a>
|
></a>
|
||||||
<div id="footer-right">
|
<div id="footer-right">
|
||||||
<span class="version-badge">beta</span>
|
<span class="version-number">1.0.0</span>
|
||||||
<span class="version-number">0.0.2</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.23.0"
|
version = "1.24.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.23.0"
|
version = "1.24.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = [
|
||||||
|
"status",
|
||||||
|
"=id",
|
||||||
|
"worker_id",
|
||||||
|
"room__slug",
|
||||||
|
"=room__id",
|
||||||
|
"accesses__user__email",
|
||||||
|
]
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
|||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -267,6 +268,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from livekit import api
|
|||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from core import utils
|
|
||||||
from core.models import Recording, RecordingStatusChoices
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
|
from .room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
from .telephony import TelephonyException, TelephonyService
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {}, ["recording_mode", "recording_status"]
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
)
|
)
|
||||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
|||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
async def update_metadata(
|
||||||
"""Update a LiveKit room's metadata.
|
self,
|
||||||
|
room_name: str,
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
|
remove_keys: Optional[list[str]] = None,
|
||||||
|
):
|
||||||
|
"""Merge values into a LiveKit room's metadata.
|
||||||
|
|
||||||
The `room_name` corresponds to the LiveKit room identifier
|
The `room_name` corresponds to the LiveKit room identifier
|
||||||
(i.e. the Room model's UUID as a string).
|
(i.e. the Room model's UUID as a string).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RoomNotFoundException: the room does not exist in LiveKit.
|
||||||
|
RoomManagementException: the metadata update otherwise fails.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lkapi = utils.create_livekit_client()
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||||
|
|
||||||
|
if not response.rooms:
|
||||||
|
logger.warning(
|
||||||
|
"Room %s not found in LiveKit, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise RoomNotFoundException("Room does not exist")
|
||||||
|
|
||||||
|
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||||
|
|
||||||
|
for key in remove_keys or []:
|
||||||
|
existing_metadata.pop(key, None)
|
||||||
|
|
||||||
|
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||||
|
|
||||||
await lkapi.room.update_room_metadata(
|
await lkapi.room.update_room_metadata(
|
||||||
UpdateRoomMetadataRequest(
|
UpdateRoomMetadataRequest(
|
||||||
room=room_name,
|
room=room_name,
|
||||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
metadata=json.dumps(updated_metadata),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
|||||||
return WorkerServiceMediator(mock_worker_service)
|
return WorkerServiceMediator(mock_worker_service)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_start_recording_success(
|
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||||
mock_update_room_metadata, mediator, mock_worker_service
|
|
||||||
):
|
|
||||||
"""Test successful recording start"""
|
"""Test successful recording start"""
|
||||||
# Setup
|
# Setup
|
||||||
worker_id = "test-worker-123"
|
worker_id = "test-worker-123"
|
||||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
|||||||
assert mock_recording.worker_id == worker_id
|
assert mock_recording.worker_id == worker_id
|
||||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(mock_recording.room.id),
|
str(mock_recording.room.id),
|
||||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||||
)
|
)
|
||||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_mediator_start_recording_worker_errors(
|
def test_mediator_start_recording_worker_errors(
|
||||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||||
assert mock_recording.worker_id is None
|
assert mock_recording.worker_id is None
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_mediator_start_recording_from_forbidden_status(
|
def test_mediator_start_recording_from_forbidden_status(
|
||||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
mock_update_metadata, mediator, mock_worker_service, status
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
|||||||
mock_recording.refresh_from_db()
|
mock_recording.refresh_from_db()
|
||||||
assert mock_recording.status == status
|
assert mock_recording.status == status
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ from core.services.livekit_events import (
|
|||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
|
from core.services.room_management import RoomManagementException
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
from core.utils import MetadataUpdateException, NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -70,9 +71,9 @@ def test_initialization(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success(
|
def test_handle_egress_ended_success(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
@@ -86,8 +87,8 @@ def test_handle_egress_ended_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -104,9 +105,9 @@ def test_handle_egress_ended_success(
|
|||||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_success(
|
def test_handle_egress_updated_success(
|
||||||
mock_update_room_metadata, egress_status, status, service
|
mock_update_metadata, egress_status, status, service
|
||||||
):
|
):
|
||||||
"""Should successfully update room's metadata."""
|
"""Should successfully update room's metadata."""
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ def test_handle_egress_updated_success(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {"recording_status": status}
|
str(recording.room.id), {"recording_status": status}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,9 +130,9 @@ def test_handle_egress_updated_success(
|
|||||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_non_handled(
|
def test_handle_egress_updated_non_handled(
|
||||||
mock_update_room_metadata, egress_status, service
|
mock_update_metadata, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ def test_handle_egress_updated_non_handled(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -153,9 +154,9 @@ def test_handle_egress_updated_non_handled(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
@@ -178,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
def test_handle_egress_ended_notification_fails(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
@@ -200,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
|
|||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_found(
|
def test_handle_egress_ended_recording_not_found(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||||
|
|
||||||
@@ -223,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "active"
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_active(
|
def test_handle_egress_ended_recording_not_active(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore non-active recordings."""
|
"""Should ignore non-active recordings."""
|
||||||
|
|
||||||
@@ -244,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -253,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_limit_reached(
|
def test_handle_egress_ended_recording_not_limit_reached(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore egress non-limit-reached statuses."""
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
@@ -267,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||||
mock_update_room_metadata, mock_collector_class, service, settings
|
mock_update_metadata, mock_collector_class, service, settings
|
||||||
):
|
):
|
||||||
"""Should call MetadataCollectorService.stop when it exists."""
|
"""Should call MetadataCollectorService.stop when it exists."""
|
||||||
settings.METADATA_COLLECTOR_ENABLED = True
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
@@ -306,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
@@ -335,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||||
@@ -345,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
[(True, "notification_succeeded"), (False, "saved")],
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
@@ -378,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status, expected_status",
|
"egress_status, expected_status",
|
||||||
[
|
[
|
||||||
@@ -387,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
egress_status,
|
egress_status,
|
||||||
@@ -424,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
|||||||
EgressStatus.EGRESS_ABORTED,
|
EgressStatus.EGRESS_ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_room_metadata, egress_status, service, settings
|
mock_update_metadata, egress_status, service, settings
|
||||||
):
|
):
|
||||||
"""Shouldn't save on invalid status."""
|
"""Shouldn't save on invalid status."""
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
@@ -445,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
mock_update_room_metadata, status, service, settings
|
mock_update_metadata, status, service, settings
|
||||||
):
|
):
|
||||||
"""Should handle non-savable recordings idempotently without raising.
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
LiveKitAPI,
|
LiveKitAPI,
|
||||||
SendDataRequest,
|
SendDataRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,11 +108,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,7 +125,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.with_attributes(
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||||
)
|
)
|
||||||
@@ -253,57 +257,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
|||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
class MetadataUpdateException(Exception):
|
|
||||||
"""Room's metadata update fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def update_room_metadata(
|
|
||||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
|
||||||
):
|
|
||||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room_name: Name of the room to update
|
|
||||||
metadata: Dictionary of metadata key-values to add/update
|
|
||||||
remove_keys: Optional list of keys to remove from existing metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
lkapi = create_livekit_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await lkapi.room.list_rooms(
|
|
||||||
ListRoomsRequest(
|
|
||||||
names=[room_name],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.rooms:
|
|
||||||
return
|
|
||||||
|
|
||||||
room = response.rooms[0]
|
|
||||||
|
|
||||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
|
||||||
|
|
||||||
if remove_keys:
|
|
||||||
for key in remove_keys:
|
|
||||||
existing_metadata.pop(key, None)
|
|
||||||
|
|
||||||
updated_metadata = {**existing_metadata, **metadata}
|
|
||||||
|
|
||||||
await lkapi.room.update_room_metadata(
|
|
||||||
UpdateRoomMetadataRequest(
|
|
||||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except TwirpError as e:
|
|
||||||
raise MetadataUpdateException(
|
|
||||||
f"Failed to update metadata for room {room_name}: {e}"
|
|
||||||
) from e
|
|
||||||
finally:
|
|
||||||
await lkapi.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,9 @@ 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
|
||||||
),
|
),
|
||||||
@@ -671,6 +674,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.23.0"
|
version = "1.24.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.23.0"
|
version = "1.24.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+239
-54
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.23.0",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.23.0",
|
"version": "1.24.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,10 +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",
|
||||||
"@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.0",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@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",
|
||||||
@@ -28,14 +29,14 @@
|
|||||||
"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.19.2",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.387.0",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,6 +112,62 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"aria-hidden": "^1.2.3",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
|
"react-aria": "3.49.0",
|
||||||
|
"react-stately": "3.47.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
|
||||||
|
"version": "3.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||||
|
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -970,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.45.8",
|
"version": "1.46.6",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -991,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/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",
|
||||||
@@ -1752,9 +1816,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.35.0",
|
"version": "3.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||||
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -2357,9 +2421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.0",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2378,12 +2442,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.0",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.0"
|
"@tanstack/query-core": "5.101.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3192,6 +3256,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||||
@@ -7094,6 +7171,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-boolean-object": {
|
"node_modules/is-boolean-object": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||||
@@ -8035,13 +8125,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.19.2",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.45.8",
|
"@livekit/protocol": "1.46.6",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -8675,6 +8765,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9033,19 +9136,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.387.0",
|
"version": "1.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.387.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||||
"integrity": "sha512-Pv1jUMySMN62zoAxdJBJPV8n62lkHdjuWhpeU7izczc5Dqbx3hhqO2hkrNTI8Yx1ezmWk2qUHZs03FuOBubdFQ==",
|
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.33.0",
|
"@posthog/core": "^1.38.0",
|
||||||
"@posthog/types": "^1.387.0",
|
"@posthog/types": "^1.391.1",
|
||||||
"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",
|
||||||
"preact": "^10.28.2",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -9062,13 +9165,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.28.3",
|
"version": "10.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
@@ -9275,19 +9386,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria": {
|
"node_modules/react-aria": {
|
||||||
"version": "3.49.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"aria-hidden": "^1.2.3",
|
"aria-hidden": "^1.2.3",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9296,17 +9407,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria-components": {
|
"node_modules/react-aria-components": {
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-stately": "3.47.0"
|
"react-stately": "3.48.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
@@ -9360,15 +9471,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-stately": {
|
"node_modules/react-stately": {
|
||||||
"version": "3.47.0",
|
"version": "3.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -11137,6 +11248,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sapphi-red"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-svgr": {
|
"node_modules/vite-plugin-svgr": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||||
@@ -11470,9 +11655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "5.1.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.23.0",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,15 +17,16 @@
|
|||||||
"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",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@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",
|
||||||
"@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.0",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@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",
|
||||||
@@ -35,14 +36,14 @@
|
|||||||
"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.19.2",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.387.0",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ 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.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@ 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
|
||||||
@@ -58,6 +59,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -7,36 +7,20 @@ 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: {
|
subtitles: { width: '40px', height: '40px' },
|
||||||
width: '40px',
|
list: { width: '32px', height: '32px' },
|
||||||
height: '40px',
|
placeholder: { width: '100%', height: '100%' },
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: {
|
true: { border: '2px solid white' },
|
||||||
border: '2px solid white',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -49,32 +33,35 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = React.memo(
|
||||||
name,
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
bgColor,
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
context,
|
return (
|
||||||
notification,
|
<div
|
||||||
style,
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
...props
|
className={avatar({ context, notification })}
|
||||||
}: AvatarProps) => {
|
{...props}
|
||||||
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',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{initial}
|
<svg
|
||||||
</span>
|
viewBox="0 0 100 100"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
)
|
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'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -10,6 +11,72 @@ 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[]
|
||||||
@@ -40,52 +107,42 @@ 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 tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
|
? prev
|
||||||
|
: next
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
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
|
<>
|
||||||
key={carouselOrientation}
|
<CarouselLayoutObserver
|
||||||
className="lk-carousel"
|
asideEl={asideEl}
|
||||||
ref={asideEl}
|
orientation={orientation}
|
||||||
{...props}
|
onLayoutChange={handleLayoutChange}
|
||||||
>
|
/>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
</aside>
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
|
<aside
|
||||||
|
key={layout.orientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from './ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
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) {
|
||||||
@@ -10,6 +10,36 @@ 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
|
||||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
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 { layout } = useGridLayout(gridEl, tracks.length)
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -57,8 +88,13 @@ 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 > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||||
|
|
||||||
|
export const PinAnnouncer = () => {
|
||||||
|
usePinAnnouncements()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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 { useMaybeLayoutContext } from '@livekit/components-react'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
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,7 +28,6 @@ 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
|
||||||
@@ -38,18 +37,13 @@ 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) => {
|
||||||
pinParticipant()
|
setPinnedTrack(trackReference)
|
||||||
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 { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/participants/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'
|
||||||
|
|||||||
+6
-5
@@ -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,7 +17,6 @@ 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(() => {
|
||||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
if (!localParticipant.isScreenShareEnabled) return
|
const participant = trackReference.participant
|
||||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
if (!(participant instanceof LocalParticipant)) return
|
||||||
|
if (!participant.isScreenShareEnabled) return
|
||||||
|
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
+9
-7
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
children: ReactNode
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.FC<KeyboardShortcutHintProps> = ({
|
export const KeyboardShortcutHint = React.memo(
|
||||||
children,
|
({ hint }: KeyboardShortcutHintProps) => {
|
||||||
}) => {
|
return <Hint>{hint}</Hint>
|
||||||
return <Hint>{children}</Hint>
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
|
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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'
|
||||||
+34
-104
@@ -1,39 +1,32 @@
|
|||||||
|
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'
|
import { ParticipantTileFocus } from './participantTileFocus/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<{
|
||||||
@@ -52,6 +45,7 @@ export function TrackRefContextIfNeeded(
|
|||||||
|
|
||||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||||
disableMetadata?: boolean
|
disableMetadata?: boolean
|
||||||
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
@@ -66,21 +60,18 @@ export const ParticipantTile: (
|
|||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
disableMetadata,
|
disableMetadata,
|
||||||
|
disableTileControls,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
}: ParticipantTileExtendedProps,
|
}: ParticipantTileExtendedProps,
|
||||||
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(
|
||||||
@@ -88,28 +79,25 @@ export const ParticipantTile: (
|
|||||||
if (
|
if (
|
||||||
trackReference.source &&
|
trackReference.source &&
|
||||||
!subscribed &&
|
!subscribed &&
|
||||||
layoutContext &&
|
layoutStore.pinnedTrackRef &&
|
||||||
layoutContext.pin.dispatch &&
|
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
|
||||||
) {
|
) {
|
||||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
clearPinnedTrack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackReference, layoutContext]
|
[trackReference]
|
||||||
)
|
)
|
||||||
|
|
||||||
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 participantName = getParticipantName(trackReference.participant)
|
const participantColor = getParticipantColor(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 = {
|
||||||
@@ -136,7 +124,9 @@ 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}>
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
{trackReference.participant.isLocal && (
|
||||||
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{isTrackReference(trackReference) &&
|
||||||
@@ -158,80 +148,20 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
participant={trackReference.participant}
|
color={participantColor}
|
||||||
|
displayedNamed={participantName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<div className="lk-participant-metadata">
|
<ParticipantMetadata
|
||||||
<HStack gap={0.25}>
|
displayedName={participantName}
|
||||||
{!isScreenShare && (
|
isScreenShare={isScreenShare}
|
||||||
<MutedMicIndicator
|
participant={trackReference.participant}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!disableMetadata && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
@@ -239,13 +169,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>
|
<KeyboardShortcutHint
|
||||||
{t('toolbarHint', {
|
hint={t('toolbarHint', {
|
||||||
shortcut: formatShortcutLabel(
|
shortcut: formatShortcutLabel(
|
||||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</KeyboardShortcutHint>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
@@ -0,0 +1,23 @@
|
|||||||
|
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
@@ -0,0 +1,32 @@
|
|||||||
|
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
@@ -0,0 +1,45 @@
|
|||||||
|
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
@@ -0,0 +1,112 @@
|
|||||||
|
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
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
-5
@@ -1,23 +1,22 @@
|
|||||||
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 '@/features/rooms/api/lowerHandParticipants'
|
import { useLowerHandParticipants } from '../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>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LowerAllHandsButton = ({
|
const LowerAllHandsButtonInner = ({
|
||||||
participants,
|
participants,
|
||||||
}: LowerAllHandsButtonProps) => {
|
}: LowerAllHandsButtonProps) => {
|
||||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
if (!participants.length) return null
|
||||||
if (!isAdminOrOwner) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -36,3 +35,13 @@ export const LowerAllHandsButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LowerAllHandsButton = ({
|
||||||
|
participants,
|
||||||
|
}: LowerAllHandsButtonProps) => {
|
||||||
|
return (
|
||||||
|
<AdminOrOwnerOnly>
|
||||||
|
<LowerAllHandsButtonInner participants={participants} />
|
||||||
|
</AdminOrOwnerOnly>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-5
@@ -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 '../../ParticipantMenu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/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,9 +93,7 @@ type ParticipantListItemProps = {
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantListItem = ({
|
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||||
participant,
|
|
||||||
}: ParticipantListItemProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
return (
|
return (
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
-21
@@ -4,27 +4,47 @@ 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 '@/features/rooms/api/lowerHandParticipant'
|
import { useLowerHandParticipant } from '../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 HandRaisedListItem = ({
|
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||||
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"
|
||||||
@@ -69,19 +89,9 @@ export const HandRaisedListItem = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{isAdminOrOwner && (
|
<AdminOrOwnerOnly>
|
||||||
<Button
|
<ActionButton participant={participant} name={name} />
|
||||||
square
|
</AdminOrOwnerOnly>
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+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 '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantRow = ({
|
||||||
participant,
|
participant,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+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 './PinMenuItem'
|
import { PinMenuItem } from './items/PinMenuItem'
|
||||||
import { RemoveMenuItem } from './RemoveMenuItem'
|
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
+17
-4
@@ -1,15 +1,28 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import { Participant, Track } 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 { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
import { useSnapshot } from 'valtio'
|
||||||
|
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 { toggle, inFocus } = useFocusToggleParticipant(participant)
|
const trackRef = useMemo(() => {
|
||||||
|
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
|
||||||
@@ -17,7 +30,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={toggle}
|
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../api/listWaitingParticipants'
|
} from '../../participants/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,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/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(mainTrack)}
|
key={getTrackKey(mainTrack)}
|
||||||
trackRef={mainTrack}
|
trackRef={mainTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</MainSlot>
|
</MainSlot>
|
||||||
)}
|
)}
|
||||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(thumbnailTrack)}
|
key={getTrackKey(thumbnailTrack)}
|
||||||
trackRef={thumbnailTrack}
|
trackRef={thumbnailTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
)}
|
)}
|
||||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
'& .lk-participant-media-video': {
|
'& .lk-participant-media-video': {
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
},
|
},
|
||||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
|||||||
const Thumbnail = styled('div', {
|
const Thumbnail = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '1rem',
|
right: '1.25rem',
|
||||||
bottom: '1rem',
|
bottom: '1.25rem',
|
||||||
width: '42%',
|
width: '42%',
|
||||||
maxWidth: '220px',
|
maxWidth: '220px',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'md',
|
boxShadow: 'md',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
@@ -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/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/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'
|
||||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
|||||||
<GridContainer ref={containerRef} style={gridStyle}>
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||||
<ParticipantTile trackRef={track} />
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
|
type PipScreenShareLayoutProps = {
|
||||||
|
screenShareTrack: TrackReferenceOrPlaceholder
|
||||||
|
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout when a screen share is active.
|
||||||
|
* Camera tiles are shown as a compact row at the top; the screen share occupies
|
||||||
|
* the remaining space below, much larger than the camera tiles.
|
||||||
|
*/
|
||||||
|
export const PipScreenShareLayout = memo(
|
||||||
|
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<LayoutContainer>
|
||||||
|
{cameraTracks.length > 0 && (
|
||||||
|
<CameraTilesRow>
|
||||||
|
{cameraTracks.map((track) => (
|
||||||
|
<CameraTile key={getTrackKey(track)}>
|
||||||
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
|
</CameraTile>
|
||||||
|
))}
|
||||||
|
</CameraTilesRow>
|
||||||
|
)}
|
||||||
|
<ScreenShareSlot>
|
||||||
|
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
|
||||||
|
</ScreenShareSlot>
|
||||||
|
</LayoutContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
|
||||||
|
|
||||||
|
const LayoutContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTilesRow = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '22%',
|
||||||
|
minHeight: '60px',
|
||||||
|
maxHeight: '120px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTile = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: '0 1 auto',
|
||||||
|
height: '100%',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
minWidth: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ScreenShareSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { usePagination, useTracks } from '@livekit/components-react'
|
import { usePagination, useTracks } from '@livekit/components-react'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { PipFocusLayout } from './PipFocusLayout'
|
import { PipFocusLayout } from './PipFocusLayout'
|
||||||
import { PipGridLayout } from './PipGridLayout'
|
import { PipGridLayout } from './PipGridLayout'
|
||||||
import { PipPagination } from './PipPagination'
|
import { PipPagination } from './PipPagination'
|
||||||
|
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||||
import { StageFrame } from './StageFrame'
|
import { StageFrame } from './StageFrame'
|
||||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +14,12 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PipStage picks between two layouts based on track count:
|
* PipStage picks between three layouts:
|
||||||
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
* - Screen share mode (any screen share active):
|
||||||
* - Grid mode (3+ tracks): adaptive tiling.
|
* small camera tiles in a row at the top, large screen share below.
|
||||||
|
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
|
||||||
|
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
|
||||||
|
* + one thumbnail overlay.
|
||||||
*/
|
*/
|
||||||
export const PipStage = () => {
|
export const PipStage = () => {
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
@@ -41,51 +45,55 @@ export const PipStage = () => {
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Grid mode order: screen share leads, then cameras (already ordered by
|
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||||
// active speaker via the `ActiveSpeakersChanged` update above).
|
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||||
const gridTracks = useMemo(
|
|
||||||
() =>
|
|
||||||
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
|
||||||
[screenShareTrack, cameraTracks]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||||
// page visually stable (active/recent speakers stay put) via its internal
|
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
||||||
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
|
|
||||||
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
|
|
||||||
|
|
||||||
if (tracks.length === 0) return null
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
/**
|
// Screen share active
|
||||||
* The focus layout shows one main track with one thumbnail overlay,
|
if (screenShareTrack) {
|
||||||
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||||
*/
|
if (cameraTracks.length <= 1) {
|
||||||
if (gridTracks.length > 2) {
|
return (
|
||||||
return (
|
|
||||||
<StageWrapper>
|
|
||||||
<StageFrame>
|
<StageFrame>
|
||||||
<PipGridLayout tracks={pagination.tracks} />
|
<PipFocusLayout
|
||||||
|
mainTrack={screenShareTrack}
|
||||||
|
thumbnailTrack={cameraTracks[0]}
|
||||||
|
/>
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
<PipPagination
|
)
|
||||||
totalPageCount={pagination.totalPageCount}
|
}
|
||||||
currentPage={pagination.currentPage}
|
// Multiple cameras: camera row at top, screen share below
|
||||||
nextPage={pagination.nextPage}
|
return (
|
||||||
prevPage={pagination.prevPage}
|
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||||
|
<PipScreenShareLayout
|
||||||
|
screenShareTrack={screenShareTrack}
|
||||||
|
cameraTracks={paginatedCameraTracks.tracks}
|
||||||
/>
|
/>
|
||||||
</StageWrapper>
|
</PaginatedStage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3+ camera tracks → adaptive grid
|
||||||
|
if (cameraTracks.length > 2) {
|
||||||
|
return (
|
||||||
|
<PaginatedStage pagination={pagination}>
|
||||||
|
<PipGridLayout tracks={pagination.tracks} />
|
||||||
|
</PaginatedStage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
|
||||||
const localCameraTrack = cameraTracks.find(
|
const localCameraTrack = cameraTracks.find(
|
||||||
(track) => track.participant?.isLocal
|
(track) => track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
|
||||||
const remoteCameraTrack = cameraTracks.find(
|
const remoteCameraTrack = cameraTracks.find(
|
||||||
(track) => !track.participant?.isLocal
|
(track) => !track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
const mainTrack = remoteCameraTrack ?? localCameraTrack
|
||||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
|
||||||
|
|
||||||
const thumbnailTrack =
|
const thumbnailTrack =
|
||||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||||
|
|
||||||
@@ -96,6 +104,31 @@ export const PipStage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginationResult = {
|
||||||
|
totalPageCount: number
|
||||||
|
currentPage: number
|
||||||
|
nextPage: () => void
|
||||||
|
prevPage: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginatedStage = ({
|
||||||
|
pagination,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
pagination: PaginationResult
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<StageWrapper>
|
||||||
|
<StageFrame>{children}</StageFrame>
|
||||||
|
<PipPagination
|
||||||
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
nextPage={pagination.nextPage}
|
||||||
|
prevPage={pagination.prevPage}
|
||||||
|
/>
|
||||||
|
</StageWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
const StageWrapper = styled('div', {
|
const StageWrapper = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useTrackToggle } 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 { localParticipant } = useLocalParticipant()
|
const { enabled: isScreenSharing } = useTrackToggle({
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
{...(!isScreenSharing ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -25,8 +28,8 @@ const Container = styled('div', {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
marginLeft: '0.5rem',
|
padding: '0.5rem',
|
||||||
marginRight: '0.5rem',
|
boxSizing: 'border-box',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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'
|
||||||
@@ -35,6 +34,7 @@ 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,8 +48,6 @@ 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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
pin_code: string
|
pin_code?: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -67,35 +68,36 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = ({
|
export const ActiveSpeaker = React.memo(
|
||||||
isSpeaking,
|
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
||||||
pushToTalk,
|
return (
|
||||||
}: ActiveSpeakerProps) => {
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
return (
|
<StyledChild
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '300ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '300ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
style={{
|
||||||
active={isSpeaking}
|
animationDelay: '100ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '100ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '500ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '500ms',
|
/>
|
||||||
}}
|
</StyledContainer>
|
||||||
/>
|
)
|
||||||
</StyledContainer>
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
|||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
|||||||
userChoices: LocalUserChoices
|
userChoices: LocalUserChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId, posthog])
|
||||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
roomId: roomId as string,
|
roomId: roomId as string,
|
||||||
username: userConfig.username,
|
username: username,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.statusCode == '404') {
|
if (error.statusCode == '404') {
|
||||||
createRoom({ slug: roomId, username: userConfig.username })
|
createRoom({ slug: roomId, username })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -167,7 +170,6 @@ 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
|
||||||
@@ -300,13 +302,7 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && !isMobile && (
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<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, type DialogProps, P, Bold } from '@/primitives'
|
import { Div, Button, 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 } from 'react'
|
import { useMemo, useState } 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,7 +39,9 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -58,163 +60,161 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
|
if (!showInviteDialog) return null
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog {...props}>
|
<StyledRACDialog>
|
||||||
{({ close }) => (
|
<VStack
|
||||||
<VStack
|
alignItems="left"
|
||||||
alignItems="left"
|
justify="start"
|
||||||
justify="start"
|
gap={0}
|
||||||
gap={0}
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
>
|
||||||
>
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
{t('heading')}
|
||||||
{t('heading')}
|
</Heading>
|
||||||
</Heading>
|
<Div position="absolute" top="5" right="5">
|
||||||
<Div position="absolute" top="5" right="5">
|
<Button
|
||||||
<Button
|
invisible
|
||||||
invisible
|
variant="tertiaryText"
|
||||||
variant="tertiaryText"
|
size="xs"
|
||||||
size="xs"
|
onPress={() => {
|
||||||
onPress={() => {
|
setShowInviteDialog(false)
|
||||||
props.onClose?.()
|
}}
|
||||||
close()
|
aria-label={t('closeDialog')}
|
||||||
}}
|
>
|
||||||
aria-label={t('closeDialog')}
|
<RiCloseLine />
|
||||||
>
|
</Button>
|
||||||
<RiCloseLine />
|
</Div>
|
||||||
</Button>
|
<P>{t('description')}</P>
|
||||||
</Div>
|
{isTelephonyReadyForUse ? (
|
||||||
<P>{t('description')}</P>
|
<div
|
||||||
{isTelephonyReadyForUse ? (
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
marginTop: '0.5rem',
|
justifyContent: 'space-between',
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<Text as="p" wrap="pretty">
|
||||||
className={css({
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
display: 'flex',
|
</Text>
|
||||||
alignItems: 'center',
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
justifyContent: 'space-between',
|
<Button
|
||||||
})}
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
>
|
square
|
||||||
<Text as="p" wrap="pretty">
|
size={'sm'}
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
onPress={copyRoomUrlToClipboard}
|
||||||
</Text>
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
<Button
|
>
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
{isRoomUrlCopied ? (
|
||||||
square
|
<RiCheckLine aria-hidden="true" />
|
||||||
size={'sm'}
|
) : (
|
||||||
onPress={copyRoomUrlToClipboard}
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
)}
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
</Button>
|
||||||
>
|
)}
|
||||||
{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>
|
</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
|
<Button
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
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 size={24} style={{ marginRight: '8px' }} />
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
<RiFileCopyLine
|
||||||
{t('copyUrl')}
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
) : (
|
||||||
<HStack>
|
<Button
|
||||||
<div
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
|
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({
|
||||||
backgroundColor: 'primary.200',
|
fill: 'primary.500',
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<RiSpam2Fill
|
</div>
|
||||||
size={22}
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
className={css({
|
{t('permissions')}
|
||||||
fill: 'primary.500',
|
</Text>
|
||||||
})}
|
</HStack>
|
||||||
/>
|
)}
|
||||||
</div>
|
</VStack>
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
|
||||||
{t('permissions')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ import {
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
saveAudioOutputDeviceId,
|
saveAudioOutputDeviceId,
|
||||||
saveUsername,
|
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveVideoInputEnabled,
|
saveVideoInputEnabled,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
|
|
||||||
|
import { saveUsername, userStore } from '@/stores/user'
|
||||||
|
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -114,6 +117,9 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
|
||||||
|
const { data: configData } = useConfig()
|
||||||
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
@@ -121,9 +127,10 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
} = useSnapshot(userChoicesStore)
|
} = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
|
|
||||||
if (initialUserChoices.current === null) {
|
if (initialUserChoices.current === null) {
|
||||||
@@ -134,7 +141,6 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,20 +454,23 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
{(!isLoggedIn ||
|
||||||
type="text"
|
configData?.authenticated_users_can_edit_display_name) && (
|
||||||
onChange={saveUsername}
|
<Field
|
||||||
label={t('usernameLabel')}
|
type="text"
|
||||||
id="input-name"
|
onChange={saveUsername}
|
||||||
defaultValue={username}
|
label={t('usernameLabel')}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
id="input-name"
|
||||||
wrapperProps={{
|
defaultValue={username || user?.full_name}
|
||||||
noMargin: true,
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
fullWidth: true,
|
wrapperProps={{
|
||||||
}}
|
noMargin: true,
|
||||||
autoComplete="name"
|
fullWidth: true,
|
||||||
maxLength={50}
|
}}
|
||||||
/>
|
autoComplete="name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
+19
-9
@@ -1,27 +1,35 @@
|
|||||||
|
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 { DisconnectReason, RoomEvent } from 'livekit-client'
|
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
|
import posthog from 'posthog-js'
|
||||||
|
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
|
||||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const useConnectionObserver = () => {
|
export const ConnectionObserver = () => {
|
||||||
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 =
|
||||||
useIsAdvancedConnectionObserverEnabled()
|
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
@@ -232,4 +240,6 @@ export const useConnectionObserver = () => {
|
|||||||
connectionStartTimeRef.current = null
|
connectionStartTimeRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
@@ -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,7 +94,6 @@ 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')}{' '}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
|
||||||
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%',
|
|
||||||
backgroundColor: 'primaryDark.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 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}>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user