mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d4bcad949 | |||
| 4d64ba5ea9 | |||
| 74f63388e6 | |||
| d70e6cf81b | |||
| 525bc38644 | |||
| 7f7f819fb2 | |||
| fdc4d43bf4 | |||
| 44702426bf | |||
| 14de928619 | |||
| df62b37046 | |||
| 6047682fb1 | |||
| 3e1ccba91b | |||
| ba0b6b84ce | |||
| 83d562dbdf | |||
| 25a013ae9b | |||
| 1a28cb52b8 | |||
| 8d4de7d289 | |||
| a3afe02b04 | |||
| 3a4bbd061f | |||
| 43e6275396 | |||
| a89fff9f52 | |||
| 59159d8967 | |||
| 40012b3cd3 | |||
| 43b973af25 | |||
| 6e7d3f10e1 | |||
| 2380ad82af | |||
| 158ea5adf2 | |||
| be033ae6c9 | |||
| 02088608c9 | |||
| 7691e262d6 | |||
| 129893ecf9 | |||
| 9f5e69a35a | |||
| fcc2f51b85 | |||
| 80cf70388b | |||
| 059ea935b7 | |||
| 430e638f62 | |||
| 0285870ccb | |||
| 89d1c74f7b | |||
| 7cc16303d9 | |||
| 0079aa8176 | |||
| c32254fa51 | |||
| 0dd3faf010 | |||
| 1d7644df61 | |||
| b92f9fb24d | |||
| 78c9607cb2 | |||
| 40e83ca98a | |||
| b7f6a18897 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 |
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
|
|||||||
@@ -10,6 +10,57 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(summary) report exception type in failure analytics
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
|
- 🐛(summary) properly detect when failure webhook should be sent
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||||
|
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||||
|
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||||
|
|
||||||
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
|
- ✨(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
|
||||||
|
|
||||||
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
|
- ⬆️(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
|
||||||
|
|
||||||
|
- 🩹(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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- ✨(backend) extend analytics module to support feature flags
|
- ✨(backend) extend analytics module to support feature flags
|
||||||
- ✨(backend) implement feature flags in Posthog analytics backend
|
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||||
- ✨(agents) report errors to Sentry for all LiveKit agents
|
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||||
@@ -23,6 +74,12 @@ and this project adheres to
|
|||||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||||
- 🔥(summary) remove call to summary enabled feature flag
|
- 🔥(summary) remove call to summary enabled feature flag
|
||||||
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||||
|
- ⬆️(frontend) update the frontend build image to Node 22
|
||||||
|
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||||
|
- ✨(summary) more precise analytics events
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -42,6 +99,7 @@ and this project adheres to
|
|||||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
- 🩹(frontend) enable screen share button in PiP #1458
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:22 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
|||||||
(Note : in your development environment, you can `make migrate`.)
|
(Note : in your development environment, you can `make migrate`.)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## v1.23.0
|
||||||
|
|
||||||
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|
||||||
|
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||||
|
|
||||||
|
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|
||||||
|
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||||
|
|
||||||
|
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
|||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
|
# Run uv lock in agents
|
||||||
|
print_info "Running uv lock in agents..."
|
||||||
|
cd "src/agents"
|
||||||
|
uv lock
|
||||||
|
cd -
|
||||||
|
|
||||||
# Update CHANGELOG
|
# Update CHANGELOG
|
||||||
print_info "Updating CHANGELOG..."
|
print_info "Updating CHANGELOG..."
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
|||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
|
echo " - src/agents/uv.lock"
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "Next steps:"
|
print_warning "Next steps:"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
|||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
|||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||||
|
|
||||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||||
<Version>0.0.2.0</Version>
|
<Version>1.0.0.0</Version>
|
||||||
<ProviderName>__APP_NAME__</ProviderName>
|
<ProviderName>__APP_NAME__</ProviderName>
|
||||||
<DefaultLocale>fr-FR</DefaultLocale>
|
<DefaultLocale>fr-FR</DefaultLocale>
|
||||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||||
@@ -205,5 +205,174 @@
|
|||||||
</bt:String>
|
</bt:String>
|
||||||
</bt:LongStrings>
|
</bt:LongStrings>
|
||||||
</Resources>
|
</Resources>
|
||||||
|
|
||||||
|
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
|
||||||
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
|
||||||
|
<Requirements>
|
||||||
|
<bt:Sets DefaultMinVersion="1.8">
|
||||||
|
<bt:Set Name="Mailbox"/>
|
||||||
|
</bt:Sets>
|
||||||
|
</Requirements>
|
||||||
|
<Hosts>
|
||||||
|
<Host xsi:type="MailHost">
|
||||||
|
<DesktopFormFactor>
|
||||||
|
<FunctionFile resid="Commands.Url"/>
|
||||||
|
<SupportsSharedFolders>true</SupportsSharedFolders>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgReadGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||||
|
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="apptComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||||
|
<Label resid="OpenSettings.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="OpenSettings.Label"/>
|
||||||
|
<Description resid="OpenSettings.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
</DesktopFormFactor>
|
||||||
|
</Host>
|
||||||
|
</Hosts>
|
||||||
|
<Resources>
|
||||||
|
<bt:Images>
|
||||||
|
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
|
||||||
|
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
|
||||||
|
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
|
||||||
|
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
|
||||||
|
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
|
||||||
|
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
|
||||||
|
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
|
||||||
|
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
|
||||||
|
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
|
||||||
|
</bt:Images>
|
||||||
|
<bt:Urls>
|
||||||
|
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
|
||||||
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
|
||||||
|
</bt:Urls>
|
||||||
|
<bt:ShortStrings>
|
||||||
|
<!-- Default (French) -->
|
||||||
|
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||||
|
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
|
||||||
|
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Open settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen"/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:ShortStrings>
|
||||||
|
<bt:LongStrings>
|
||||||
|
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:LongStrings>
|
||||||
|
</Resources>
|
||||||
|
</VersionOverrides>
|
||||||
</VersionOverrides>
|
</VersionOverrides>
|
||||||
</OfficeApp>
|
</OfficeApp>
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.1",
|
"i18next": "^26.3.2",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.1",
|
"version": "26.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
||||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.2",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
data-i18n="footer.feedback"
|
data-i18n="footer.feedback"
|
||||||
></a>
|
></a>
|
||||||
<div id="footer-right">
|
<div id="footer-right">
|
||||||
<span class="version-badge">beta</span>
|
<span class="version-number">1.0.0</span>
|
||||||
<span class="version-number">0.0.2</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.4",
|
"livekit-agents==1.6.4",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = [
|
||||||
|
"status",
|
||||||
|
"=id",
|
||||||
|
"worker_id",
|
||||||
|
"room__slug",
|
||||||
|
"=room__id",
|
||||||
|
"accesses__user__email",
|
||||||
|
]
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
|||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -267,6 +268,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
|
|||||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
|||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"external_api": True,
|
"external_api": True,
|
||||||
"auth_method": auth_method,
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
|
|||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -109,11 +109,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,7 +126,7 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.with_attributes(
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
@@ -670,6 +671,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -1128,6 +1134,20 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
):
|
||||||
|
warnings.warn(
|
||||||
|
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||||
|
"The legacy v1 API has been removed from the experimental "
|
||||||
|
"Summary service. Please update your Summary service deployment to "
|
||||||
|
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||||
|
# We use UserWarning to make sure it shows up in production deployment
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||||
if cls.SENTRY_DSN is not None:
|
if cls.SENTRY_DSN is not None:
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
Generated
+239
-54
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,10 +15,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -28,14 +29,14 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,6 +112,62 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"aria-hidden": "^1.2.3",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
|
"react-aria": "3.49.0",
|
||||||
|
"react-stately": "3.47.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
|
||||||
|
"version": "3.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||||
|
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -970,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.45.8",
|
"version": "1.46.6",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -991,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
|
"version": "0.10.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||||
|
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -1752,9 +1816,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.35.0",
|
"version": "3.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||||
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -2357,9 +2421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2378,12 +2442,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.100.14"
|
"@tanstack/query-core": "5.101.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3192,6 +3256,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||||
@@ -7094,6 +7171,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-boolean-object": {
|
"node_modules/is-boolean-object": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||||
@@ -8035,13 +8125,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.19.0",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.45.8",
|
"@livekit/protocol": "1.46.6",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -8675,6 +8765,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9033,19 +9136,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.386.5",
|
"version": "1.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||||
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
|
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.32.3",
|
"@posthog/core": "^1.38.0",
|
||||||
"@posthog/types": "^1.386.3",
|
"@posthog/types": "^1.391.1",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.28.2",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -9062,13 +9165,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.28.3",
|
"version": "10.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
@@ -9275,19 +9386,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria": {
|
"node_modules/react-aria": {
|
||||||
"version": "3.49.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"aria-hidden": "^1.2.3",
|
"aria-hidden": "^1.2.3",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9296,17 +9407,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria-components": {
|
"node_modules/react-aria-components": {
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-stately": "3.47.0"
|
"react-stately": "3.48.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
@@ -9360,15 +9471,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-stately": {
|
"node_modules/react-stately": {
|
||||||
"version": "3.47.0",
|
"version": "3.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -11137,6 +11248,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sapphi-red"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-svgr": {
|
"node_modules/vite-plugin-svgr": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||||
@@ -11470,9 +11655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "5.1.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,15 +17,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -35,14 +36,14 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
overlayIn: {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 0.6 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: {
|
subtitles: { width: '40px', height: '40px' },
|
||||||
width: '40px',
|
list: { width: '32px', height: '32px' },
|
||||||
height: '40px',
|
placeholder: { width: '100%', height: '100%' },
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: {
|
true: { border: '2px solid white' },
|
||||||
border: '2px solid white',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -49,32 +33,35 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = React.memo(
|
||||||
name,
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
bgColor,
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
context,
|
return (
|
||||||
notification,
|
<div
|
||||||
style,
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
...props
|
className={avatar({ context, notification })}
|
||||||
}: AvatarProps) => {
|
{...props}
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
className={avatar({ context, notification })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={css({
|
|
||||||
marginTop: '-0.3rem',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{initial}
|
<svg
|
||||||
</span>
|
viewBox="0 0 100 100"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
)
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
}
|
>
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="50"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize="52"
|
||||||
|
fontWeight="500"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Avatar.displayName = 'Avatar'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { ChatMessages } from './ChatMessages'
|
||||||
|
import { ChatTextArea } from './ChatTextArea'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const ChatContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChatMessagesContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContainer>
|
||||||
|
<TextContainer>
|
||||||
|
<Text variant="sm">{t('disclaimer')}</Text>
|
||||||
|
</TextContainer>
|
||||||
|
<ChatMessagesContainer>
|
||||||
|
<ChatMessages />
|
||||||
|
</ChatMessagesContainer>
|
||||||
|
<ChatTextArea />
|
||||||
|
</ChatContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ChatRow } from '@/stores/chat'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||||
|
import { ChatMessageBody } from './ChatMessageBody'
|
||||||
|
|
||||||
|
const StyledContainer = styled('li', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageProps = {
|
||||||
|
item: ChatRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||||
|
const time = new Date(item.timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
return (
|
||||||
|
<StyledContainer
|
||||||
|
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||||
|
>
|
||||||
|
{!item.hideMetadata && (
|
||||||
|
<ChatMessageMetadata
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
identity={item.identity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChatMessageBody message={item.message} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ChatRow } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||||
|
|
||||||
|
export const ChatMessageBody = React.memo(
|
||||||
|
({ message }: ChatMessageBodyProps) => {
|
||||||
|
const formattedMessage = useMemo(() => {
|
||||||
|
return formatChatMessageLinks(message)
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
'& .lk-chat-link': {
|
||||||
|
color: 'blue',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedMessage}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
const StyledContainer = styled('span', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||||
|
|
||||||
|
export const ChatMessageMetadata = React.memo(
|
||||||
|
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||||
|
const time = new Date(timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
|
||||||
|
const { names } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const currentDisplayName = useMemo(() => {
|
||||||
|
if (identity) return names[identity] ?? identity
|
||||||
|
}, [names, identity])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<Text bold={true} variant="sm">
|
||||||
|
{currentDisplayName}
|
||||||
|
</Text>
|
||||||
|
<Text variant="smNote" wrap="no">
|
||||||
|
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
ListBox,
|
||||||
|
ListBoxItem,
|
||||||
|
ListLayout,
|
||||||
|
Virtualizer,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ChatMessage } from './ChatMessage.tsx'
|
||||||
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||||
|
// rendered height of each row; this value is only used to size the
|
||||||
|
// scrollbar before rows have been measured.
|
||||||
|
const ESTIMATED_ROW_HEIGHT = 56
|
||||||
|
const BOTTOM_THRESHOLD_PX = 32
|
||||||
|
|
||||||
|
export const ChatMessages = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
const { rows: items } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const stick = useRef(true)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = listRef.current
|
||||||
|
if (!el) return
|
||||||
|
const pin = () => {
|
||||||
|
if (stick.current) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
const onScroll = () => {
|
||||||
|
stick.current =
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(pin)
|
||||||
|
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||||
|
// time an estimated row height is replaced by a measured one.
|
||||||
|
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||||
|
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||||
|
ro.observe(el)
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
pin()
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtualizer
|
||||||
|
layout={ListLayout}
|
||||||
|
layoutOptions={{
|
||||||
|
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||||
|
gap: 4,
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListBox
|
||||||
|
ref={listRef}
|
||||||
|
aria-label={t('messagesLabel', 'Chat messages')}
|
||||||
|
items={items}
|
||||||
|
selectionMode="none"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item: ChatRow) => (
|
||||||
|
<ListBoxItem style={{ width: '100%' }}>
|
||||||
|
<ChatMessage item={item} />
|
||||||
|
</ListBoxItem>
|
||||||
|
)}
|
||||||
|
</ListBox>
|
||||||
|
</Virtualizer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||||
|
import { ref } from 'valtio'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||||
|
import type { ChatMessage } from '@livekit/components-core'
|
||||||
|
import {
|
||||||
|
LocalParticipant,
|
||||||
|
Participant,
|
||||||
|
RemoteParticipant,
|
||||||
|
RoomEvent,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
|
export const ChatProvider = () => {
|
||||||
|
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||||
|
const { send, chatMessages, isSending } = useChat()
|
||||||
|
const { isChatOpen } = useSidePanel()
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetChatStore()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Tigger the message notification (temporary)
|
||||||
|
useEffect(() => {
|
||||||
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
// (see https://github.com/livekit/components-js/issues/1158)
|
||||||
|
// Remove this once we refactor chat to use the new text stream approach
|
||||||
|
const latestMessage = chatMessages.slice(-1)[0]
|
||||||
|
if (!latestMessage) return
|
||||||
|
const from = latestMessage.from as
|
||||||
|
| RemoteParticipant
|
||||||
|
| LocalParticipant
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||||
|
}, [chatMessages, room])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||||
|
appendRow(chatMessages[i])
|
||||||
|
}
|
||||||
|
}, [chatMessages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.send = ref(send)
|
||||||
|
}, [send])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.isSending = isSending
|
||||||
|
}, [isSending])
|
||||||
|
|
||||||
|
// Set the unread messages count
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatMessages.length === 0) return
|
||||||
|
const last = chatMessages[chatMessages.length - 1]
|
||||||
|
if (isChatOpen) {
|
||||||
|
lastReadMsgAt.current = last.timestamp
|
||||||
|
chatStore.unreadMessages = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatStore.unreadMessages = chatMessages.filter(
|
||||||
|
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||||
|
).length
|
||||||
|
}, [chatMessages, isChatOpen])
|
||||||
|
|
||||||
|
// Listen to participant name changes
|
||||||
|
useEffect(() => {
|
||||||
|
const setName = (p: Participant) => {
|
||||||
|
chatStore.names[p.identity] = p.name || p.identity
|
||||||
|
}
|
||||||
|
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||||
|
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
|
||||||
|
type ChatSubmitButtonProps = {
|
||||||
|
handleSubmit: () => Promise<void>
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatSubmitButton = React.memo(
|
||||||
|
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={t('button.label')}
|
||||||
|
>
|
||||||
|
<RiSendPlane2Fill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { TextArea } from '@/primitives'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import {
|
||||||
|
chatStore,
|
||||||
|
clearTextAreaValue,
|
||||||
|
persistTextAreaValue,
|
||||||
|
} from '@/stores/chat'
|
||||||
|
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||||
|
|
||||||
|
const StyledContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: '0.75rem 0 1.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChatTextArea = () => {
|
||||||
|
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
|
||||||
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current
|
||||||
|
if (!el) return
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
el.focus({ preventScroll: true })
|
||||||
|
const end = el.value.length
|
||||||
|
el.setSelectionRange(end, end)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const text = chatStore.textAreaValue
|
||||||
|
if (!send || !text) return
|
||||||
|
await send(text)
|
||||||
|
inputRef?.current?.focus({ preventScroll: true })
|
||||||
|
clearTextAreaValue()
|
||||||
|
}, [send, inputRef])
|
||||||
|
|
||||||
|
const isDisabled = !textAreaValue.trim() || isSending
|
||||||
|
|
||||||
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||||
|
return
|
||||||
|
e.preventDefault()
|
||||||
|
await handleSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
value={textAreaValue}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onChange={(e) => {
|
||||||
|
persistTextAreaValue(e.target.value)
|
||||||
|
}}
|
||||||
|
fieldSizing={'content'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: '240px',
|
||||||
|
minHeight: `34px`,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
padding: '7px 10px',
|
||||||
|
}}
|
||||||
|
placeholderStyle="strong"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder={t('textArea.placeholder')}
|
||||||
|
aria-label={t('textArea.label')}
|
||||||
|
/>
|
||||||
|
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||||
|
|
||||||
|
interface CarouselLayoutState {
|
||||||
|
orientation: CarouselOrientation
|
||||||
|
maxVisibleTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselLayoutObserverProps {
|
||||||
|
asideEl: React.RefObject<HTMLDivElement>
|
||||||
|
orientation?: CarouselOrientation
|
||||||
|
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselLayoutObserver = ({
|
||||||
|
orientation,
|
||||||
|
asideEl,
|
||||||
|
onLayoutChange,
|
||||||
|
}: CarouselLayoutObserverProps) => {
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
|
||||||
|
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||||
|
// container size hovers around a breakpoint. A ref (not state) because
|
||||||
|
// updating it must not trigger a re-render.
|
||||||
|
const prevTilesRef = React.useRef(0)
|
||||||
|
|
||||||
|
const carouselOrientation: CarouselOrientation =
|
||||||
|
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const availableSpan =
|
||||||
|
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||||
|
|
||||||
|
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles: number
|
||||||
|
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||||
|
// Within the dead zone: keep the previous count.
|
||||||
|
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||||
|
} else {
|
||||||
|
maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
prevTilesRef.current = tilesThatFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cosmetic layout output straight to the DOM.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = asideEl.current
|
||||||
|
if (!el) return
|
||||||
|
el.dataset.lkOrientation = carouselOrientation
|
||||||
|
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||||
|
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||||
|
|
||||||
|
// Report upward only what the parent actually needs for
|
||||||
|
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||||
|
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
|
? prev
|
||||||
|
: next
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
key={carouselOrientation}
|
<CarouselLayoutObserver
|
||||||
className="lk-carousel"
|
asideEl={asideEl}
|
||||||
ref={asideEl}
|
orientation={orientation}
|
||||||
{...props}
|
onLayoutChange={handleLayoutChange}
|
||||||
>
|
/>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
</aside>
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
|
<aside
|
||||||
|
key={layout.orientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from './ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
interface GridLayoutObserverProps {
|
||||||
|
gridEl: React.RefObject<HTMLDivElement>
|
||||||
|
trackCount: number
|
||||||
|
onMaxTilesChange: (maxTiles: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless component that runs the layout calculation in isolation and
|
||||||
|
* reports the resulting tile capacity upward.
|
||||||
|
*
|
||||||
|
* `useGridLayout` re-renders its host on every layout recalculation
|
||||||
|
* (e.g. container resizes). Rendering it in a null child means only this
|
||||||
|
* component churns; the parent `GridLayout` re-renders solely when
|
||||||
|
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||||
|
*/
|
||||||
|
const GridLayoutObserver = ({
|
||||||
|
gridEl,
|
||||||
|
trackCount,
|
||||||
|
onMaxTilesChange,
|
||||||
|
}: GridLayoutObserverProps) => {
|
||||||
|
const { layout } = useGridLayout(gridEl, trackCount)
|
||||||
|
useEffect(() => {
|
||||||
|
onMaxTilesChange(layout.maxTiles)
|
||||||
|
}, [onMaxTilesChange, layout.maxTiles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxTiles, setMaxTiles] = useState(1)
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
|
<GridLayoutObserver
|
||||||
|
gridEl={gridEl}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
onMaxTilesChange={setMaxTiles}
|
||||||
|
/>
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||||
|
|
||||||
|
export const PinAnnouncer = () => {
|
||||||
|
usePinAnnouncements()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||||
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
|
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||||
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
isTrackReference,
|
||||||
|
log,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export const StageLayout = () => {
|
||||||
|
const lastAutoFocusedScreenShareTrack =
|
||||||
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
|
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ updateOnlyOn: [], onlySubscribed: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
|
||||||
|
const carouselTracks = tracks.filter(
|
||||||
|
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
|
useEffect(() => {
|
||||||
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
|
if (
|
||||||
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
|
) {
|
||||||
|
log.debug('Auto set screen share focus:', {
|
||||||
|
newScreenShareTrack: screenShareTracks[0],
|
||||||
|
})
|
||||||
|
setPinnedTrack(screenShareTracks[0])
|
||||||
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||||
|
} else if (
|
||||||
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
|
!screenShareTracks.some(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid ===
|
||||||
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.')
|
||||||
|
clearPinnedTrack()
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null
|
||||||
|
}
|
||||||
|
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||||
|
const updatedFocusTrack = tracks.find(
|
||||||
|
(tr) =>
|
||||||
|
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||||
|
tr.source === pinnedTrackRef.source
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
updatedFocusTrack !== pinnedTrackRef &&
|
||||||
|
isTrackReference(updatedFocusTrack)
|
||||||
|
) {
|
||||||
|
setPinnedTrack(updatedFocusTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
screenShareTracks
|
||||||
|
.map(
|
||||||
|
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||||
|
)
|
||||||
|
.join(),
|
||||||
|
pinnedTrackRef?.publication?.trackSid,
|
||||||
|
tracks,
|
||||||
|
])
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!pinnedTrackRef ? (
|
||||||
|
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
<FocusLayout trackRef={pinnedTrackRef} />
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
|
export const usePinAnnouncements = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const getAnnouncementName = useCallback(
|
||||||
|
(participant?: Participant | null) => {
|
||||||
|
if (!participant) return tRooms('participants.unknown')
|
||||||
|
return participant.isLocal
|
||||||
|
? tRooms('participants.you')
|
||||||
|
: getParticipantName(participant)
|
||||||
|
},
|
||||||
|
[tRooms]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. unpin
|
||||||
|
if (!pinnedIdentity) {
|
||||||
|
const lastIdentity = lastPinnedIdentityRef.current
|
||||||
|
if (!lastIdentity) return
|
||||||
|
|
||||||
|
const lastParticipant =
|
||||||
|
room.localParticipant.identity === lastIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(lastIdentity)
|
||||||
|
|
||||||
|
announce(
|
||||||
|
lastParticipant?.isLocal
|
||||||
|
? t('self.unpin')
|
||||||
|
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||||
|
)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. same pin → do nothing
|
||||||
|
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||||
|
|
||||||
|
// 3. new pin
|
||||||
|
const participant =
|
||||||
|
room.localParticipant.identity === pinnedIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(pinnedIdentity)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = pinnedIdentity
|
||||||
|
|
||||||
|
announce(
|
||||||
|
participant?.isLocal
|
||||||
|
? t('self.pin')
|
||||||
|
: t('pin', { name: getAnnouncementName(participant) })
|
||||||
|
)
|
||||||
|
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useReducer, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tripwire for speaker promotion.
|
||||||
|
*
|
||||||
|
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||||
|
* re-render of the host component only when an active speaker has none of
|
||||||
|
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||||
|
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||||
|
* state and performs the actual swap.
|
||||||
|
*
|
||||||
|
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||||
|
*
|
||||||
|
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||||
|
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||||
|
*/
|
||||||
|
export function useSpeakerPromotionTrigger(
|
||||||
|
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||||
|
) {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||||
|
|
||||||
|
// Refs so the listener reads current values without re-subscribing
|
||||||
|
// and without itself being a render dependency.
|
||||||
|
const tilesRef = useRef(sortedTiles)
|
||||||
|
tilesRef.current = sortedTiles
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||||
|
const tiles = tilesRef.current
|
||||||
|
const hiddenSpeakerExists = speakers.some(
|
||||||
|
(speaker) =>
|
||||||
|
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||||
|
)
|
||||||
|
if (hiddenSpeakerExists) {
|
||||||
|
forceRender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
import { setPinnedTrack } from '@/stores/layout'
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
state,
|
state,
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
const layoutContext = useMaybeLayoutContext()
|
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
source: Source.Camera,
|
source: Source.Camera,
|
||||||
}
|
}
|
||||||
const pinParticipant = () => {
|
|
||||||
layoutContext?.pin.dispatch?.({
|
|
||||||
msg: 'set_pin',
|
|
||||||
trackReference,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
pinParticipant()
|
setPinnedTrack(trackReference)
|
||||||
closeButtonProps.onPress?.(e)
|
closeButtonProps.onPress?.(e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
|||||||
+6
-5
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
|||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { useSize } from '../hooks/useResizeObserver'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { LocalParticipant } from 'livekit-client'
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -17,7 +17,6 @@ export const FullScreenShareWarning = ({
|
|||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
const { localParticipant } = useLocalParticipant()
|
|
||||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
if (!localParticipant.isScreenShareEnabled) return
|
const participant = trackReference.participant
|
||||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
if (!(participant instanceof LocalParticipant)) return
|
||||||
|
if (!participant.isScreenShareEnabled) return
|
||||||
|
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
+9
-7
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
children: ReactNode
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||||
* typically when an element receives keyboard focus.
|
* typically when an element receives keyboard focus.
|
||||||
*/
|
*/
|
||||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
export const KeyboardShortcutHint = React.memo(
|
||||||
children,
|
({ hint }: KeyboardShortcutHintProps) => {
|
||||||
}) => {
|
return <Hint>{hint}</Hint>
|
||||||
return <Hint>{children}</Hint>
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
|
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
LockLockedIcon,
|
||||||
|
ScreenShareIcon,
|
||||||
|
useIsEncrypted,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||||
|
|
||||||
|
export const ParticipantMetadata = ({
|
||||||
|
displayedName,
|
||||||
|
participant,
|
||||||
|
isScreenShare,
|
||||||
|
}: {
|
||||||
|
displayedName: string
|
||||||
|
participant: Participant
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const isEncrypted = useIsEncrypted(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-participant-metadata">
|
||||||
|
<HStack gap={0.25}>
|
||||||
|
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
||||||
|
<RaisedHandMetadataWrapper
|
||||||
|
participant={participant}
|
||||||
|
enabled={!isScreenShare}
|
||||||
|
>
|
||||||
|
{isScreenShare && (
|
||||||
|
<ScreenShareIcon
|
||||||
|
style={{
|
||||||
|
maxWidth: '20px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEncrypted && !isScreenShare && (
|
||||||
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
displayedName={displayedName}
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RaisedHandMetadataWrapper>
|
||||||
|
</HStack>
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { CSSProperties } from 'react'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const participantNameStyles: CSSProperties = {
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantNameScreenShareStyles: CSSProperties = {
|
||||||
|
...participantNameStyles,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantName = React.memo(
|
||||||
|
({
|
||||||
|
displayedName,
|
||||||
|
isScreenShare = false,
|
||||||
|
}: {
|
||||||
|
displayedName?: string
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||||
|
if (isScreenShare) {
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||||
|
{t('screenShare', { name: displayedName })}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||||
|
{displayedName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ParticipantName.displayName = 'ParticipantName'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
containerType: 'size',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledAvatarWrapper = styled('div', {
|
||||||
|
base: {
|
||||||
|
borderRadius: '50%',
|
||||||
|
aspectRatio: '1 / 1',
|
||||||
|
width: 'min(90cqmin, 160px)',
|
||||||
|
fontSize: 'min(27cqmin, 48px)',
|
||||||
|
'[data-lk-speaking="true"] &': {
|
||||||
|
animation: 'pulse 1s infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ParticipantPlaceholderProps = {
|
||||||
|
color: string
|
||||||
|
displayedNamed: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantPlaceholder = React.memo(
|
||||||
|
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||||
|
const backgroundGradient = useMemo(
|
||||||
|
() => getParticipantBackgroundGradient(color),
|
||||||
|
[color]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<StyledParticipantPlaceHolder
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
backgroundImage: backgroundGradient,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledAvatarWrapper>
|
||||||
|
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||||
|
</StyledAvatarWrapper>
|
||||||
|
</StyledParticipantPlaceHolder>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||||
+34
-104
@@ -1,39 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ConnectionQualityIndicator,
|
|
||||||
LockLockedIcon,
|
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
ScreenShareIcon,
|
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
useIsEncrypted,
|
|
||||||
useMaybeLayoutContext,
|
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useParticipantInfo,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
isTrackReferencePinned,
|
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { RiHand } from '@remixicon/react'
|
|
||||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { ParticipantName } from './ParticipantName'
|
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
|
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||||
|
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
@@ -52,6 +45,7 @@ export function TrackRefContextIfNeeded(
|
|||||||
|
|
||||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||||
disableMetadata?: boolean
|
disableMetadata?: boolean
|
||||||
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
@@ -66,21 +60,18 @@ export const ParticipantTile: (
|
|||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
disableMetadata,
|
disableMetadata,
|
||||||
|
disableTileControls,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
}: ParticipantTileExtendedProps,
|
}: ParticipantTileExtendedProps,
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const trackReference = useEnsureTrackRef(trackRef)
|
const trackReference = useEnsureTrackRef(trackRef)
|
||||||
|
|
||||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||||
htmlProps,
|
htmlProps,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
|
||||||
const layoutContext = useMaybeLayoutContext()
|
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
|
|
||||||
const handleSubscribe = React.useCallback(
|
const handleSubscribe = React.useCallback(
|
||||||
@@ -88,28 +79,25 @@ export const ParticipantTile: (
|
|||||||
if (
|
if (
|
||||||
trackReference.source &&
|
trackReference.source &&
|
||||||
!subscribed &&
|
!subscribed &&
|
||||||
layoutContext &&
|
layoutStore.pinnedTrackRef &&
|
||||||
layoutContext.pin.dispatch &&
|
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
|
||||||
) {
|
) {
|
||||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
clearPinnedTrack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackReference, layoutContext]
|
[trackReference]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { isHandRaised } = useRaisedHand({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantName = getParticipantName(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
|
|
||||||
|
const { identity, name } = useParticipantInfo({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
@@ -136,7 +124,9 @@ export const ParticipantTile: (
|
|||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
{trackReference.participant.isLocal && (
|
||||||
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{isTrackReference(trackReference) &&
|
||||||
@@ -158,80 +148,20 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
participant={trackReference.participant}
|
color={participantColor}
|
||||||
|
displayedNamed={participantName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<div className="lk-participant-metadata">
|
<ParticipantMetadata
|
||||||
<HStack gap={0.25}>
|
displayedName={participantName}
|
||||||
{!isScreenShare && (
|
isScreenShare={isScreenShare}
|
||||||
<MutedMicIndicator
|
participant={trackReference.participant}
|
||||||
participant={trackReference.participant}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="lk-participant-metadata-item"
|
|
||||||
style={{
|
|
||||||
padding: '0.1rem 0.25rem',
|
|
||||||
backgroundColor:
|
|
||||||
isHandRaised && !isScreenShare
|
|
||||||
? firstInQueue
|
|
||||||
? '#fde047'
|
|
||||||
: 'white'
|
|
||||||
: undefined,
|
|
||||||
color:
|
|
||||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
|
||||||
transition: 'background 200ms ease, color 400ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isHandRaised && !isScreenShare && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{positionInQueue}</span>
|
|
||||||
<RiHand
|
|
||||||
color="black"
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
marginLeft: '0.1rem',
|
|
||||||
minWidth: '16px',
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationName: 'wave_hand',
|
|
||||||
animationIterationCount: '2',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isScreenShare && (
|
|
||||||
<ScreenShareIcon
|
|
||||||
style={{
|
|
||||||
maxWidth: '20px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
participant={trackReference.participant}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!disableMetadata && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
@@ -239,13 +169,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>
|
<KeyboardShortcutHint
|
||||||
{t('toolbarHint', {
|
hint={t('toolbarHint', {
|
||||||
shortcut: formatShortcutLabel(
|
shortcut: formatShortcutLabel(
|
||||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</KeyboardShortcutHint>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
useRaisedHand,
|
||||||
|
useRaisedHandPosition,
|
||||||
|
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
|
||||||
|
const PositionInQueue = React.memo(
|
||||||
|
({ positionInQueue }: { positionInQueue?: number }) => {
|
||||||
|
if (!positionInQueue) return
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
||||||
|
>
|
||||||
|
<span>{positionInQueue}</span>
|
||||||
|
<RiHand
|
||||||
|
color="black"
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
marginLeft: '0.1rem',
|
||||||
|
minWidth: '16px',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PositionInQueue.displayName = 'PositionInQueue'
|
||||||
|
|
||||||
|
const RaisedHandActiveItem = ({
|
||||||
|
participant,
|
||||||
|
targetRef,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
targetRef: RefObject<HTMLElement | null>
|
||||||
|
}) => {
|
||||||
|
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||||
|
participant,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
const el = targetRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
||||||
|
el.style.color = 'black'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.style.backgroundColor = ''
|
||||||
|
el.style.color = ''
|
||||||
|
}
|
||||||
|
}, [targetRef, firstInQueue])
|
||||||
|
|
||||||
|
return <PositionInQueue positionInQueue={positionInQueue} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RaisedHandMetadataWrapper = ({
|
||||||
|
participant,
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
enabled?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const { isHandRaised } = useRaisedHand({ participant })
|
||||||
|
const showHand = isHandRaised && enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="lk-participant-metadata-item"
|
||||||
|
style={{
|
||||||
|
padding: '0.1rem 0.25rem',
|
||||||
|
transition: 'background 200ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showHand && (
|
||||||
|
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const EffectsButton = React.memo(() => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
tooltip={t('effects')}
|
||||||
|
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||||
|
>
|
||||||
|
<RiImageCircleAiFill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
EffectsButton.displayName = 'EffectsButton'
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const FocusButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||||
|
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||||
|
>
|
||||||
|
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||||
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
|
|
||||||
|
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
participant: participant,
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
|
||||||
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
isDisabled={isMuted}
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
onPress={() => setIsAlertOpen(true)}
|
||||||
|
tooltip={t('muteParticipant', { name })}
|
||||||
|
>
|
||||||
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
|
import { FocusButton } from './FocusButton'
|
||||||
|
import { EffectsButton } from './EffectsButton'
|
||||||
|
import { MuteButton } from './MuteButton'
|
||||||
|
import { ZoomButton } from './ZoomButton'
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
|
type FadeOverlayProps = {
|
||||||
|
children: ReactNode
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||||
|
const [active, setActive] = useState(false)
|
||||||
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const armIdleTimer = () => {
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActive(false)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
setActive(true)
|
||||||
|
armIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => clearIdleTimer, [])
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || active
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
})}
|
||||||
|
data-visible={isVisible || undefined}
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={handleActivity}
|
||||||
|
onMouseMove={handleActivity}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
clearIdleTimer()
|
||||||
|
setActive(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVisible && children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantTileFocus = ({
|
||||||
|
trackRef,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}) => {
|
||||||
|
const participant = trackRef.participant
|
||||||
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
|
const isLocal = participant.isLocal
|
||||||
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
zIndex: 1,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
opacity: 0.6,
|
||||||
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
|
_hover: {
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HStack gap={0.5} padding={0.5}>
|
||||||
|
<FocusButton trackRef={trackRef} />
|
||||||
|
{!isScreenShare ? (
|
||||||
|
<>
|
||||||
|
{isLocal ? (
|
||||||
|
<EffectsButton />
|
||||||
|
) : (
|
||||||
|
canMute && <MuteButton participant={participant} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</FadeOverlay>
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiFullscreenLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
-5
@@ -1,23 +1,22 @@
|
|||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
|
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
|
||||||
type LowerAllHandsButtonProps = {
|
type LowerAllHandsButtonProps = {
|
||||||
participants: Array<Participant>
|
participants: Array<Participant>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LowerAllHandsButton = ({
|
const LowerAllHandsButtonInner = ({
|
||||||
participants,
|
participants,
|
||||||
}: LowerAllHandsButtonProps) => {
|
}: LowerAllHandsButtonProps) => {
|
||||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
if (!participants.length) return null
|
||||||
if (!isAdminOrOwner) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -36,3 +35,13 @@ export const LowerAllHandsButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LowerAllHandsButton = ({
|
||||||
|
participants,
|
||||||
|
}: LowerAllHandsButtonProps) => {
|
||||||
|
return (
|
||||||
|
<AdminOrOwnerOnly>
|
||||||
|
<LowerAllHandsButtonInner participants={participants} />
|
||||||
|
</AdminOrOwnerOnly>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-5
@@ -16,11 +16,11 @@ import Source = Track.Source
|
|||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -93,9 +93,7 @@ type ParticipantListItemProps = {
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantListItem = ({
|
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||||
participant,
|
|
||||||
}: ParticipantListItemProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
return (
|
return (
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ReactNode, useId, useState } from 'react'
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
|
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 .625rem 0.9375rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header = styled('button', {
|
||||||
|
base: {
|
||||||
|
minHeight: '2.5rem',
|
||||||
|
paddingX: '1.25rem',
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
gap: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '1rem',
|
||||||
|
transition: 'background 200ms',
|
||||||
|
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
||||||
|
_hover: { backgroundColor: 'greyscale.50' },
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
isOpen: { false: { borderRadius: '7px' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Chevron = styled(RiArrowUpSLine, {
|
||||||
|
base: { transition: 'transform 200ms', flexShrink: 0 },
|
||||||
|
variants: {
|
||||||
|
isOpen: { false: { transform: 'rotate(180deg)' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const List = styled(VStack, {
|
||||||
|
base: {
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderTopColor: 'greyscale.250',
|
||||||
|
alignItems: 'start',
|
||||||
|
minHeight: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
paddingX: '1rem',
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ParticipantsCollapsibleSectionProps = {
|
||||||
|
heading: string
|
||||||
|
count: number
|
||||||
|
action?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsCollapsibleSection = ({
|
||||||
|
heading,
|
||||||
|
count,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: ParticipantsCollapsibleSectionProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const listId = useId()
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header
|
||||||
|
type="button"
|
||||||
|
isOpen={isOpen}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={listId}
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" width="100%">
|
||||||
|
<span>{heading}</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</HStack>
|
||||||
|
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
||||||
|
</Header>
|
||||||
|
{isOpen && (
|
||||||
|
<List id={listId} role="list">
|
||||||
|
{action}
|
||||||
|
{children}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRemoteParticipants } from '@livekit/components-react'
|
||||||
|
import { srOnly } from '@/styles/a11y'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiInfinityLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
const badgeStyles = css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-.25rem',
|
||||||
|
right: '-.25rem',
|
||||||
|
width: '1.25rem',
|
||||||
|
height: '1.25rem',
|
||||||
|
backgroundColor: 'gray',
|
||||||
|
borderRadius: '50%',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated so participant join/leave events only re-render the badge,
|
||||||
|
* not the button, tooltip or shortcut registration.
|
||||||
|
*/
|
||||||
|
export const ParticipantsCount = React.memo(
|
||||||
|
({ describedById }: { describedById: string }) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'controls.participants',
|
||||||
|
})
|
||||||
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [],
|
||||||
|
})
|
||||||
|
const count = (remoteParticipants?.length ?? 0) + 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span id={describedById} className={srOnly}>
|
||||||
|
{t('count', { count })}
|
||||||
|
</span>
|
||||||
|
<div className={badgeStyles} aria-hidden>
|
||||||
|
{count < 100 ? count : <RiInfinityLine size={10} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ParticipantsCount.displayName = 'ParticipantsCount'
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import {
|
||||||
|
useRemoteParticipants,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import { Div, H } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ParticipantRow } from './ParticipantRow'
|
||||||
|
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||||
|
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
||||||
|
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||||
|
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
||||||
|
import { RaisedHandRow } from './RaisedHandRow'
|
||||||
|
|
||||||
|
const JoinedParticipantsSections = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
|
||||||
|
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
||||||
|
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
||||||
|
// are room-wide and fire for the local participant too, so this hook re-emits
|
||||||
|
// (and we re-render) on local changes, despite its name.
|
||||||
|
//
|
||||||
|
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
||||||
|
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
||||||
|
// local events waking this component up, raising your own hand would not
|
||||||
|
// update the UI. Do not "optimise" these away.
|
||||||
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [
|
||||||
|
RoomEvent.ParticipantNameChanged,
|
||||||
|
RoomEvent.ParticipantAttributesChanged,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const allParticipants = [
|
||||||
|
room.localParticipant,
|
||||||
|
...[...remoteParticipants].sort((a, b) =>
|
||||||
|
(a.name || a.identity).localeCompare(b.name || b.identity)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const participantsWithRaisedHands = allParticipants
|
||||||
|
.filter((p) => !!p.attributes.handRaisedAt)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
||||||
|
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
||||||
|
return (
|
||||||
|
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
||||||
|
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{participantsWithRaisedHands.length > 0 && (
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('raisedHands')}
|
||||||
|
count={participantsWithRaisedHands.length}
|
||||||
|
action={
|
||||||
|
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{participantsWithRaisedHands.map((p) => (
|
||||||
|
<RaisedHandRow key={p.identity} participant={p} />
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
)}
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('contributors')}
|
||||||
|
count={allParticipants.length}
|
||||||
|
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
||||||
|
>
|
||||||
|
{allParticipants.map((p) => (
|
||||||
|
<ParticipantRow key={p.identity} participant={p} />
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsList = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div overflowY="scroll">
|
||||||
|
<H
|
||||||
|
lvl={2}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
marginBottom: '0.83em',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('subheading').toUpperCase()}
|
||||||
|
</H>
|
||||||
|
<WaitingParticipantsSection />
|
||||||
|
<JoinedParticipantsSections />
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import { RiPushpin2Fill } from '@remixicon/react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { isEqualTrackRef } from '@livekit/components-core'
|
||||||
|
import Source = Track.Source
|
||||||
|
|
||||||
|
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
||||||
|
const cameraTrackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const screenShareTrackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.ScreenShare),
|
||||||
|
source: Source.ScreenShare,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus =
|
||||||
|
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
||||||
|
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
||||||
|
|
||||||
|
if (!inFocus) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
height: '18px',
|
||||||
|
width: '18px',
|
||||||
|
borderRadius: '100%',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-2px',
|
||||||
|
right: '-4px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiPushpin2Fill size={14} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
-21
@@ -4,27 +4,47 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { Text } from '@/primitives/Text'
|
import { Text } from '@/primitives/Text'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useLowerHandParticipant } from '@/features/rooms/api/lowerHandParticipant'
|
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
|
||||||
|
const ActionButton = ({
|
||||||
|
participant,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
name: string
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => lowerHandParticipant(participant)}
|
||||||
|
aria-label={t('participants.lowerParticipantHand', { name })}
|
||||||
|
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||||
|
data-attr="participants-lower-hand"
|
||||||
|
>
|
||||||
|
<RiHand />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type HandRaisedListItemProps = {
|
type HandRaisedListItemProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandRaisedListItem = ({
|
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||||
participant,
|
|
||||||
}: HandRaisedListItemProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -69,19 +89,9 @@ export const HandRaisedListItem = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{isAdminOrOwner && (
|
<AdminOrOwnerOnly>
|
||||||
<Button
|
<ActionButton participant={participant} name={name} />
|
||||||
square
|
</AdminOrOwnerOnly>
|
||||||
variant="greyscale"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => lowerHandParticipant(participant)}
|
|
||||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
|
||||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
|
||||||
data-attr="participants-lower-hand"
|
|
||||||
>
|
|
||||||
<RiHand />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -3,10 +3,10 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantRow = ({
|
||||||
participant,
|
participant,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||||
|
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
||||||
|
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
||||||
|
|
||||||
|
export const WaitingParticipantsSection = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
||||||
|
|
||||||
|
const { waitingParticipants, handleParticipantEntry } =
|
||||||
|
useWaitingParticipants()
|
||||||
|
|
||||||
|
if (waitingParticipants?.length == 0) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('title')}
|
||||||
|
count={waitingParticipants.length}
|
||||||
|
>
|
||||||
|
{waitingParticipants.map((p) => (
|
||||||
|
<WaitingParticipantRow
|
||||||
|
key={p.id}
|
||||||
|
participant={p}
|
||||||
|
onAction={handleParticipantEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { PinMenuItem } from './PinMenuItem'
|
import { PinMenuItem } from './items/PinMenuItem'
|
||||||
import { RemoveMenuItem } from './RemoveMenuItem'
|
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
+17
-4
@@ -1,15 +1,28 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import { Participant, Track } from 'livekit-client'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { isEqualTrackRef } from '@livekit/components-core'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
const { toggle, inFocus } = useFocusToggleParticipant(participant)
|
const trackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -17,7 +30,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={toggle}
|
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(mainTrack)}
|
key={getTrackKey(mainTrack)}
|
||||||
trackRef={mainTrack}
|
trackRef={mainTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</MainSlot>
|
</MainSlot>
|
||||||
)}
|
)}
|
||||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
|||||||
<ParticipantTile
|
<ParticipantTile
|
||||||
key={getTrackKey(thumbnailTrack)}
|
key={getTrackKey(thumbnailTrack)}
|
||||||
trackRef={thumbnailTrack}
|
trackRef={thumbnailTrack}
|
||||||
|
disableTileControls
|
||||||
/>
|
/>
|
||||||
</Thumbnail>
|
</Thumbnail>
|
||||||
)}
|
)}
|
||||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
|||||||
base: {
|
base: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
'& .lk-participant-media-video': {
|
'& .lk-participant-media-video': {
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
},
|
},
|
||||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
|||||||
const Thumbnail = styled('div', {
|
const Thumbnail = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '1rem',
|
right: '1.25rem',
|
||||||
bottom: '1rem',
|
bottom: '1.25rem',
|
||||||
width: '42%',
|
width: '42%',
|
||||||
maxWidth: '220px',
|
maxWidth: '220px',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
aspectRatio: '16 / 9',
|
aspectRatio: '16 / 9',
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: 'md',
|
boxShadow: 'md',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
|||||||
<GridContainer ref={containerRef} style={gridStyle}>
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||||
<ParticipantTile trackRef={track} />
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
</GridContainer>
|
</GridContainer>
|
||||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '0.25rem',
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
borderRadius: '4px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
|
type PipScreenShareLayoutProps = {
|
||||||
|
screenShareTrack: TrackReferenceOrPlaceholder
|
||||||
|
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout when a screen share is active.
|
||||||
|
* Camera tiles are shown as a compact row at the top; the screen share occupies
|
||||||
|
* the remaining space below, much larger than the camera tiles.
|
||||||
|
*/
|
||||||
|
export const PipScreenShareLayout = memo(
|
||||||
|
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<LayoutContainer>
|
||||||
|
{cameraTracks.length > 0 && (
|
||||||
|
<CameraTilesRow>
|
||||||
|
{cameraTracks.map((track) => (
|
||||||
|
<CameraTile key={getTrackKey(track)}>
|
||||||
|
<ParticipantTile trackRef={track} disableTileControls />
|
||||||
|
</CameraTile>
|
||||||
|
))}
|
||||||
|
</CameraTilesRow>
|
||||||
|
)}
|
||||||
|
<ScreenShareSlot>
|
||||||
|
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
|
||||||
|
</ScreenShareSlot>
|
||||||
|
</LayoutContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
|
||||||
|
|
||||||
|
const LayoutContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTilesRow = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '22%',
|
||||||
|
minHeight: '60px',
|
||||||
|
maxHeight: '120px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const CameraTile = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: '0 1 auto',
|
||||||
|
height: '100%',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
minWidth: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ScreenShareSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
'& .lk-participant-tile': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'& .lk-participant-media-video': {
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { usePagination, useTracks } from '@livekit/components-react'
|
import { usePagination, useTracks } from '@livekit/components-react'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { PipFocusLayout } from './PipFocusLayout'
|
import { PipFocusLayout } from './PipFocusLayout'
|
||||||
import { PipGridLayout } from './PipGridLayout'
|
import { PipGridLayout } from './PipGridLayout'
|
||||||
import { PipPagination } from './PipPagination'
|
import { PipPagination } from './PipPagination'
|
||||||
|
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||||
import { StageFrame } from './StageFrame'
|
import { StageFrame } from './StageFrame'
|
||||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +14,12 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PipStage picks between two layouts based on track count:
|
* PipStage picks between three layouts:
|
||||||
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
* - Screen share mode (any screen share active):
|
||||||
* - Grid mode (3+ tracks): adaptive tiling.
|
* small camera tiles in a row at the top, large screen share below.
|
||||||
|
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
|
||||||
|
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
|
||||||
|
* + one thumbnail overlay.
|
||||||
*/
|
*/
|
||||||
export const PipStage = () => {
|
export const PipStage = () => {
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
@@ -41,51 +45,55 @@ export const PipStage = () => {
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Grid mode order: screen share leads, then cameras (already ordered by
|
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||||
// active speaker via the `ActiveSpeakersChanged` update above).
|
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||||
const gridTracks = useMemo(
|
|
||||||
() =>
|
|
||||||
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
|
||||||
[screenShareTrack, cameraTracks]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||||
// page visually stable (active/recent speakers stay put) via its internal
|
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
||||||
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
|
|
||||||
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
|
|
||||||
|
|
||||||
if (tracks.length === 0) return null
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
/**
|
// Screen share active
|
||||||
* The focus layout shows one main track with one thumbnail overlay,
|
if (screenShareTrack) {
|
||||||
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||||
*/
|
if (cameraTracks.length <= 1) {
|
||||||
if (gridTracks.length > 2) {
|
return (
|
||||||
return (
|
|
||||||
<StageWrapper>
|
|
||||||
<StageFrame>
|
<StageFrame>
|
||||||
<PipGridLayout tracks={pagination.tracks} />
|
<PipFocusLayout
|
||||||
|
mainTrack={screenShareTrack}
|
||||||
|
thumbnailTrack={cameraTracks[0]}
|
||||||
|
/>
|
||||||
</StageFrame>
|
</StageFrame>
|
||||||
<PipPagination
|
)
|
||||||
totalPageCount={pagination.totalPageCount}
|
}
|
||||||
currentPage={pagination.currentPage}
|
// Multiple cameras: camera row at top, screen share below
|
||||||
nextPage={pagination.nextPage}
|
return (
|
||||||
prevPage={pagination.prevPage}
|
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||||
|
<PipScreenShareLayout
|
||||||
|
screenShareTrack={screenShareTrack}
|
||||||
|
cameraTracks={paginatedCameraTracks.tracks}
|
||||||
/>
|
/>
|
||||||
</StageWrapper>
|
</PaginatedStage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3+ camera tracks → adaptive grid
|
||||||
|
if (cameraTracks.length > 2) {
|
||||||
|
return (
|
||||||
|
<PaginatedStage pagination={pagination}>
|
||||||
|
<PipGridLayout tracks={pagination.tracks} />
|
||||||
|
</PaginatedStage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
|
||||||
const localCameraTrack = cameraTracks.find(
|
const localCameraTrack = cameraTracks.find(
|
||||||
(track) => track.participant?.isLocal
|
(track) => track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
|
||||||
const remoteCameraTrack = cameraTracks.find(
|
const remoteCameraTrack = cameraTracks.find(
|
||||||
(track) => !track.participant?.isLocal
|
(track) => !track.participant?.isLocal
|
||||||
)
|
)
|
||||||
|
const mainTrack = remoteCameraTrack ?? localCameraTrack
|
||||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
|
||||||
|
|
||||||
const thumbnailTrack =
|
const thumbnailTrack =
|
||||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||||
|
|
||||||
@@ -96,6 +104,31 @@ export const PipStage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginationResult = {
|
||||||
|
totalPageCount: number
|
||||||
|
currentPage: number
|
||||||
|
nextPage: () => void
|
||||||
|
prevPage: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginatedStage = ({
|
||||||
|
pagination,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
pagination: PaginationResult
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<StageWrapper>
|
||||||
|
<StageFrame>{children}</StageFrame>
|
||||||
|
<PipPagination
|
||||||
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
nextPage={pagination.nextPage}
|
||||||
|
prevPage={pagination.prevPage}
|
||||||
|
/>
|
||||||
|
</StageWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
const StageWrapper = styled('div', {
|
const StageWrapper = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useTrackToggle } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'pictureInPicture',
|
keyPrefix: 'pictureInPicture',
|
||||||
})
|
})
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { enabled: isScreenSharing } = useTrackToggle({
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
{...(!isScreenSharing ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -25,8 +28,8 @@ const Container = styled('div', {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
marginLeft: '0.5rem',
|
padding: '0.5rem',
|
||||||
marginRight: '0.5rem',
|
boxSizing: 'border-box',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { VStack } from '@/styled-system/jsx'
|
|||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useSettingsDialog,
|
|
||||||
SettingsDialogExtendedKey,
|
SettingsDialogExtendedKey,
|
||||||
useTranscriptionLanguage,
|
useTranscriptionLanguage,
|
||||||
} from '@/features/settings'
|
} from '@/features/settings'
|
||||||
@@ -35,6 +34,7 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -48,8 +48,6 @@ export const TranscriptSidePanel = () => {
|
|||||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||||
useTranscriptionLanguage()
|
useTranscriptionLanguage()
|
||||||
|
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
pin_code: string
|
pin_code?: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -67,35 +68,36 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = ({
|
export const ActiveSpeaker = React.memo(
|
||||||
isSpeaking,
|
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
||||||
pushToTalk,
|
return (
|
||||||
}: ActiveSpeakerProps) => {
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
return (
|
<StyledChild
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '300ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '300ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
style={{
|
||||||
active={isSpeaking}
|
animationDelay: '100ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '100ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '500ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '500ms',
|
/>
|
||||||
}}
|
</StyledContainer>
|
||||||
/>
|
)
|
||||||
</StyledContainer>
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
|||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
|||||||
userChoices: LocalUserChoices
|
userChoices: LocalUserChoices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId, posthog])
|
||||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
|||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchRoom({
|
fetchRoom({
|
||||||
roomId: roomId as string,
|
roomId: roomId as string,
|
||||||
username: userConfig.username,
|
username: username,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (error.statusCode == '404') {
|
if (error.statusCode == '404') {
|
||||||
createRoom({ slug: roomId, username: userConfig.username })
|
createRoom({ slug: roomId, username })
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
retry: false,
|
retry: false,
|
||||||
@@ -167,7 +170,6 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
kind: MediaDeviceKind | null
|
kind: MediaDeviceKind | null
|
||||||
@@ -300,13 +302,7 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && !isMobile && (
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<InviteDialog
|
|
||||||
isOpen={showInviteDialog}
|
|
||||||
onOpenChange={setShowInviteDialog}
|
|
||||||
onClose={() => setShowInviteDialog(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
{...mediaDeviceError}
|
{...mediaDeviceError}
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
import { Div, Button, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -39,7 +39,9 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -58,163 +60,161 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
|
if (!showInviteDialog) return null
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog {...props}>
|
<StyledRACDialog>
|
||||||
{({ close }) => (
|
<VStack
|
||||||
<VStack
|
alignItems="left"
|
||||||
alignItems="left"
|
justify="start"
|
||||||
justify="start"
|
gap={0}
|
||||||
gap={0}
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
>
|
||||||
>
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
{t('heading')}
|
||||||
{t('heading')}
|
</Heading>
|
||||||
</Heading>
|
<Div position="absolute" top="5" right="5">
|
||||||
<Div position="absolute" top="5" right="5">
|
<Button
|
||||||
<Button
|
invisible
|
||||||
invisible
|
variant="tertiaryText"
|
||||||
variant="tertiaryText"
|
size="xs"
|
||||||
size="xs"
|
onPress={() => {
|
||||||
onPress={() => {
|
setShowInviteDialog(false)
|
||||||
props.onClose?.()
|
}}
|
||||||
close()
|
aria-label={t('closeDialog')}
|
||||||
}}
|
>
|
||||||
aria-label={t('closeDialog')}
|
<RiCloseLine />
|
||||||
>
|
</Button>
|
||||||
<RiCloseLine />
|
</Div>
|
||||||
</Button>
|
<P>{t('description')}</P>
|
||||||
</Div>
|
{isTelephonyReadyForUse ? (
|
||||||
<P>{t('description')}</P>
|
<div
|
||||||
{isTelephonyReadyForUse ? (
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
marginTop: '0.5rem',
|
justifyContent: 'space-between',
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<Text as="p" wrap="pretty">
|
||||||
className={css({
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
display: 'flex',
|
</Text>
|
||||||
alignItems: 'center',
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
justifyContent: 'space-between',
|
<Button
|
||||||
})}
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
>
|
square
|
||||||
<Text as="p" wrap="pretty">
|
size={'sm'}
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
onPress={copyRoomUrlToClipboard}
|
||||||
</Text>
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
<Button
|
>
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
{isRoomUrlCopied ? (
|
||||||
square
|
<RiCheckLine aria-hidden="true" />
|
||||||
size={'sm'}
|
) : (
|
||||||
onPress={copyRoomUrlToClipboard}
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
)}
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
</Button>
|
||||||
>
|
)}
|
||||||
{isRoomUrlCopied ? (
|
|
||||||
<RiCheckLine aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
|
||||||
{telephony?.internationalPhoneNumber}
|
|
||||||
</Text>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
|
||||||
{formatPinCode(roomData?.pin_code)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine
|
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine
|
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
<RiFileCopyLine
|
||||||
{t('copyUrl')}
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
) : (
|
||||||
<HStack>
|
<Button
|
||||||
<div
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copyUrl')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill
|
||||||
|
size={22}
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primary.200',
|
fill: 'primary.500',
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<RiSpam2Fill
|
</div>
|
||||||
size={22}
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
className={css({
|
{t('permissions')}
|
||||||
fill: 'primary.500',
|
</Text>
|
||||||
})}
|
</HStack>
|
||||||
/>
|
)}
|
||||||
</div>
|
</VStack>
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
|
||||||
{t('permissions')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ import {
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
saveAudioOutputDeviceId,
|
saveAudioOutputDeviceId,
|
||||||
saveUsername,
|
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveVideoInputEnabled,
|
saveVideoInputEnabled,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
|
|
||||||
|
import { saveUsername, userStore } from '@/stores/user'
|
||||||
|
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -114,6 +117,9 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
|
|
||||||
|
const { data: configData } = useConfig()
|
||||||
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
@@ -121,9 +127,10 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
} = useSnapshot(userChoicesStore)
|
} = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
|
|
||||||
if (initialUserChoices.current === null) {
|
if (initialUserChoices.current === null) {
|
||||||
@@ -134,7 +141,6 @@ export const Join = ({
|
|||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorConfig,
|
processorConfig,
|
||||||
username,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,20 +454,23 @@ export const Join = ({
|
|||||||
<H lvl={1} margin="sm" centered>
|
<H lvl={1} margin="sm" centered>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
<Field
|
{(!isLoggedIn ||
|
||||||
type="text"
|
configData?.authenticated_users_can_edit_display_name) && (
|
||||||
onChange={saveUsername}
|
<Field
|
||||||
label={t('usernameLabel')}
|
type="text"
|
||||||
id="input-name"
|
onChange={saveUsername}
|
||||||
defaultValue={username}
|
label={t('usernameLabel')}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
id="input-name"
|
||||||
wrapperProps={{
|
defaultValue={username || user?.full_name}
|
||||||
noMargin: true,
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
fullWidth: true,
|
wrapperProps={{
|
||||||
}}
|
noMargin: true,
|
||||||
autoComplete="name"
|
fullWidth: true,
|
||||||
maxLength={50}
|
}}
|
||||||
/>
|
autoComplete="name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
+19
-9
@@ -1,27 +1,35 @@
|
|||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import {
|
import {
|
||||||
useRemoteParticipants,
|
useRemoteParticipants,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
|
import posthog from 'posthog-js'
|
||||||
|
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
|
||||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const useConnectionObserver = () => {
|
export const ConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const connectionStartTimeRef = useRef<number | null>(null)
|
const connectionStartTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||||
|
const isMobile = isMobileBrowser()
|
||||||
|
|
||||||
const isAdvancedConnectionObserverEnabled =
|
const isAdvancedConnectionObserverEnabled =
|
||||||
useIsAdvancedConnectionObserverEnabled()
|
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
@@ -232,4 +240,6 @@ export const useConnectionObserver = () => {
|
|||||||
connectionStartTimeRef.current = null
|
connectionStartTimeRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||||
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
@@ -94,7 +94,6 @@ const Description = () => {
|
|||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
return (
|
return (
|
||||||
<P>
|
<P>
|
||||||
{t('settingsPrefix')}{' '}
|
{t('settingsPrefix')}{' '}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { CSSProperties } from 'react'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useParticipantInfo } from '@livekit/components-react'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
|
|
||||||
const participantNameStyles: CSSProperties = {
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantNameScreenShareStyles: CSSProperties = {
|
|
||||||
...participantNameStyles,
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantName = ({
|
|
||||||
participant,
|
|
||||||
isScreenShare = false,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
|
||||||
|
|
||||||
const { identity, name } = useParticipantInfo({ participant })
|
|
||||||
const displayedName = name != '' ? name : identity
|
|
||||||
|
|
||||||
if (isScreenShare) {
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
|
||||||
{displayedName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
|
||||||
participant: Participant
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantPlaceholder = ({
|
|
||||||
participant,
|
|
||||||
}: ParticipantPlaceholderProps) => {
|
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
|
||||||
const participantColor = getParticipantColor(participant)
|
|
||||||
|
|
||||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
|
||||||
const { width, height } = useSize(placeholderEl)
|
|
||||||
|
|
||||||
const minDimension = Math.min(width, height)
|
|
||||||
const avatarSize = useMemo(
|
|
||||||
() => Math.min(Math.round(minDimension * 0.9), 160),
|
|
||||||
[minDimension]
|
|
||||||
)
|
|
||||||
|
|
||||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
|
||||||
height: 'auto',
|
|
||||||
aspectRatio: '1/1',
|
|
||||||
width: '80%',
|
|
||||||
maxWidth: `${avatarSize}px`,
|
|
||||||
fontSize: `${initialSize}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
|
||||||
<Avatar
|
|
||||||
name={participant.name}
|
|
||||||
bgColor={participantColor}
|
|
||||||
context="placeholder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</StyledParticipantPlaceHolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import {
|
|
||||||
RiFullscreenLine,
|
|
||||||
RiImageCircleAiFill,
|
|
||||||
RiMicLine,
|
|
||||||
RiMicOffLine,
|
|
||||||
RiPushpin2Line,
|
|
||||||
RiUnpinLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import {
|
|
||||||
useFocusToggle,
|
|
||||||
useTrackMutedIndicator,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
|
||||||
import { useFullScreen } from '../hooks/useFullScreen'
|
|
||||||
import { type Participant, Track } from 'livekit-client'
|
|
||||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|
||||||
|
|
||||||
const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FocusButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { mergedProps, inFocus } = useFocusToggle({
|
|
||||||
trackRef,
|
|
||||||
props: {},
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
|
||||||
>
|
|
||||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const EffectsButton = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
tooltip={t('effects')}
|
|
||||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MuteButton = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
|
||||||
participant: participant,
|
|
||||||
source: Track.Source.Microphone,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
isDisabled={isMuted}
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
onPress={() => setIsAlertOpen(true)}
|
|
||||||
tooltip={t('muteParticipant', { name })}
|
|
||||||
>
|
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
|
||||||
trackRef,
|
|
||||||
hasKeyboardFocus,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}) => {
|
|
||||||
const [hovered, setHovered] = useState(false)
|
|
||||||
const [opacity, setOpacity] = useState(0)
|
|
||||||
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
// Wait for next frame to ensure element is mounted
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setOpacity(0.6)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setOpacity(0)
|
|
||||||
}
|
|
||||||
}, [isVisible])
|
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
|
||||||
if (idleTimerRef.current) {
|
|
||||||
window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setIsIdleRef(true)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
setIsIdleRef(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant = trackRef.participant
|
|
||||||
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
|
||||||
const isLocal = trackRef.participant.isLocal
|
|
||||||
|
|
||||||
const canMute = useCanMute(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '0',
|
|
||||||
top: '0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
})}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
|
||||||
{isVisible && (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
transition: 'opacity 200ms linear',
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
_hover: {
|
|
||||||
opacity: '0.95 !important',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
style={{ opacity }}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
gap={0.5}
|
|
||||||
className={css({
|
|
||||||
padding: '0.5rem',
|
|
||||||
_hover: {
|
|
||||||
opacity: '1 !important',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FocusButton trackRef={trackRef} />
|
|
||||||
{!isScreenShare ? (
|
|
||||||
<>
|
|
||||||
{participant.isLocal ? (
|
|
||||||
<EffectsButton />
|
|
||||||
) : (
|
|
||||||
canMute && <MuteButton participant={participant} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
||||||
|
|
||||||
|
export const RoomMetadataSynchronizer = () => {
|
||||||
|
useSyncLiveKitMetadata()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -5,16 +5,17 @@ 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 './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode } from 'react'
|
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '@/features/chat/components/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
|
||||||
@@ -29,103 +30,115 @@ type StyledSidePanelProps = {
|
|||||||
isReactionToolbarOpen?: boolean
|
isReactionToolbarOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||||
title,
|
(
|
||||||
ariaLabel,
|
{
|
||||||
children,
|
title,
|
||||||
onClose,
|
ariaLabel,
|
||||||
isClosed,
|
children,
|
||||||
isReactionToolbarOpen,
|
onClose,
|
||||||
closeButtonTooltip,
|
isClosed,
|
||||||
isSubmenu = false,
|
isReactionToolbarOpen,
|
||||||
onBack,
|
closeButtonTooltip,
|
||||||
backButtonLabel,
|
isSubmenu = false,
|
||||||
}: StyledSidePanelProps) => (
|
onBack,
|
||||||
<aside
|
backButtonLabel,
|
||||||
className={css({
|
},
|
||||||
borderWidth: '1px',
|
ref
|
||||||
borderStyle: 'solid',
|
) => (
|
||||||
borderColor: 'box.border',
|
<aside
|
||||||
backgroundColor: 'box.bg',
|
ref={ref}
|
||||||
color: 'box.text',
|
tabIndex={-1}
|
||||||
borderRadius: 8,
|
className={css({
|
||||||
flex: 1,
|
borderWidth: '1px',
|
||||||
position: 'absolute',
|
borderStyle: 'solid',
|
||||||
overflow: 'hidden',
|
borderColor: 'box.border',
|
||||||
display: 'flex',
|
backgroundColor: 'box.bg',
|
||||||
flexDirection: 'column',
|
color: 'box.text',
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
borderRadius: 8,
|
||||||
marginLeft: 0,
|
flex: 1,
|
||||||
marginBottom: 0,
|
position: 'absolute',
|
||||||
padding: 0,
|
overflow: 'hidden',
|
||||||
gap: 0,
|
display: 'flex',
|
||||||
right: 0,
|
flexDirection: 'column',
|
||||||
top: 0,
|
margin: 'var(--sizes-room-side-panel-margin)',
|
||||||
width: 'var(--sizes-room-side-panel)',
|
marginLeft: 0,
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
marginBottom: 0,
|
||||||
})}
|
padding: 0,
|
||||||
style={{
|
gap: 0,
|
||||||
transform: isClosed
|
right: 0,
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
top: 0,
|
||||||
: 'none',
|
width: 'var(--sizes-room-side-panel)',
|
||||||
bottom: isReactionToolbarOpen
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
'&:focus': {
|
||||||
: 'var(--sizes-room-control-bar)',
|
outline: 'none',
|
||||||
}}
|
},
|
||||||
aria-hidden={isClosed}
|
})}
|
||||||
aria-label={ariaLabel}
|
style={{
|
||||||
>
|
transform: isClosed
|
||||||
<HStack alignItems="center">
|
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||||
{isSubmenu && (
|
: 'none',
|
||||||
<Button
|
bottom: isReactionToolbarOpen
|
||||||
variant="secondaryText"
|
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||||
size="sm"
|
: 'var(--sizes-room-control-bar)',
|
||||||
square
|
}}
|
||||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
aria-hidden={isClosed}
|
||||||
aria-label={backButtonLabel}
|
aria-label={ariaLabel}
|
||||||
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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
{title}
|
||||||
</Button>
|
</Heading>
|
||||||
)}
|
</HStack>
|
||||||
<Heading
|
<Div
|
||||||
slot="title"
|
position="absolute"
|
||||||
level={1}
|
top="5"
|
||||||
className={text({ variant: 'h2' })}
|
right="5"
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
display: isClosed ? 'none' : undefined,
|
||||||
paddingTop: '1rem',
|
|
||||||
display: isClosed ? 'none' : 'flex',
|
|
||||||
justifyContent: 'start',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
<Button
|
||||||
</Heading>
|
invisible
|
||||||
</HStack>
|
variant="tertiaryText"
|
||||||
<Div
|
size="xs"
|
||||||
position="absolute"
|
onPress={onClose}
|
||||||
top="5"
|
aria-label={closeButtonTooltip}
|
||||||
right="5"
|
tooltip={closeButtonTooltip}
|
||||||
style={{
|
>
|
||||||
display: isClosed ? 'none' : undefined,
|
<RiCloseLine />
|
||||||
}}
|
</Button>
|
||||||
>
|
</Div>
|
||||||
<Button
|
{children}
|
||||||
invisible
|
</aside>
|
||||||
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
|
||||||
@@ -162,8 +175,28 @@ 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={() => {
|
||||||
@@ -185,7 +218,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isEffectsOpen}>
|
<Panel isOpen={isEffectsOpen}>
|
||||||
<Effects />
|
<Effects />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
<Panel isOpen={isChatOpen}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||||
|
|||||||
@@ -95,8 +95,13 @@ const ToolButton = ({
|
|||||||
|
|
||||||
export const Tools = () => {
|
export const Tools = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const {
|
||||||
useSidePanel()
|
openTranscript,
|
||||||
|
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
|
||||||
@@ -113,6 +118,7 @@ export const Tools = () => {
|
|||||||
},
|
},
|
||||||
restoreFocusRaf: true,
|
restoreFocusRaf: true,
|
||||||
preventScroll: true,
|
preventScroll: true,
|
||||||
|
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user