mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdb5d447b0 |
@@ -8,55 +8,14 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
|
||||||
- ✨(frontend) add configurable documentation menu item
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
|
||||||
- 🐛(summary) properly detect when failure webhook should be sent
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
|
||||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.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
|
|
||||||
|
|
||||||
## [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
|
||||||
|
|
||||||
@@ -100,7 +59,6 @@ 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:22 AS mail-builder
|
FROM node:20 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ echo " - src/backend/pyproject.toml"
|
|||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
echo " - src/agents/uv.lock"
|
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "Next steps:"
|
print_warning "Next steps:"
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
|||||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
|||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||||
|
|
||||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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>1.0.0.0</Version>
|
<Version>0.0.2.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,174 +205,5 @@
|
|||||||
</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.4",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.4",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.4",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
data-i18n="footer.feedback"
|
data-i18n="footer.feedback"
|
||||||
></a>
|
></a>
|
||||||
<div id="footer-right">
|
<div id="footer-right">
|
||||||
<span class="version-number">1.0.0</span>
|
<span class="version-badge">beta</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.24.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.4",
|
"livekit-agents==1.6.4",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -402,14 +402,7 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = [
|
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||||
"status",
|
|
||||||
"=id",
|
|
||||||
"worker_id",
|
|
||||||
"room__slug",
|
|
||||||
"=room__id",
|
|
||||||
"accesses__user__email",
|
|
||||||
]
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ 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,7 +92,6 @@ 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
|
||||||
|
|
||||||
@@ -268,9 +267,6 @@ 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,
|
||||||
|
|||||||
@@ -130,9 +130,6 @@ 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",
|
||||||
@@ -165,9 +162,6 @@ 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",
|
||||||
|
|||||||
@@ -2,109 +2,13 @@
|
|||||||
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.factories import UserFactory
|
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||||
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")
|
||||||
|
|||||||
@@ -109,16 +109,11 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = user.full_name or str(user)
|
default_username = 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"],
|
||||||
@@ -126,7 +121,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(display_name)
|
.with_name(username or default_username)
|
||||||
.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"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -398,9 +398,6 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
"documentation_url": values.Value(
|
|
||||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
|
||||||
),
|
|
||||||
"external_home_url": values.Value(
|
"external_home_url": values.Value(
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||||
),
|
),
|
||||||
@@ -674,11 +671,6 @@ 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.24.0"
|
version = "1.23.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+54
-239
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,11 +15,10 @@
|
|||||||
"@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.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -29,14 +28,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.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.387.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.49.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.18.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.47.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"
|
||||||
@@ -64,7 +63,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -112,62 +110,6 @@
|
|||||||
"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",
|
||||||
@@ -1028,9 +970,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.46.6",
|
"version": "1.45.8",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1049,18 +991,12 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
|
||||||
"version": "0.10.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
|
||||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -1816,9 +1752,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.36.0",
|
"version": "3.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
||||||
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
||||||
"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"
|
||||||
@@ -2421,9 +2357,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2442,12 +2378,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.1"
|
"@tanstack/query-core": "5.101.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3256,19 +3192,6 @@
|
|||||||
"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",
|
||||||
@@ -7171,19 +7094,6 @@
|
|||||||
"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",
|
||||||
@@ -8125,13 +8035,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.20.0",
|
"version": "2.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.46.6",
|
"@livekit/protocol": "1.45.8",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -8765,19 +8675,6 @@
|
|||||||
"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",
|
||||||
@@ -9136,19 +9033,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.395.0",
|
"version": "1.387.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.387.0.tgz",
|
||||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
"integrity": "sha512-Pv1jUMySMN62zoAxdJBJPV8n62lkHdjuWhpeU7izczc5Dqbx3hhqO2hkrNTI8Yx1ezmWk2qUHZs03FuOBubdFQ==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.38.0",
|
"@posthog/core": "^1.33.0",
|
||||||
"@posthog/types": "^1.391.1",
|
"@posthog/types": "^1.387.0",
|
||||||
"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.29.2",
|
"preact": "^10.28.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0"
|
"web-vitals": "^5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -9165,21 +9062,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.29.7",
|
"version": "10.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||||
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||||
"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": {
|
||||||
@@ -9386,19 +9275,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria": {
|
"node_modules/react-aria": {
|
||||||
"version": "3.50.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||||
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||||
"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.36.0",
|
"@react-types/shared": "^3.35.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.48.0",
|
"react-stately": "3.47.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9407,17 +9296,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria-components": {
|
"node_modules/react-aria-components": {
|
||||||
"version": "1.19.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||||
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@react-types/shared": "^3.36.0",
|
"@react-types/shared": "^3.35.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.49.0",
|
||||||
"react-stately": "3.48.0"
|
"react-stately": "3.47.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",
|
||||||
@@ -9471,15 +9360,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-stately": {
|
"node_modules/react-stately": {
|
||||||
"version": "3.48.0",
|
"version": "3.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||||
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||||
"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.36.0",
|
"@react-types/shared": "^3.35.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"
|
||||||
},
|
},
|
||||||
@@ -11248,80 +11137,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -11655,9 +11470,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "5.3.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||||
"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.24.0",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,16 +17,15 @@
|
|||||||
"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",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@libreaudio/la-call": "0.1.4",
|
||||||
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@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.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -36,14 +35,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.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.387.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.49.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.18.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.47.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"
|
||||||
@@ -71,7 +70,6 @@
|
|||||||
"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,10 +114,6 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overlayIn: {
|
|
||||||
from: { opacity: 0 },
|
|
||||||
to: { opacity: 0.6 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -20,7 +20,6 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
documentation_url?: string
|
|
||||||
external_home_url?: string
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
@@ -59,7 +58,6 @@ 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,20 +7,36 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: { width: '40px', height: '40px' },
|
subtitles: {
|
||||||
list: { width: '32px', height: '32px' },
|
width: '40px',
|
||||||
placeholder: { width: '100%', height: '100%' },
|
height: '40px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: { border: '2px solid white' },
|
true: {
|
||||||
|
border: '2px solid white',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -33,35 +49,32 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = ({
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
name,
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
bgColor,
|
||||||
return (
|
context,
|
||||||
<div
|
notification,
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style,
|
||||||
className={avatar({ context, notification })}
|
...props
|
||||||
{...props}
|
}: AvatarProps) => {
|
||||||
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className={avatar({ context, notification })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({
|
||||||
|
marginTop: '-0.3rem',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<svg
|
{initial}
|
||||||
viewBox="0 0 100 100"
|
</span>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
)
|
||||||
>
|
}
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="50"
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize="52"
|
|
||||||
fontWeight="500"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Avatar.displayName = 'Avatar'
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { ChatMessages } from './ChatMessages'
|
|
||||||
import { ChatTextArea } from './ChatTextArea'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const ChatContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
flexGrow: 1,
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ChatMessagesContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TextContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0.75rem',
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Chat = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatContainer>
|
|
||||||
<TextContainer>
|
|
||||||
<Text variant="sm">{t('disclaimer')}</Text>
|
|
||||||
</TextContainer>
|
|
||||||
<ChatMessagesContainer>
|
|
||||||
<ChatMessages />
|
|
||||||
</ChatMessagesContainer>
|
|
||||||
<ChatTextArea />
|
|
||||||
</ChatContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { ChatRow } from '@/stores/chat'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
|
||||||
import { ChatMessageBody } from './ChatMessageBody'
|
|
||||||
|
|
||||||
const StyledContainer = styled('li', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageProps = {
|
|
||||||
item: ChatRow
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
|
||||||
const time = new Date(item.timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
return (
|
|
||||||
<StyledContainer
|
|
||||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
|
||||||
>
|
|
||||||
{!item.hideMetadata && (
|
|
||||||
<ChatMessageMetadata
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
identity={item.identity}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ChatMessageBody message={item.message} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
|
||||||
|
|
||||||
export const ChatMessageBody = React.memo(
|
|
||||||
({ message }: ChatMessageBodyProps) => {
|
|
||||||
const formattedMessage = useMemo(() => {
|
|
||||||
return formatChatMessageLinks(message)
|
|
||||||
}, [message])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
'& .lk-chat-link': {
|
|
||||||
color: 'blue',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{formattedMessage}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
const StyledContainer = styled('span', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
|
||||||
|
|
||||||
export const ChatMessageMetadata = React.memo(
|
|
||||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
|
||||||
const time = new Date(timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
|
|
||||||
const { names } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const currentDisplayName = useMemo(() => {
|
|
||||||
if (identity) return names[identity] ?? identity
|
|
||||||
}, [names, identity])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<Text bold={true} variant="sm">
|
|
||||||
{currentDisplayName}
|
|
||||||
</Text>
|
|
||||||
<Text variant="smNote" wrap="no">
|
|
||||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
|
||||||
</Text>
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
ListBox,
|
|
||||||
ListBoxItem,
|
|
||||||
ListLayout,
|
|
||||||
Virtualizer,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ChatMessage } from './ChatMessage.tsx'
|
|
||||||
import { useLayoutEffect, useRef } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
|
|
||||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
|
||||||
// rendered height of each row; this value is only used to size the
|
|
||||||
// scrollbar before rows have been measured.
|
|
||||||
const ESTIMATED_ROW_HEIGHT = 56
|
|
||||||
const BOTTOM_THRESHOLD_PX = 32
|
|
||||||
|
|
||||||
export const ChatMessages = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
const { rows: items } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
|
||||||
const stick = useRef(true)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = listRef.current
|
|
||||||
if (!el) return
|
|
||||||
const pin = () => {
|
|
||||||
if (stick.current) el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
const onScroll = () => {
|
|
||||||
stick.current =
|
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(pin)
|
|
||||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
|
||||||
// time an estimated row height is replaced by a measured one.
|
|
||||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
|
||||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
|
||||||
ro.observe(el)
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
|
||||||
pin()
|
|
||||||
return () => {
|
|
||||||
ro.disconnect()
|
|
||||||
el.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer
|
|
||||||
layout={ListLayout}
|
|
||||||
layoutOptions={{
|
|
||||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
|
||||||
gap: 4,
|
|
||||||
padding: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListBox
|
|
||||||
ref={listRef}
|
|
||||||
aria-label={t('messagesLabel', 'Chat messages')}
|
|
||||||
items={items}
|
|
||||||
selectionMode="none"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item: ChatRow) => (
|
|
||||||
<ListBoxItem style={{ width: '100%' }}>
|
|
||||||
<ChatMessage item={item} />
|
|
||||||
</ListBoxItem>
|
|
||||||
)}
|
|
||||||
</ListBox>
|
|
||||||
</Virtualizer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
|
||||||
import { ref } from 'valtio'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
|
||||||
import type { ChatMessage } from '@livekit/components-core'
|
|
||||||
import {
|
|
||||||
LocalParticipant,
|
|
||||||
Participant,
|
|
||||||
RemoteParticipant,
|
|
||||||
RoomEvent,
|
|
||||||
} from 'livekit-client'
|
|
||||||
|
|
||||||
export const ChatProvider = () => {
|
|
||||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
|
||||||
const { send, chatMessages, isSending } = useChat()
|
|
||||||
const { isChatOpen } = useSidePanel()
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetChatStore()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
|
||||||
useEffect(() => {
|
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
|
||||||
// (see https://github.com/livekit/components-js/issues/1158)
|
|
||||||
// Remove this once we refactor chat to use the new text stream approach
|
|
||||||
const latestMessage = chatMessages.slice(-1)[0]
|
|
||||||
if (!latestMessage) return
|
|
||||||
const from = latestMessage.from as
|
|
||||||
| RemoteParticipant
|
|
||||||
| LocalParticipant
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
|
||||||
}, [chatMessages, room])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
|
||||||
appendRow(chatMessages[i])
|
|
||||||
}
|
|
||||||
}, [chatMessages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.send = ref(send)
|
|
||||||
}, [send])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.isSending = isSending
|
|
||||||
}, [isSending])
|
|
||||||
|
|
||||||
// Set the unread messages count
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatMessages.length === 0) return
|
|
||||||
const last = chatMessages[chatMessages.length - 1]
|
|
||||||
if (isChatOpen) {
|
|
||||||
lastReadMsgAt.current = last.timestamp
|
|
||||||
chatStore.unreadMessages = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatStore.unreadMessages = chatMessages.filter(
|
|
||||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
|
||||||
).length
|
|
||||||
}, [chatMessages, isChatOpen])
|
|
||||||
|
|
||||||
// Listen to participant name changes
|
|
||||||
useEffect(() => {
|
|
||||||
const setName = (p: Participant) => {
|
|
||||||
chatStore.names[p.identity] = p.name || p.identity
|
|
||||||
}
|
|
||||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
|
||||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
|
||||||
|
|
||||||
type ChatSubmitButtonProps = {
|
|
||||||
handleSubmit: () => Promise<void>
|
|
||||||
isDisabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatSubmitButton = React.memo(
|
|
||||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
variant="tertiaryText"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t('button.label')}
|
|
||||||
>
|
|
||||||
<RiSendPlane2Fill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TextArea } from '@/primitives'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import React, { useCallback, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import {
|
|
||||||
chatStore,
|
|
||||||
clearTextAreaValue,
|
|
||||||
persistTextAreaValue,
|
|
||||||
} from '@/stores/chat'
|
|
||||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
margin: '0.75rem 0 1.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
backgroundColor: 'gray.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ChatTextArea = () => {
|
|
||||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = inputRef.current
|
|
||||||
if (!el) return
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
el.focus({ preventScroll: true })
|
|
||||||
const end = el.value.length
|
|
||||||
el.setSelectionRange(end, end)
|
|
||||||
})
|
|
||||||
return () => cancelAnimationFrame(raf)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
const text = chatStore.textAreaValue
|
|
||||||
if (!send || !text) return
|
|
||||||
await send(text)
|
|
||||||
inputRef?.current?.focus({ preventScroll: true })
|
|
||||||
clearTextAreaValue()
|
|
||||||
}, [send, inputRef])
|
|
||||||
|
|
||||||
const isDisabled = !textAreaValue.trim() || isSending
|
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
|
||||||
return
|
|
||||||
e.preventDefault()
|
|
||||||
await handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
value={textAreaValue}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onChange={(e) => {
|
|
||||||
persistTextAreaValue(e.target.value)
|
|
||||||
}}
|
|
||||||
fieldSizing={'content'}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
height: 'auto',
|
|
||||||
maxHeight: '240px',
|
|
||||||
minHeight: `34px`,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
padding: '7px 10px',
|
|
||||||
}}
|
|
||||||
placeholderStyle="strong"
|
|
||||||
spellCheck={false}
|
|
||||||
maxLength={2000}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
|
||||||
aria-label={t('textArea.label')}
|
|
||||||
/>
|
|
||||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,11 +9,10 @@ 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 { useSnapshot } from 'valtio'
|
import { loadUserChoices } from '@livekit/components-core'
|
||||||
import { userStore } from '@/stores/user'
|
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = useSnapshot(userStore)
|
const { username } = loadUserChoices()
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
|
||||||
|
|
||||||
interface CarouselLayoutState {
|
|
||||||
orientation: CarouselOrientation
|
|
||||||
maxVisibleTiles: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarouselLayoutObserverProps {
|
|
||||||
asideEl: React.RefObject<HTMLDivElement>
|
|
||||||
orientation?: CarouselOrientation
|
|
||||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselLayoutObserver = ({
|
|
||||||
orientation,
|
|
||||||
asideEl,
|
|
||||||
onLayoutChange,
|
|
||||||
}: CarouselLayoutObserverProps) => {
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
|
|
||||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
|
||||||
// container size hovers around a breakpoint. A ref (not state) because
|
|
||||||
// updating it must not trigger a re-render.
|
|
||||||
const prevTilesRef = React.useRef(0)
|
|
||||||
|
|
||||||
const carouselOrientation: CarouselOrientation =
|
|
||||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
|
||||||
|
|
||||||
const tileSpan =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
|
||||||
|
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const availableSpan =
|
|
||||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
|
||||||
|
|
||||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
|
|
||||||
let maxVisibleTiles: number
|
|
||||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
|
||||||
// Within the dead zone: keep the previous count.
|
|
||||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
|
||||||
} else {
|
|
||||||
maxVisibleTiles = Math.round(tilesThatFit)
|
|
||||||
prevTilesRef.current = tilesThatFit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply cosmetic layout output straight to the DOM.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = asideEl.current
|
|
||||||
if (!el) return
|
|
||||||
el.dataset.lkOrientation = carouselOrientation
|
|
||||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
|
||||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
|
||||||
|
|
||||||
// Report upward only what the parent actually needs for
|
|
||||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
|
||||||
useEffect(() => {
|
|
||||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
|
||||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -107,42 +40,52 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
const tileSpan =
|
||||||
orientation: orientation ?? 'vertical',
|
carouselOrientation === 'vertical'
|
||||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
})
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
// Stable callback + identity check: the parent only re-renders when the
|
const tilesThatFit =
|
||||||
// derived layout genuinely changed, not on every resize tick.
|
carouselOrientation === 'vertical'
|
||||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
setLayout((prev) =>
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
prev.orientation === next.orientation &&
|
|
||||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
|
||||||
? prev
|
|
||||||
: next
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
|
maxVisibleTiles = Math.round(prevTiles)
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside
|
||||||
<CarouselLayoutObserver
|
key={carouselOrientation}
|
||||||
asideEl={asideEl}
|
className="lk-carousel"
|
||||||
orientation={orientation}
|
ref={asideEl}
|
||||||
onLayoutChange={handleLayoutChange}
|
{...props}
|
||||||
/>
|
>
|
||||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
</aside>
|
||||||
<aside
|
|
||||||
key={layout.orientation}
|
|
||||||
className="lk-carousel"
|
|
||||||
ref={asideEl}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,36 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
|
||||||
|
|
||||||
interface GridLayoutObserverProps {
|
|
||||||
gridEl: React.RefObject<HTMLDivElement>
|
|
||||||
trackCount: number
|
|
||||||
onMaxTilesChange: (maxTiles: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that runs the layout calculation in isolation and
|
|
||||||
* reports the resulting tile capacity upward.
|
|
||||||
*
|
|
||||||
* `useGridLayout` re-renders its host on every layout recalculation
|
|
||||||
* (e.g. container resizes). Rendering it in a null child means only this
|
|
||||||
* component churns; the parent `GridLayout` re-renders solely when
|
|
||||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
|
||||||
*/
|
|
||||||
const GridLayoutObserver = ({
|
|
||||||
gridEl,
|
|
||||||
trackCount,
|
|
||||||
onMaxTilesChange,
|
|
||||||
}: GridLayoutObserverProps) => {
|
|
||||||
const { layout } = useGridLayout(gridEl, trackCount)
|
|
||||||
useEffect(() => {
|
|
||||||
onMaxTilesChange(layout.maxTiles)
|
|
||||||
}, [onMaxTilesChange, layout.maxTiles])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -67,15 +37,14 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = useRef<HTMLDivElement>(null)
|
const gridEl = React.createRef<HTMLDivElement>()
|
||||||
const [maxTiles, setMaxTiles] = useState(1)
|
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const pagination = usePagination(maxTiles, tracks)
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -88,13 +57,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
<GridLayoutObserver
|
|
||||||
gridEl={gridEl}
|
|
||||||
trackCount={tracks.length}
|
|
||||||
onMaxTilesChange={setMaxTiles}
|
|
||||||
/>
|
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > maxTiles && (
|
{tracks.length > layout.maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
|
||||||
|
|
||||||
export const PinAnnouncer = () => {
|
|
||||||
usePinAnnouncements()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
|
||||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
|
||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
|
||||||
log,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export const StageLayout = () => {
|
|
||||||
const lastAutoFocusedScreenShareTrack =
|
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
|
||||||
|
|
||||||
const tracks = useTracks(
|
|
||||||
[
|
|
||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
|
||||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
|
||||||
],
|
|
||||||
{ updateOnlyOn: [], onlySubscribed: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const screenShareTracks = tracks
|
|
||||||
.filter(isTrackReference)
|
|
||||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
|
|
||||||
const carouselTracks = tracks.filter(
|
|
||||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
|
||||||
)
|
|
||||||
|
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
|
||||||
useEffect(() => {
|
|
||||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
|
||||||
if (
|
|
||||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
|
||||||
lastAutoFocusedScreenShareTrack.current === null
|
|
||||||
) {
|
|
||||||
log.debug('Auto set screen share focus:', {
|
|
||||||
newScreenShareTrack: screenShareTracks[0],
|
|
||||||
})
|
|
||||||
setPinnedTrack(screenShareTracks[0])
|
|
||||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
|
||||||
} else if (
|
|
||||||
lastAutoFocusedScreenShareTrack.current &&
|
|
||||||
!screenShareTracks.some(
|
|
||||||
(track) =>
|
|
||||||
track.publication.trackSid ===
|
|
||||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
log.debug('Auto clearing screen share focus.')
|
|
||||||
clearPinnedTrack()
|
|
||||||
lastAutoFocusedScreenShareTrack.current = null
|
|
||||||
}
|
|
||||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
|
||||||
const updatedFocusTrack = tracks.find(
|
|
||||||
(tr) =>
|
|
||||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
|
||||||
tr.source === pinnedTrackRef.source
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
updatedFocusTrack !== pinnedTrackRef &&
|
|
||||||
isTrackReference(updatedFocusTrack)
|
|
||||||
) {
|
|
||||||
setPinnedTrack(updatedFocusTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
screenShareTracks
|
|
||||||
.map(
|
|
||||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
|
||||||
)
|
|
||||||
.join(),
|
|
||||||
pinnedTrackRef?.publication?.trackSid,
|
|
||||||
tracks,
|
|
||||||
])
|
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!pinnedTrackRef ? (
|
|
||||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
|
||||||
<ParticipantTile />
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParticipantTile />
|
|
||||||
</CarouselLayout>
|
|
||||||
<FocusLayout trackRef={pinnedTrackRef} />
|
|
||||||
</FocusLayoutContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
export const usePinAnnouncements = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const getAnnouncementName = useCallback(
|
|
||||||
(participant?: Participant | null) => {
|
|
||||||
if (!participant) return tRooms('participants.unknown')
|
|
||||||
return participant.isLocal
|
|
||||||
? tRooms('participants.you')
|
|
||||||
: getParticipantName(participant)
|
|
||||||
},
|
|
||||||
[tRooms]
|
|
||||||
)
|
|
||||||
|
|
||||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 1. unpin
|
|
||||||
if (!pinnedIdentity) {
|
|
||||||
const lastIdentity = lastPinnedIdentityRef.current
|
|
||||||
if (!lastIdentity) return
|
|
||||||
|
|
||||||
const lastParticipant =
|
|
||||||
room.localParticipant.identity === lastIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(lastIdentity)
|
|
||||||
|
|
||||||
announce(
|
|
||||||
lastParticipant?.isLocal
|
|
||||||
? t('self.unpin')
|
|
||||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
|
||||||
)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. same pin → do nothing
|
|
||||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
|
||||||
|
|
||||||
// 3. new pin
|
|
||||||
const participant =
|
|
||||||
room.localParticipant.identity === pinnedIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(pinnedIdentity)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = pinnedIdentity
|
|
||||||
|
|
||||||
announce(
|
|
||||||
participant?.isLocal
|
|
||||||
? t('self.pin')
|
|
||||||
: t('pin', { name: getAnnouncementName(participant) })
|
|
||||||
)
|
|
||||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { useEffect, useReducer, useRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tripwire for speaker promotion.
|
|
||||||
*
|
|
||||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
|
||||||
* re-render of the host component only when an active speaker has none of
|
|
||||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
|
||||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
|
||||||
* state and performs the actual swap.
|
|
||||||
*
|
|
||||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
|
||||||
*
|
|
||||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
|
||||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
|
||||||
*/
|
|
||||||
export function useSpeakerPromotionTrigger(
|
|
||||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
|
||||||
) {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
|
||||||
|
|
||||||
// Refs so the listener reads current values without re-subscribing
|
|
||||||
// and without itself being a render dependency.
|
|
||||||
const tilesRef = useRef(sortedTiles)
|
|
||||||
tilesRef.current = sortedTiles
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
|
||||||
const tiles = tilesRef.current
|
|
||||||
const hiddenSpeakerExists = speakers.some(
|
|
||||||
(speaker) =>
|
|
||||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
|
||||||
)
|
|
||||||
if (hiddenSpeakerExists) {
|
|
||||||
forceRender()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
state,
|
state,
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
@@ -37,13 +38,18 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
source: Source.Camera,
|
source: Source.Camera,
|
||||||
}
|
}
|
||||||
|
const pinParticipant = () => {
|
||||||
|
layoutContext?.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference,
|
||||||
|
})
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
setPinnedTrack(trackReference)
|
pinParticipant()
|
||||||
closeButtonProps.onPress?.(e)
|
closeButtonProps.onPress?.(e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
ConnectionQualityIndicator,
|
|
||||||
LockLockedIcon,
|
|
||||||
ScreenShareIcon,
|
|
||||||
useIsEncrypted,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
|
||||||
import { ParticipantName } from './ParticipantName'
|
|
||||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
|
||||||
|
|
||||||
export const ParticipantMetadata = ({
|
|
||||||
displayedName,
|
|
||||||
participant,
|
|
||||||
isScreenShare,
|
|
||||||
}: {
|
|
||||||
displayedName: string
|
|
||||||
participant: Participant
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const isEncrypted = useIsEncrypted(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lk-participant-metadata">
|
|
||||||
<HStack gap={0.25}>
|
|
||||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
|
||||||
<RaisedHandMetadataWrapper
|
|
||||||
participant={participant}
|
|
||||||
enabled={!isScreenShare}
|
|
||||||
>
|
|
||||||
{isScreenShare && (
|
|
||||||
<ScreenShareIcon
|
|
||||||
style={{
|
|
||||||
maxWidth: '20px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
displayedName={displayedName}
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</RaisedHandMetadataWrapper>
|
|
||||||
</HStack>
|
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { CSSProperties } from 'react'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const participantNameStyles: CSSProperties = {
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantNameScreenShareStyles: CSSProperties = {
|
|
||||||
...participantNameStyles,
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantName = React.memo(
|
|
||||||
({
|
|
||||||
displayedName,
|
|
||||||
isScreenShare = false,
|
|
||||||
}: {
|
|
||||||
displayedName?: string
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
|
||||||
if (isScreenShare) {
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
|
||||||
{displayedName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantName.displayName = 'ParticipantName'
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
containerType: 'size',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledAvatarWrapper = styled('div', {
|
|
||||||
base: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
aspectRatio: '1 / 1',
|
|
||||||
width: 'min(90cqmin, 160px)',
|
|
||||||
fontSize: 'min(27cqmin, 48px)',
|
|
||||||
'[data-lk-speaking="true"] &': {
|
|
||||||
animation: 'pulse 1s infinite',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
|
||||||
color: string
|
|
||||||
displayedNamed: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantPlaceholder = React.memo(
|
|
||||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
|
||||||
const backgroundGradient = useMemo(
|
|
||||||
() => getParticipantBackgroundGradient(color),
|
|
||||||
[color]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<StyledParticipantPlaceHolder
|
|
||||||
style={{
|
|
||||||
backgroundColor: color,
|
|
||||||
backgroundImage: backgroundGradient,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StyledAvatarWrapper>
|
|
||||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
|
||||||
</StyledAvatarWrapper>
|
|
||||||
</StyledParticipantPlaceHolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
useRaisedHand,
|
|
||||||
useRaisedHandPosition,
|
|
||||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { RiHand } from '@remixicon/react'
|
|
||||||
|
|
||||||
const PositionInQueue = React.memo(
|
|
||||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
|
||||||
if (!positionInQueue) return
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
|
||||||
>
|
|
||||||
<span>{positionInQueue}</span>
|
|
||||||
<RiHand
|
|
||||||
color="black"
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
marginLeft: '0.1rem',
|
|
||||||
minWidth: '16px',
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationName: 'wave_hand',
|
|
||||||
animationIterationCount: '2',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
PositionInQueue.displayName = 'PositionInQueue'
|
|
||||||
|
|
||||||
const RaisedHandActiveItem = ({
|
|
||||||
participant,
|
|
||||||
targetRef,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
targetRef: RefObject<HTMLElement | null>
|
|
||||||
}) => {
|
|
||||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
|
||||||
participant,
|
|
||||||
})
|
|
||||||
useEffect(() => {
|
|
||||||
const el = targetRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
|
||||||
el.style.color = 'black'
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
el.style.backgroundColor = ''
|
|
||||||
el.style.color = ''
|
|
||||||
}
|
|
||||||
}, [targetRef, firstInQueue])
|
|
||||||
|
|
||||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RaisedHandMetadataWrapper = ({
|
|
||||||
participant,
|
|
||||||
enabled = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
enabled?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) => {
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const { isHandRaised } = useRaisedHand({ participant })
|
|
||||||
const showHand = isHandRaised && enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="lk-participant-metadata-item"
|
|
||||||
style={{
|
|
||||||
padding: '0.1rem 0.25rem',
|
|
||||||
transition: 'background 200ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showHand && (
|
|
||||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const EffectsButton = React.memo(() => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
tooltip={t('effects')}
|
|
||||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
EffectsButton.displayName = 'EffectsButton'
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const FocusButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
|
||||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
|
||||||
>
|
|
||||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
|
||||||
participant: participant,
|
|
||||||
source: Track.Source.Microphone,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
isDisabled={isMuted}
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
onPress={() => setIsAlertOpen(true)}
|
|
||||||
tooltip={t('muteParticipant', { name })}
|
|
||||||
>
|
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|
||||||
import { FocusButton } from './FocusButton'
|
|
||||||
import { EffectsButton } from './EffectsButton'
|
|
||||||
import { MuteButton } from './MuteButton'
|
|
||||||
import { ZoomButton } from './ZoomButton'
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
|
||||||
children: ReactNode
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|
||||||
const [active, setActive] = useState(false)
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
|
||||||
clearIdleTimer()
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setActive(false)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
|
||||||
setActive(true)
|
|
||||||
armIdleTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearIdleTimer, [])
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '0',
|
|
||||||
top: '0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
})}
|
|
||||||
data-visible={isVisible || undefined}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
onMouseEnter={handleActivity}
|
|
||||||
onMouseMove={handleActivity}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isVisible && children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
|
||||||
trackRef,
|
|
||||||
hasKeyboardFocus,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}) => {
|
|
||||||
const participant = trackRef.participant
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
|
||||||
const isLocal = participant.isLocal
|
|
||||||
const canMute = useCanMute(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
opacity: 0.6,
|
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
|
||||||
_hover: {
|
|
||||||
opacity: 0.95,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HStack gap={0.5} padding={0.5}>
|
|
||||||
<FocusButton trackRef={trackRef} />
|
|
||||||
{!isScreenShare ? (
|
|
||||||
<>
|
|
||||||
{isLocal ? (
|
|
||||||
<EffectsButton />
|
|
||||||
) : (
|
|
||||||
canMute && <MuteButton participant={participant} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</FadeOverlay>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiFullscreenLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { ReactNode, useId, useState } from 'react'
|
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
|
||||||
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'greyscale.250',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 .625rem 0.9375rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Header = styled('button', {
|
|
||||||
base: {
|
|
||||||
minHeight: '2.5rem',
|
|
||||||
paddingX: '1.25rem',
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
gap: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
fontSize: '1rem',
|
|
||||||
transition: 'background 200ms',
|
|
||||||
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
|
||||||
_hover: { backgroundColor: 'greyscale.50' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { borderRadius: '7px' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Chevron = styled(RiArrowUpSLine, {
|
|
||||||
base: { transition: 'transform 200ms', flexShrink: 0 },
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { transform: 'rotate(180deg)' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const List = styled(VStack, {
|
|
||||||
base: {
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
alignItems: 'start',
|
|
||||||
minHeight: 0,
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
paddingX: '1rem',
|
|
||||||
gap: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ParticipantsCollapsibleSectionProps = {
|
|
||||||
heading: string
|
|
||||||
count: number
|
|
||||||
action?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsCollapsibleSection = ({
|
|
||||||
heading,
|
|
||||||
count,
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
}: ParticipantsCollapsibleSectionProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(true)
|
|
||||||
const listId = useId()
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header
|
|
||||||
type="button"
|
|
||||||
isOpen={isOpen}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls={listId}
|
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
<HStack justify="space-between" width="100%">
|
|
||||||
<span>{heading}</span>
|
|
||||||
<span>{count}</span>
|
|
||||||
</HStack>
|
|
||||||
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
|
||||||
</Header>
|
|
||||||
{isOpen && (
|
|
||||||
<List id={listId} role="list">
|
|
||||||
{action}
|
|
||||||
{children}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useRemoteParticipants } from '@livekit/components-react'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiInfinityLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
const badgeStyles = css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-.25rem',
|
|
||||||
right: '-.25rem',
|
|
||||||
width: '1.25rem',
|
|
||||||
height: '1.25rem',
|
|
||||||
backgroundColor: 'gray',
|
|
||||||
borderRadius: '50%',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Isolated so participant join/leave events only re-render the badge,
|
|
||||||
* not the button, tooltip or shortcut registration.
|
|
||||||
*/
|
|
||||||
export const ParticipantsCount = React.memo(
|
|
||||||
({ describedById }: { describedById: string }) => {
|
|
||||||
const { t } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'controls.participants',
|
|
||||||
})
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [],
|
|
||||||
})
|
|
||||||
const count = (remoteParticipants?.length ?? 0) + 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span id={describedById} className={srOnly}>
|
|
||||||
{t('count', { count })}
|
|
||||||
</span>
|
|
||||||
<div className={badgeStyles} aria-hidden>
|
|
||||||
{count < 100 ? count : <RiInfinityLine size={10} />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ParticipantsCount.displayName = 'ParticipantsCount'
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import {
|
|
||||||
useRemoteParticipants,
|
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import { Div, H } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantRow } from './ParticipantRow'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
|
||||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
|
||||||
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
|
||||||
import { RaisedHandRow } from './RaisedHandRow'
|
|
||||||
|
|
||||||
const JoinedParticipantsSections = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
|
||||||
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
|
||||||
// are room-wide and fire for the local participant too, so this hook re-emits
|
|
||||||
// (and we re-render) on local changes, despite its name.
|
|
||||||
//
|
|
||||||
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
|
||||||
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
|
||||||
// local events waking this component up, raising your own hand would not
|
|
||||||
// update the UI. Do not "optimise" these away.
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [
|
|
||||||
RoomEvent.ParticipantNameChanged,
|
|
||||||
RoomEvent.ParticipantAttributesChanged,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const allParticipants = [
|
|
||||||
room.localParticipant,
|
|
||||||
...[...remoteParticipants].sort((a, b) =>
|
|
||||||
(a.name || a.identity).localeCompare(b.name || b.identity)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const participantsWithRaisedHands = allParticipants
|
|
||||||
.filter((p) => !!p.attributes.handRaisedAt)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
|
||||||
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
|
||||||
return (
|
|
||||||
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
|
||||||
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{participantsWithRaisedHands.length > 0 && (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('raisedHands')}
|
|
||||||
count={participantsWithRaisedHands.length}
|
|
||||||
action={
|
|
||||||
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{participantsWithRaisedHands.map((p) => (
|
|
||||||
<RaisedHandRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)}
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('contributors')}
|
|
||||||
count={allParticipants.length}
|
|
||||||
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
|
||||||
>
|
|
||||||
{allParticipants.map((p) => (
|
|
||||||
<ParticipantRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsList = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Div overflowY="scroll">
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
className={css({
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'greyscale.600',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
marginBottom: '0.83em',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('subheading').toUpperCase()}
|
|
||||||
</H>
|
|
||||||
<WaitingParticipantsSection />
|
|
||||||
<JoinedParticipantsSections />
|
|
||||||
</Div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { RiPushpin2Fill } from '@remixicon/react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
|
|
||||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
|
||||||
const cameraTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const screenShareTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.ScreenShare),
|
|
||||||
source: Source.ScreenShare,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus =
|
|
||||||
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
|
||||||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
|
||||||
|
|
||||||
if (!inFocus) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
height: '18px',
|
|
||||||
width: '18px',
|
|
||||||
borderRadius: '100%',
|
|
||||||
background: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-2px',
|
|
||||||
right: '-4px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiPushpin2Fill size={14} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
|
||||||
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
|
||||||
|
|
||||||
export const WaitingParticipantsSection = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
|
||||||
|
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
|
||||||
useWaitingParticipants()
|
|
||||||
|
|
||||||
if (waitingParticipants?.length == 0) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('title')}
|
|
||||||
count={waitingParticipants.length}
|
|
||||||
>
|
|
||||||
{waitingParticipants.map((p) => (
|
|
||||||
<WaitingParticipantRow
|
|
||||||
key={p.id}
|
|
||||||
participant={p}
|
|
||||||
onAction={handleParticipantEntry}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
@@ -26,7 +26,6 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(mainTrack)}
|
key={getTrackKey(mainTrack)}
|
||||||
trackRef={mainTrack}
|
trackRef={mainTrack}
|
||||||
disableTileControls
|
|
||||||
/>
|
/>
|
||||||
</MainSlot>
|
</MainSlot>
|
||||||
)}
|
)}
|
||||||
@@ -35,7 +34,6 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(thumbnailTrack)}
|
key={getTrackKey(thumbnailTrack)}
|
||||||
trackRef={thumbnailTrack}
|
trackRef={thumbnailTrack}
|
||||||
disableTileControls
|
|
||||||
/>
|
/>
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
)}
|
)}
|
||||||
@@ -50,10 +48,9 @@ const FocusContainer = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '8px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,8 +58,6 @@ 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',
|
||||||
},
|
},
|
||||||
@@ -72,13 +67,13 @@ const MainSlot = styled('div', {
|
|||||||
const Thumbnail = styled('div', {
|
const Thumbnail = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '1.25rem',
|
right: '1rem',
|
||||||
bottom: '1.25rem',
|
bottom: '1rem',
|
||||||
width: '42%',
|
width: '42%',
|
||||||
maxWidth: '220px',
|
maxWidth: '220px',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
borderRadius: '8px',
|
borderRadius: '4px',
|
||||||
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/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
@@ -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} disableTileControls />
|
<ParticipantTile trackRef={track} />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
@@ -54,8 +54,7 @@ const GridContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.5rem',
|
gap: '0.25rem',
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ const GridCell = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: '8px',
|
borderRadius: '4px',
|
||||||
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.
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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,11 +1,10 @@
|
|||||||
import React, { useMemo } from 'react'
|
import { 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 {
|
||||||
@@ -14,12 +13,9 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PipStage picks between three layouts:
|
* PipStage picks between two layouts based on track count:
|
||||||
* - Screen share mode (any screen share active):
|
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
||||||
* small camera tiles in a row at the top, large screen share below.
|
* - Grid mode (3+ tracks): adaptive tiling.
|
||||||
* - 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(
|
||||||
@@ -45,55 +41,51 @@ export const PipStage = () => {
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
// Grid mode order: screen share leads, then cameras (already ordered by
|
||||||
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
// active speaker via the `ActiveSpeakersChanged` update above).
|
||||||
|
const gridTracks = useMemo(
|
||||||
|
() =>
|
||||||
|
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
||||||
|
[screenShareTrack, cameraTracks]
|
||||||
|
)
|
||||||
|
|
||||||
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
||||||
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
// page visually stable (active/recent speakers stay put) via its internal
|
||||||
|
// `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
|
/**
|
||||||
if (screenShareTrack) {
|
* The focus layout shows one main track with one thumbnail overlay,
|
||||||
// Solo presenter: screen share fills the area, camera as small thumbnail
|
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
||||||
if (cameraTracks.length <= 1) {
|
*/
|
||||||
return (
|
if (gridTracks.length > 2) {
|
||||||
|
return (
|
||||||
|
<StageWrapper>
|
||||||
<StageFrame>
|
<StageFrame>
|
||||||
<PipFocusLayout
|
<PipGridLayout tracks={pagination.tracks} />
|
||||||
mainTrack={screenShareTrack}
|
|
||||||
thumbnailTrack={cameraTracks[0]}
|
|
||||||
/>
|
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
)
|
<PipPagination
|
||||||
}
|
totalPageCount={pagination.totalPageCount}
|
||||||
// Multiple cameras: camera row at top, screen share below
|
currentPage={pagination.currentPage}
|
||||||
return (
|
nextPage={pagination.nextPage}
|
||||||
<PaginatedStage pagination={paginatedCameraTracks}>
|
prevPage={pagination.prevPage}
|
||||||
<PipScreenShareLayout
|
|
||||||
screenShareTrack={screenShareTrack}
|
|
||||||
cameraTracks={paginatedCameraTracks.tracks}
|
|
||||||
/>
|
/>
|
||||||
</PaginatedStage>
|
</StageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
@@ -104,31 +96,6 @@ 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,21 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useTrackToggle } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
|
|
||||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'pictureInPicture',
|
keyPrefix: 'pictureInPicture',
|
||||||
})
|
})
|
||||||
const { enabled: isScreenSharing } = useTrackToggle({
|
const { localParticipant } = useLocalParticipant()
|
||||||
source: Track.Source.ScreenShare,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!isScreenSharing ? { inert: '' } : {})}
|
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -28,8 +25,8 @@ const Container = styled('div', {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
padding: '0.5rem',
|
marginLeft: '0.5rem',
|
||||||
boxSizing: 'border-box',
|
marginRight: '0.5rem',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
|
|||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useSettingsDialog,
|
||||||
SettingsDialogExtendedKey,
|
SettingsDialogExtendedKey,
|
||||||
useTranscriptionLanguage,
|
useTranscriptionLanguage,
|
||||||
} from '@/features/settings'
|
} from '@/features/settings'
|
||||||
@@ -34,7 +35,6 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -48,6 +48,8 @@ export const TranscriptSidePanel = () => {
|
|||||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||||
useTranscriptionLanguage()
|
useTranscriptionLanguage()
|
||||||
|
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -68,36 +67,35 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = React.memo(
|
export const ActiveSpeaker = ({
|
||||||
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
isSpeaking,
|
||||||
return (
|
pushToTalk,
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
}: ActiveSpeakerProps) => {
|
||||||
<StyledChild
|
return (
|
||||||
pushToTalk={pushToTalk}
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '300ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '300ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
style={{
|
pushToTalk={pushToTalk}
|
||||||
animationDelay: '100ms',
|
active={isSpeaking}
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '100ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '500ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '500ms',
|
||||||
</StyledContainer>
|
}}
|
||||||
)
|
/>
|
||||||
}
|
</StyledContainer>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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,
|
||||||
@@ -54,8 +53,6 @@ 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])
|
||||||
@@ -86,10 +83,10 @@ export const Conference = ({
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
roomId: roomId as string,
|
roomId: roomId as string,
|
||||||
username: username,
|
username: userConfig.username,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.statusCode == '404') {
|
if (error.statusCode == '404') {
|
||||||
createRoom({ slug: roomId, username })
|
createRoom({ slug: roomId, username: userConfig.username })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -170,6 +167,7 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
kind: MediaDeviceKind | null
|
kind: MediaDeviceKind | null
|
||||||
@@ -302,7 +300,13 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{showInviteDialog && !isMobile && (
|
||||||
|
<InviteDialog
|
||||||
|
isOpen={showInviteDialog}
|
||||||
|
onOpenChange={setShowInviteDialog}
|
||||||
|
onClose={() => setShowInviteDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
{...mediaDeviceError}
|
{...mediaDeviceError}
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, P, Bold } from '@/primitives'
|
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -39,9 +39,7 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -60,161 +58,163 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
if (!showInviteDialog) return null
|
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog>
|
<StyledRACDialog {...props}>
|
||||||
<VStack
|
{({ close }) => (
|
||||||
alignItems="left"
|
<VStack
|
||||||
justify="start"
|
alignItems="left"
|
||||||
gap={0}
|
justify="start"
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
gap={0}
|
||||||
>
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
>
|
||||||
{t('heading')}
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
</Heading>
|
{t('heading')}
|
||||||
<Div position="absolute" top="5" right="5">
|
</Heading>
|
||||||
<Button
|
<Div position="absolute" top="5" right="5">
|
||||||
invisible
|
<Button
|
||||||
variant="tertiaryText"
|
invisible
|
||||||
size="xs"
|
variant="tertiaryText"
|
||||||
onPress={() => {
|
size="xs"
|
||||||
setShowInviteDialog(false)
|
onPress={() => {
|
||||||
}}
|
props.onClose?.()
|
||||||
aria-label={t('closeDialog')}
|
close()
|
||||||
>
|
}}
|
||||||
<RiCloseLine />
|
aria-label={t('closeDialog')}
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
<P>{t('description')}</P>
|
|
||||||
{isTelephonyReadyForUse ? (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<RiCloseLine />
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
</Button>
|
||||||
</Text>
|
</Div>
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
<P>{t('description')}</P>
|
||||||
<Button
|
{isTelephonyReadyForUse ? (
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
|
||||||
square
|
|
||||||
size={'sm'}
|
|
||||||
onPress={copyRoomUrlToClipboard}
|
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
>
|
|
||||||
{isRoomUrlCopied ? (
|
|
||||||
<RiCheckLine aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<div
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
className={css({
|
||||||
{telephony?.internationalPhoneNumber}
|
display: 'flex',
|
||||||
</Text>
|
alignItems: 'center',
|
||||||
<Text as="p" wrap="pretty">
|
justifyContent: 'space-between',
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
})}
|
||||||
{formatPinCode(roomData?.pin_code)}
|
>
|
||||||
</Text>
|
<Text as="p" wrap="pretty">
|
||||||
</div>
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? (
|
||||||
|
<RiCheckLine aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
size="sm"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
{t('copyUrl')}
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
) : (
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
<Button
|
<HStack>
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
<div
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copyUrl')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
|
||||||
<HStack>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primary.200',
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiSpam2Fill
|
|
||||||
size={22}
|
|
||||||
className={css({
|
className={css({
|
||||||
fill: 'primary.500',
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
</div>
|
<RiSpam2Fill
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
size={22}
|
||||||
{t('permissions')}
|
className={css({
|
||||||
</Text>
|
fill: 'primary.500',
|
||||||
</HStack>
|
})}
|
||||||
)}
|
/>
|
||||||
</VStack>
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,17 +42,14 @@ 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)
|
||||||
|
|
||||||
@@ -117,9 +114,6 @@ 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,
|
||||||
@@ -127,10 +121,9 @@ 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) {
|
||||||
@@ -141,6 +134,7 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
|
username,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,23 +448,20 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
{(!isLoggedIn ||
|
<Field
|
||||||
configData?.authenticated_users_can_edit_display_name) && (
|
type="text"
|
||||||
<Field
|
onChange={saveUsername}
|
||||||
type="text"
|
label={t('usernameLabel')}
|
||||||
onChange={saveUsername}
|
id="input-name"
|
||||||
label={t('usernameLabel')}
|
defaultValue={username}
|
||||||
id="input-name"
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
defaultValue={username || user?.full_name}
|
wrapperProps={{
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
noMargin: true,
|
||||||
wrapperProps={{
|
fullWidth: true,
|
||||||
noMargin: true,
|
}}
|
||||||
fullWidth: true,
|
autoComplete="name"
|
||||||
}}
|
maxLength={50}
|
||||||
autoComplete="name"
|
/>
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { ParticipantTile } from './ParticipantTile'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
+5
-6
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
|||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import { useSize } from '../hooks/useResizeObserver'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { LocalParticipant } from 'livekit-client'
|
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -17,6 +17,7 @@ export const FullScreenShareWarning = ({
|
|||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
@@ -56,10 +57,8 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
const participant = trackReference.participant
|
if (!localParticipant.isScreenShareEnabled) return
|
||||||
if (!(participant instanceof LocalParticipant)) return
|
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||||
if (!participant.isScreenShareEnabled) return
|
|
||||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
@@ -94,6 +94,7 @@ const Description = () => {
|
|||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
return (
|
return (
|
||||||
<P>
|
<P>
|
||||||
{t('settingsPrefix')}{' '}
|
{t('settingsPrefix')}{' '}
|
||||||
|
|||||||
+7
-9
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,17 +25,15 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
hint?: string
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||||
* typically when an element receives keyboard focus.
|
* typically when an element receives keyboard focus.
|
||||||
*/
|
*/
|
||||||
export const KeyboardShortcutHint = React.memo(
|
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||||
({ hint }: KeyboardShortcutHintProps) => {
|
children,
|
||||||
return <Hint>{hint}</Hint>
|
}) => {
|
||||||
}
|
return <Hint>{children}</Hint>
|
||||||
)
|
}
|
||||||
|
|
||||||
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { PinMenuItem } from './items/PinMenuItem'
|
import { PinMenuItem } from './PinMenuItem'
|
||||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
import { RemoveMenuItem } from './RemoveMenuItem'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
+4
-17
@@ -1,28 +1,15 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
const trackRef = useMemo(() => {
|
const { toggle, inFocus } = useFocusToggleParticipant(participant)
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -30,7 +17,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
onAction={toggle}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useParticipantInfo } from '@livekit/components-react'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
const participantNameStyles: CSSProperties = {
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantNameScreenShareStyles: CSSProperties = {
|
||||||
|
...participantNameStyles,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantName = ({
|
||||||
|
participant,
|
||||||
|
isScreenShare = false,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||||
|
|
||||||
|
const { identity, name } = useParticipantInfo({ participant })
|
||||||
|
const displayedName = name != '' ? name : identity
|
||||||
|
|
||||||
|
if (isScreenShare) {
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||||
|
{t('screenShare', { name: displayedName })}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||||
|
{displayedName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+104
-34
@@ -1,32 +1,39 @@
|
|||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
LockLockedIcon,
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
|
ScreenShareIcon,
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
|
useIsEncrypted,
|
||||||
|
useMaybeLayoutContext,
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
useParticipantInfo,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
|
isTrackReferencePinned,
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
|
||||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
@@ -45,7 +52,6 @@ export function TrackRefContextIfNeeded(
|
|||||||
|
|
||||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||||
disableMetadata?: boolean
|
disableMetadata?: boolean
|
||||||
disableTileControls?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
@@ -60,18 +66,21 @@ 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(
|
||||||
@@ -79,25 +88,28 @@ export const ParticipantTile: (
|
|||||||
if (
|
if (
|
||||||
trackReference.source &&
|
trackReference.source &&
|
||||||
!subscribed &&
|
!subscribed &&
|
||||||
layoutStore.pinnedTrackRef &&
|
layoutContext &&
|
||||||
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
layoutContext.pin.dispatch &&
|
||||||
|
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||||
) {
|
) {
|
||||||
clearPinnedTrack()
|
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackReference]
|
[trackReference, layoutContext]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { isHandRaised } = useRaisedHand({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantName = getParticipantName(trackReference.participant)
|
||||||
|
|
||||||
const { identity, name } = useParticipantInfo({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
const participantName = name || identity || 'Unknown'
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
@@ -124,9 +136,7 @@ export const ParticipantTile: (
|
|||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
|
||||||
)}
|
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{isTrackReference(trackReference) &&
|
||||||
@@ -148,20 +158,80 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
participant={trackReference.participant}
|
||||||
displayedNamed={participantName}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<ParticipantMetadata
|
<div className="lk-participant-metadata">
|
||||||
displayedName={participantName}
|
<HStack gap={0.25}>
|
||||||
isScreenShare={isScreenShare}
|
{!isScreenShare && (
|
||||||
participant={trackReference.participant}
|
<MutedMicIndicator
|
||||||
/>
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="lk-participant-metadata-item"
|
||||||
|
style={{
|
||||||
|
padding: '0.1rem 0.25rem',
|
||||||
|
backgroundColor:
|
||||||
|
isHandRaised && !isScreenShare
|
||||||
|
? firstInQueue
|
||||||
|
? '#fde047'
|
||||||
|
: 'white'
|
||||||
|
: undefined,
|
||||||
|
color:
|
||||||
|
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||||
|
transition: 'background 200ms ease, color 400ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isHandRaised && !isScreenShare && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{positionInQueue}</span>
|
||||||
|
<RiHand
|
||||||
|
color="black"
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
marginLeft: '0.1rem',
|
||||||
|
minWidth: '16px',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isScreenShare && (
|
||||||
|
<ScreenShareIcon
|
||||||
|
style={{
|
||||||
|
maxWidth: '20px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEncrypted && !isScreenShare && (
|
||||||
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
@@ -169,13 +239,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint
|
<KeyboardShortcutHint>
|
||||||
hint={t('toolbarHint', {
|
{t('toolbarHint', {
|
||||||
shortcut: formatShortcutLabel(
|
shortcut: formatShortcutLabel(
|
||||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
/>
|
</KeyboardShortcutHint>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import {
|
||||||
|
RiFullscreenLine,
|
||||||
|
RiImageCircleAiFill,
|
||||||
|
RiMicLine,
|
||||||
|
RiMicOffLine,
|
||||||
|
RiPushpin2Line,
|
||||||
|
RiUnpinLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import {
|
||||||
|
useFocusToggle,
|
||||||
|
useTrackMutedIndicator,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { useFullScreen } from '../hooks/useFullScreen'
|
||||||
|
import { type Participant, Track } from 'livekit-client'
|
||||||
|
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
|
|
||||||
|
const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FocusButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { mergedProps, inFocus } = useFocusToggle({
|
||||||
|
trackRef,
|
||||||
|
props: {},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
||||||
|
>
|
||||||
|
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EffectsButton = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
tooltip={t('effects')}
|
||||||
|
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||||
|
>
|
||||||
|
<RiImageCircleAiFill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
participant: participant,
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
isDisabled={isMuted}
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
onPress={() => setIsAlertOpen(true)}
|
||||||
|
tooltip={t('muteParticipant', { name })}
|
||||||
|
>
|
||||||
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
|
export const ParticipantTileFocus = ({
|
||||||
|
trackRef,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}) => {
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const [opacity, setOpacity] = useState(0)
|
||||||
|
|
||||||
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
// Wait for next frame to ensure element is mounted
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setOpacity(0.6)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setOpacity(0)
|
||||||
|
}
|
||||||
|
}, [isVisible])
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsIdleRef(true)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
setIsIdleRef(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = trackRef.participant
|
||||||
|
|
||||||
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
|
const isLocal = trackRef.participant.isLocal
|
||||||
|
|
||||||
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
})}
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
>
|
||||||
|
{isVisible && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
transition: 'opacity 200ms linear',
|
||||||
|
zIndex: 1,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
_hover: {
|
||||||
|
opacity: '0.95 !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
style={{ opacity }}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={0.5}
|
||||||
|
className={css({
|
||||||
|
padding: '0.5rem',
|
||||||
|
_hover: {
|
||||||
|
opacity: '1 !important',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FocusButton trackRef={trackRef} />
|
||||||
|
{!isScreenShare ? (
|
||||||
|
<>
|
||||||
|
{participant.isLocal ? (
|
||||||
|
<EffectsButton />
|
||||||
|
) : (
|
||||||
|
canMute && <MuteButton participant={participant} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
|
||||||
|
|
||||||
export const RoomMetadataSynchronizer = () => {
|
|
||||||
useSyncLiveKitMetadata()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,16 @@ import { text } from '@/primitives/Text'
|
|||||||
import { Button, Div } from '@/primitives'
|
import { Button, Div } from '@/primitives'
|
||||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import React, { ReactNode, useCallback, useRef } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Chat } from '@/features/chat/components/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -30,115 +29,103 @@ type StyledSidePanelProps = {
|
|||||||
isReactionToolbarOpen?: boolean
|
isReactionToolbarOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
const StyledSidePanel = ({
|
||||||
(
|
title,
|
||||||
{
|
ariaLabel,
|
||||||
title,
|
children,
|
||||||
ariaLabel,
|
onClose,
|
||||||
children,
|
isClosed,
|
||||||
onClose,
|
isReactionToolbarOpen,
|
||||||
isClosed,
|
closeButtonTooltip,
|
||||||
isReactionToolbarOpen,
|
isSubmenu = false,
|
||||||
closeButtonTooltip,
|
onBack,
|
||||||
isSubmenu = false,
|
backButtonLabel,
|
||||||
onBack,
|
}: StyledSidePanelProps) => (
|
||||||
backButtonLabel,
|
<aside
|
||||||
},
|
className={css({
|
||||||
ref
|
borderWidth: '1px',
|
||||||
) => (
|
borderStyle: 'solid',
|
||||||
<aside
|
borderColor: 'box.border',
|
||||||
ref={ref}
|
backgroundColor: 'box.bg',
|
||||||
tabIndex={-1}
|
color: 'box.text',
|
||||||
className={css({
|
borderRadius: 8,
|
||||||
borderWidth: '1px',
|
flex: 1,
|
||||||
borderStyle: 'solid',
|
position: 'absolute',
|
||||||
borderColor: 'box.border',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'box.bg',
|
display: 'flex',
|
||||||
color: 'box.text',
|
flexDirection: 'column',
|
||||||
borderRadius: 8,
|
margin: 'var(--sizes-room-side-panel-margin)',
|
||||||
flex: 1,
|
marginLeft: 0,
|
||||||
position: 'absolute',
|
marginBottom: 0,
|
||||||
overflow: 'hidden',
|
padding: 0,
|
||||||
display: 'flex',
|
gap: 0,
|
||||||
flexDirection: 'column',
|
right: 0,
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
top: 0,
|
||||||
marginLeft: 0,
|
width: 'var(--sizes-room-side-panel)',
|
||||||
marginBottom: 0,
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
padding: 0,
|
})}
|
||||||
gap: 0,
|
style={{
|
||||||
right: 0,
|
transform: isClosed
|
||||||
top: 0,
|
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||||
width: 'var(--sizes-room-side-panel)',
|
: 'none',
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
bottom: isReactionToolbarOpen
|
||||||
'&:focus': {
|
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||||
outline: 'none',
|
: 'var(--sizes-room-control-bar)',
|
||||||
},
|
}}
|
||||||
})}
|
aria-hidden={isClosed}
|
||||||
style={{
|
aria-label={ariaLabel}
|
||||||
transform: isClosed
|
>
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
<HStack alignItems="center">
|
||||||
: 'none',
|
{isSubmenu && (
|
||||||
bottom: isReactionToolbarOpen
|
<Button
|
||||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
variant="secondaryText"
|
||||||
: 'var(--sizes-room-control-bar)',
|
size="sm"
|
||||||
}}
|
square
|
||||||
aria-hidden={isClosed}
|
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||||
aria-label={ariaLabel}
|
aria-label={backButtonLabel}
|
||||||
>
|
onPress={onBack}
|
||||||
<HStack alignItems="center">
|
|
||||||
{isSubmenu && (
|
|
||||||
<Button
|
|
||||||
variant="secondaryText"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
|
||||||
aria-label={backButtonLabel}
|
|
||||||
onPress={onBack}
|
|
||||||
>
|
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Heading
|
|
||||||
slot="title"
|
|
||||||
level={1}
|
|
||||||
className={text({ variant: 'h2' })}
|
|
||||||
style={{
|
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : 'flex',
|
|
||||||
justifyContent: 'start',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{title}
|
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||||
</Heading>
|
</Button>
|
||||||
</HStack>
|
)}
|
||||||
<Div
|
<Heading
|
||||||
position="absolute"
|
slot="title"
|
||||||
top="5"
|
level={1}
|
||||||
right="5"
|
className={text({ variant: 'h2' })}
|
||||||
style={{
|
style={{
|
||||||
display: isClosed ? 'none' : undefined,
|
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
display: isClosed ? 'none' : 'flex',
|
||||||
|
justifyContent: 'start',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
{title}
|
||||||
invisible
|
</Heading>
|
||||||
variant="tertiaryText"
|
</HStack>
|
||||||
size="xs"
|
<Div
|
||||||
onPress={onClose}
|
position="absolute"
|
||||||
aria-label={closeButtonTooltip}
|
top="5"
|
||||||
tooltip={closeButtonTooltip}
|
right="5"
|
||||||
>
|
style={{
|
||||||
<RiCloseLine />
|
display: isClosed ? 'none' : undefined,
|
||||||
</Button>
|
}}
|
||||||
</Div>
|
>
|
||||||
{children}
|
<Button
|
||||||
</aside>
|
invisible
|
||||||
)
|
variant="tertiaryText"
|
||||||
|
size="xs"
|
||||||
|
onPress={onClose}
|
||||||
|
aria-label={closeButtonTooltip}
|
||||||
|
tooltip={closeButtonTooltip}
|
||||||
|
>
|
||||||
|
<RiCloseLine />
|
||||||
|
</Button>
|
||||||
|
</Div>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledSidePanel.displayName = 'StyledSidePanel'
|
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -175,28 +162,8 @@ export const SidePanel = () => {
|
|||||||
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
const asideRef = useRef<HTMLElement>(null)
|
|
||||||
|
|
||||||
const focusAside = useCallback(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
asideRef.current?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePanelOpened = useCallback(() => {
|
|
||||||
if (activePanelId === PanelId.CHAT) return
|
|
||||||
focusAside()
|
|
||||||
}, [activePanelId, focusAside])
|
|
||||||
|
|
||||||
useRestoreFocus(isSidePanelOpen, {
|
|
||||||
onOpened: handlePanelOpened,
|
|
||||||
preventScroll: true,
|
|
||||||
activeKey: activePanelId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
ref={asideRef}
|
|
||||||
title={title}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel', { title })}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -218,7 +185,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isEffectsOpen}>
|
<Panel isOpen={isEffectsOpen}>
|
||||||
<Effects />
|
<Effects />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isChatOpen}>
|
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||||
|
|||||||
@@ -95,13 +95,8 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const {
|
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||||
openTranscript,
|
useSidePanel()
|
||||||
openScreenRecording,
|
|
||||||
activeSubPanelId,
|
|
||||||
isToolsOpen,
|
|
||||||
isSidePanelOpen,
|
|
||||||
} = useSidePanel()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||||
|
|
||||||
// Restore focus to the element that opened the Tools panel
|
// Restore focus to the element that opened the Tools panel
|
||||||
@@ -118,7 +113,6 @@ export const Tools = () => {
|
|||||||
},
|
},
|
||||||
restoreFocusRaf: true,
|
restoreFocusRaf: true,
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||||
|
|||||||
+5
-4
@@ -13,10 +13,8 @@ import {
|
|||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import {
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
SELFIE_SEGMENTER_MODEL_PATH,
|
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
type ProcessorType,
|
type ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
@@ -155,10 +153,13 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initSegmenter() {
|
async initSegmenter() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
const vision = await FilesetResolver.forVisionTasks(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||||
|
)
|
||||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
modelAssetPath:
|
||||||
|
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
||||||
delegate: 'CPU', // Use CPU for Firefox.
|
delegate: 'CPU', // Use CPU for Firefox.
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
TIMEOUT_TICK,
|
TIMEOUT_TICK,
|
||||||
timerWorkerScript,
|
timerWorkerScript,
|
||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import { ProcessorType } from '.'
|
||||||
FACE_LANDMARKS_MODEL_PATH,
|
|
||||||
ProcessorType,
|
|
||||||
MEDIAPIPE_PATH_WASM,
|
|
||||||
} from '.'
|
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256 * 3
|
const PROCESSING_WIDTH = 256 * 3
|
||||||
const PROCESSING_HEIGHT = 144 * 3
|
const PROCESSING_HEIGHT = 144 * 3
|
||||||
@@ -132,10 +128,13 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initFaceLandmarker() {
|
async initFaceLandmarker() {
|
||||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
const vision = await FilesetResolver.forVisionTasks(
|
||||||
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||||
|
)
|
||||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||||
baseOptions: {
|
baseOptions: {
|
||||||
modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
|
modelAssetPath:
|
||||||
|
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
||||||
delegate: 'GPU',
|
delegate: 'GPU',
|
||||||
},
|
},
|
||||||
runningMode: 'VIDEO',
|
runningMode: 'VIDEO',
|
||||||
|
|||||||
+4
-19
@@ -1,14 +1,13 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
|
BackgroundBlur,
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
BackgroundProcessor,
|
VirtualBackground,
|
||||||
} from '@livekit/track-processors'
|
} from '@livekit/track-processors'
|
||||||
import {
|
import {
|
||||||
type ProcessorConfig,
|
type ProcessorConfig,
|
||||||
BackgroundProcessorInterface,
|
BackgroundProcessorInterface,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
|
||||||
SELFIE_SEGMENTER_MODEL_PATH,
|
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
@@ -21,24 +20,10 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
|
|
||||||
if (opts.type === 'virtual') {
|
if (opts.type === 'virtual') {
|
||||||
this.processorType = ProcessorType.VIRTUAL
|
this.processorType = ProcessorType.VIRTUAL
|
||||||
this.processor = BackgroundProcessor({
|
this.processor = VirtualBackground(opts.imagePath)
|
||||||
mode: 'virtual-background',
|
|
||||||
imagePath: opts.imagePath,
|
|
||||||
assetPaths: {
|
|
||||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
|
||||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (opts.type === 'blur') {
|
} else if (opts.type === 'blur') {
|
||||||
this.processorType = ProcessorType.BLUR
|
this.processorType = ProcessorType.BLUR
|
||||||
this.processor = BackgroundProcessor({
|
this.processor = BackgroundBlur(opts.blurRadius)
|
||||||
mode: 'background-blur',
|
|
||||||
blurRadius: opts.blurRadius,
|
|
||||||
assetPaths: {
|
|
||||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
|
||||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Must provide either imagePath for virtual background or blurRadius for blur'
|
'Must provide either imagePath for virtual background or blurRadius for blur'
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
|||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||||
|
|
||||||
export const SELFIE_SEGMENTER_MODEL_PATH =
|
|
||||||
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
|
|
||||||
|
|
||||||
export const FACE_LANDMARKS_MODEL_PATH =
|
|
||||||
'/assets/mediapipe/models/face_landmarker.task'
|
|
||||||
|
|
||||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
VIRTUAL = 'virtual',
|
VIRTUAL = 'virtual',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ReceivedChatMessage } from '@livekit/components-core'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import type { MessageFormatter } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||||
|
entry: ReceivedChatMessage
|
||||||
|
hideMetadata?: boolean
|
||||||
|
messageFormatter?: MessageFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatEntry: (
|
||||||
|
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
||||||
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
ChatEntryProps
|
||||||
|
>(function ChatEntry(
|
||||||
|
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const formattedMessage = React.useMemo(() => {
|
||||||
|
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||||
|
}, [entry.message, messageFormatter])
|
||||||
|
const time = new Date(entry.timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
})}
|
||||||
|
ref={ref}
|
||||||
|
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||||
|
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{!hideMetadata && (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text bold={true} variant="sm">
|
||||||
|
{entry.from?.name ?? entry.from?.identity}
|
||||||
|
</Text>
|
||||||
|
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
||||||
|
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
'& .lk-chat-link': {
|
||||||
|
color: 'blue',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedMessage}
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Button, TextArea } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
import { useState, useEffect, RefObject } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
const MAX_ROWS = 6
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
inputRef: RefObject<HTMLTextAreaElement>
|
||||||
|
onSubmit: (text: string) => void
|
||||||
|
isSending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInput = ({
|
||||||
|
inputRef,
|
||||||
|
onSubmit,
|
||||||
|
isSending,
|
||||||
|
}: ChatInputProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [rows, setRows] = useState(1)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit(text)
|
||||||
|
setText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = !text.trim() || isSending
|
||||||
|
|
||||||
|
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDisabled) handleSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resize = () => {
|
||||||
|
if (!inputRef.current) return
|
||||||
|
|
||||||
|
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
||||||
|
const previousRows = inputRef.current.rows
|
||||||
|
inputRef.current.rows = 1
|
||||||
|
|
||||||
|
const currentRows = Math.floor(
|
||||||
|
inputRef.current.scrollHeight / textAreaLineHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentRows === previousRows) {
|
||||||
|
inputRef.current.rows = currentRows
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRows >= MAX_ROWS) {
|
||||||
|
inputRef.current.rows = MAX_ROWS
|
||||||
|
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRows < MAX_ROWS) {
|
||||||
|
inputRef.current.style.overflowY = 'hidden'
|
||||||
|
} else {
|
||||||
|
inputRef.current.style.overflowY = 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize()
|
||||||
|
}, [text, inputRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
className={css({
|
||||||
|
margin: '0.75rem 0 1.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
submitOnEnter(e)
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => e.stopPropagation()}
|
||||||
|
placeholder={t('textArea.placeholder')}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value)
|
||||||
|
}}
|
||||||
|
rows={rows || 1}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
height: 'auto',
|
||||||
|
minHeight: `34px`,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
padding: '7px 10px',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
}}
|
||||||
|
placeholderStyle={'strong'}
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={2000}
|
||||||
|
aria-label={t('textArea.label')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={t('button.label')}
|
||||||
|
>
|
||||||
|
<RiSendPlane2Fill />
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-1
@@ -2,7 +2,7 @@ import { Button } from '@/primitives'
|
|||||||
import { RiSettings3Line } from '@remixicon/react'
|
import { RiSettings3Line } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
export const SettingsButton = ({
|
export const SettingsButton = ({
|
||||||
settingTab,
|
settingTab,
|
||||||
@@ -12,6 +12,7 @@ export const SettingsButton = ({
|
|||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import useLongPress from '@/features/shortcuts/useLongPress'
|
|||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
import {
|
import {
|
||||||
useIsSpeaking,
|
useIsSpeaking,
|
||||||
|
useLocalParticipant,
|
||||||
useMaybeRoomContext,
|
useMaybeRoomContext,
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
@@ -169,7 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActiveSpeakerWrapper = () => {
|
const ActiveSpeakerWrapper = () => {
|
||||||
const room = useRoomContext()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
const isSpeaking = useIsSpeaking(localParticipant)
|
||||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||||
}
|
}
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
import { RiBookOpenLine } from '@remixicon/react'
|
|
||||||
import { MenuItem } from 'react-aria-components'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
|
|
||||||
export const DocumentationMenuItem = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
|
||||||
const { data } = useConfig()
|
|
||||||
|
|
||||||
if (!data?.documentation_url) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
href={data.documentation_url}
|
|
||||||
target="_blank"
|
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
|
||||||
>
|
|
||||||
<RiBookOpenLine size={20} />
|
|
||||||
{t('documentation')}
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user