mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a2c4ce0fb | |||
| 6f30eac723 | |||
| b61c0a3bc0 | |||
| 72f40ecf48 | |||
| 10fd18caf8 | |||
| 10e8bca5a4 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 |
@@ -10,10 +10,33 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(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
|
||||
|
||||
@@ -33,6 +56,7 @@ and this project adheres to
|
||||
- 🩹(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
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
|
||||
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
|
||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
||||
|
||||
### 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
|
||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
||||
```
|
||||
|
||||
> [!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.
|
||||
|
||||
@@ -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]
|
||||
> 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"?>
|
||||
<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>
|
||||
<Version>0.0.2.0</Version>
|
||||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
@@ -205,5 +205,174 @@
|
||||
</bt:String>
|
||||
</bt:LongStrings>
|
||||
</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>
|
||||
</OfficeApp>
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "^26.3.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
data-i18n="footer.feedback"
|
||||
></a>
|
||||
<div id="footer-right">
|
||||
<span class="version-badge">beta</span>
|
||||
<span class="version-number">0.0.2</span>
|
||||
<span class="version-number">1.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.4",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class IsPresentInMeeting(permissions.BasePermission):
|
||||
"""Check that the requesting user is currently connected to the meeting.
|
||||
|
||||
The requester must be session-authenticated (their DB identity is needed
|
||||
to check privileges); presence is verified against LiveKit using their
|
||||
`sub` as participant identity. Fails closed on LiveKit errors.
|
||||
"""
|
||||
|
||||
message = "You must be connected to the meeting to perform this action."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Verify the requester's identity is a participant of the room."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -183,13 +183,12 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -312,6 +311,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate an in-meeting role change (promotion/demotion) request."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
|
||||
help_text="Target role. Ownership cannot be granted this way.",
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
@@ -639,6 +643,53 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="update-participant-role",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsPresentInMeeting,
|
||||
],
|
||||
)
|
||||
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Promote or demote a participant currently connected to the meeting.
|
||||
|
||||
Requires the requester to be session-authenticated, have privileges
|
||||
(admin/owner) on the room, and be connected to the meeting.
|
||||
|
||||
If the target participant has a user account, the role is persisted
|
||||
(`ResourceAccess`) then mirrored to their LiveKit attributes.
|
||||
|
||||
If the participant is anonymous, the promotion will fail.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.ParticipantRoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
participant_identity = serializer.validated_data["participant_identity"]
|
||||
role = serializer.validated_data["role"]
|
||||
|
||||
if str(request.user.sub) == str(participant_identity):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot change your own role."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
result = RoomRoleService().set_participant_role(
|
||||
room=room,
|
||||
participant_identity=participant_identity,
|
||||
role=role,
|
||||
actor=request.user,
|
||||
)
|
||||
except RoomRoleError as e:
|
||||
return drf_response.Response({"error": str(e)}, status=e.status_code)
|
||||
|
||||
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -162,7 +162,6 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
return participant, livekit_config
|
||||
@@ -183,7 +182,6 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Room role management service.
|
||||
|
||||
Single entry point for changing a user's role on a room, used by:
|
||||
- the in-meeting endpoint (promote/demote a connected participant)
|
||||
- (more to come soon)
|
||||
|
||||
`ResourceAccess` is the source of truth. The LiveKit `room_role`
|
||||
participant attribute is only a projection of it, synced best-effort.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from uuid import UUID
|
||||
|
||||
from core import models
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomRoleError(Exception):
|
||||
"""Base exception for room role management errors."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class SelfActionError(RoomRoleError):
|
||||
"""Raised when a user tries to change their own role."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class OwnerRoleError(RoomRoleError):
|
||||
"""Raised when trying to demote an owner or grant ownership."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class ParticipantNotInMeetingError(RoomRoleError):
|
||||
"""Raised when the target participant is not connected to the meeting."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
class UserNotFoundError(RoomRoleError):
|
||||
"""Raised when the target participant has no user account in database."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
|
||||
|
||||
|
||||
class RoomRoleService:
|
||||
"""Manage promotion and demotion of room co-hosts."""
|
||||
|
||||
def set_role(
|
||||
self, room: models.Room, user: models.User, role: str, actor: models.User
|
||||
):
|
||||
"""Persist `role` for `user` on `room`, idempotently and atomically.
|
||||
|
||||
Returns the up-to-date `ResourceAccess`. Never grants or removes
|
||||
ownership: granting OWNER is refused, and an existing OWNER access
|
||||
is never modified.
|
||||
"""
|
||||
|
||||
if role not in ASSIGNABLE_ROLES:
|
||||
raise OwnerRoleError("Ownership cannot be granted through this action.")
|
||||
|
||||
if actor is not None and user == actor:
|
||||
raise SelfActionError("You cannot change your own role.")
|
||||
|
||||
access, created = models.ResourceAccess.objects.get_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": role},
|
||||
)
|
||||
|
||||
if created:
|
||||
return access
|
||||
|
||||
if access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerRoleError("Room owners cannot be demoted.")
|
||||
|
||||
if access.role != role:
|
||||
access.role = role
|
||||
access.save(update_fields=["role", "updated_at"])
|
||||
|
||||
return access
|
||||
|
||||
def set_participant_role(
|
||||
self,
|
||||
room: models.Room,
|
||||
participant_identity: UUID,
|
||||
role: str,
|
||||
actor: models.User,
|
||||
):
|
||||
"""Change the role of a participant currently connected to the meeting.
|
||||
|
||||
- The participant must be connected (checked against LiveKit).
|
||||
- The participant must map to a user account.
|
||||
- The role is persisted in DB then mirrored to LiveKit.
|
||||
|
||||
Returns a dict: {"role", "livekit_synced"}.
|
||||
"""
|
||||
|
||||
room_name = str(room.pk)
|
||||
participants_management = ParticipantsManagement()
|
||||
|
||||
try:
|
||||
is_in_meeting = participants_management.check_if_in_meeting(
|
||||
room_name=room_name, identity=str(participant_identity)
|
||||
)
|
||||
except ParticipantNotFoundException as e:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
) from e
|
||||
|
||||
if not is_in_meeting:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
)
|
||||
|
||||
user = models.User.objects.filter(sub=participant_identity).first()
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError(
|
||||
"This participant has no user account and cannot be assigned a role."
|
||||
)
|
||||
|
||||
# Source of truth first: even if the LiveKit sync below fails,
|
||||
# the role is real and any fresh token will carry it.
|
||||
self.set_role(room=room, user=user, role=role, actor=actor)
|
||||
|
||||
livekit_synced = self._sync_livekit_role(
|
||||
room_name=room_name,
|
||||
participant_identity=str(participant_identity),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"livekit_synced": livekit_synced,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
|
||||
"""Mirror the role to the participant's LiveKit attributes.
|
||||
|
||||
Best-effort: returns False on failure instead of raising, so callers
|
||||
can report a partial success. Re-running the action re-syncs.
|
||||
"""
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=room_name,
|
||||
identity=participant_identity,
|
||||
attributes={"room_role": role},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
# The participant left between the presence check and the update:
|
||||
# harmless, the DB state (if any) remains authoritative.
|
||||
logger.info(
|
||||
"Participant %s left room %s before role sync",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
logger.exception(
|
||||
"Could not sync role to LiveKit for participant %s in room %s",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), admin_user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -278,8 +271,9 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,7 +307,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -330,8 +323,9 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -355,7 +349,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -401,7 +394,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -418,8 +410,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -493,7 +486,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -511,6 +503,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
is_authenticated=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update-participant-role.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import ResourceAccess, RoleChoices
|
||||
from ...services.participants_management import ParticipantNotFoundException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_participant_role_anonymous():
|
||||
"""Anonymous requesters are rejected."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_participant_role_requires_privileges():
|
||||
"""A simple member cannot promote other participants."""
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
|
||||
"""An admin who is not connected to the meeting is rejected."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
|
||||
room_name=str(room.pk), identity=str(user.sub)
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_target_self(mock_perm_pm):
|
||||
"""Requesters cannot change their own role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
client = APIClient()
|
||||
user = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": user.sub, "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "You cannot change your own role."}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_promotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting a connected, authenticated participant persists the role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": True,
|
||||
}
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.ADMIN
|
||||
mock_sync.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
participant_identity=str(target.sub),
|
||||
role="administrator",
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_demotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Demoting a connected admin back to member updates the access row."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_demote_owner(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Room owners can never be demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
owner = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(owner.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "Room owners cannot be demoted."}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
|
||||
)
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_anonymous_target_is_ephemeral(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an anonymous participant should not be possible."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
anonymous_identity = uuid.uuid4()
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": anonymous_identity, "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"error": "This participant has no user account and cannot be assigned a role."
|
||||
}
|
||||
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_target_not_in_meeting(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Only connected participants can be promoted or demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
|
||||
ParticipantNotFoundException("Participant does not exist")
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_is_idempotent_and_resyncs(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_livekit_failure_reports_partial_success(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""A LiveKit sync failure does not lose the persisted role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = False
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": False,
|
||||
}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
|
||||
"""The owner role can never be granted through this endpoint."""
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "owner"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -266,7 +266,6 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
@@ -305,7 +304,6 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
@@ -400,7 +398,6 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -66,8 +66,9 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
is_authenticated: Optional[bool] = False,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -80,14 +81,16 @@ def generate_token(
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
role (Optional[str]): Room's access role if any
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
is_authenticated (Optional[bool]): Is user authentified.
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -128,7 +131,7 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
{"color": color, "room_role": role, "is_authenticated": 'true' if is_authenticated else 'false'}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -139,10 +142,11 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
role: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
is_authenticated: Optional[bool] = False,
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -150,11 +154,12 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||
role (str): Room's access role if any
|
||||
color (Optional[str]): Optional color to associate with the participant.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
is_authenticated: Optional[bool]: Is user authentified.
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
@@ -173,8 +178,9 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
is_authenticated=is_authenticated,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +398,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.23.0"
|
||||
version = "1.24.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+32
-26
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,11 +15,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -29,8 +29,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
@@ -1028,9 +1028,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1049,12 +1049,18 @@
|
||||
"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",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"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": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -2415,9 +2421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2436,12 +2442,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -8119,13 +8125,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
@@ -9130,13 +9136,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.391.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.35.3",
|
||||
"@posthog/types": "^1.390.2",
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -22,11 +22,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -36,8 +36,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
|
||||
@@ -114,6 +114,10 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
|
||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
subtitles: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
subtitles: { width: '40px', height: '40px' },
|
||||
list: { width: '32px', height: '32px' },
|
||||
placeholder: { width: '100%', height: '100%' },
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
true: { border: '2px solid white' },
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -49,32 +33,35 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
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 */
|
||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
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 =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||
orientation: orientation ?? 'vertical',
|
||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||
})
|
||||
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
// Stable callback + identity check: the parent only re-renders when the
|
||||
// derived layout genuinely changed, not on every resize tick.
|
||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||
setLayout((prev) =>
|
||||
prev.orientation === next.orientation &&
|
||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}, [])
|
||||
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
<>
|
||||
<CarouselLayoutObserver
|
||||
asideEl={asideEl}
|
||||
orientation={orientation}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
/>
|
||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||
{/* 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'
|
||||
|
||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
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 */
|
||||
export interface GridLayoutProps
|
||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
||||
* @public
|
||||
*/
|
||||
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(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<GridLayoutObserver
|
||||
gridEl={gridEl}
|
||||
trackCount={tracks.length}
|
||||
onMaxTilesChange={setMaxTiles}
|
||||
/>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
{tracks.length > maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
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 Source = Track.Source
|
||||
|
||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
state,
|
||||
ref
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const pinParticipant = () => {
|
||||
layoutContext?.pin.dispatch?.({
|
||||
msg: 'set_pin',
|
||||
trackReference,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<ClickableToast
|
||||
ref={ref}
|
||||
onPress={(e) => {
|
||||
pinParticipant()
|
||||
setPinnedTrack(trackReference)
|
||||
closeButtonProps.onPress?.(e)
|
||||
}}
|
||||
>
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
|
||||
+6
-5
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { LocalParticipant } from 'livekit-client'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
@@ -17,7 +17,6 @@ export const FullScreenShareWarning = ({
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
||||
screenSharePreferences.enabled && isFullScreenSharing
|
||||
|
||||
const handleStopScreenShare = async () => {
|
||||
if (!localParticipant.isScreenShareEnabled) return
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
const participant = trackReference.participant
|
||||
if (!(participant instanceof LocalParticipant)) return
|
||||
if (!participant.isScreenShareEnabled) return
|
||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = useCallback(() => {
|
||||
+9
-7
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const Hint = styled('div', {
|
||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
||||
})
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||
* typically when an element receives keyboard focus.
|
||||
*/
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return <Hint>{children}</Hint>
|
||||
}
|
||||
export const KeyboardShortcutHint = React.memo(
|
||||
({ hint }: KeyboardShortcutHintProps) => {
|
||||
return <Hint>{hint}</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'
|
||||
+31
-103
@@ -1,39 +1,32 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
VideoTrack,
|
||||
TrackRefContext,
|
||||
ParticipantContextIfNeeded,
|
||||
useParticipantInfo,
|
||||
} from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
isTrackReferencePinned,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
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 { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -73,16 +66,12 @@ export const ParticipantTile: (
|
||||
ref
|
||||
) {
|
||||
const trackReference = useEnsureTrackRef(trackRef)
|
||||
|
||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||
htmlProps,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
|
||||
const handleSubscribe = React.useCallback(
|
||||
@@ -90,28 +79,25 @@ export const ParticipantTile: (
|
||||
if (
|
||||
trackReference.source &&
|
||||
!subscribed &&
|
||||
layoutContext &&
|
||||
layoutContext.pin.dispatch &&
|
||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||
layoutStore.pinnedTrackRef &&
|
||||
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||
) {
|
||||
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 [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 interactiveProps = {
|
||||
@@ -138,7 +124,9 @@ export const ParticipantTile: (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{trackReference.participant.isLocal && (
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
@@ -160,76 +148,16 @@ export const ParticipantTile: (
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
color={participantColor}
|
||||
displayedNamed={participantName}
|
||||
/>
|
||||
</div>
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
<ParticipantMetadata
|
||||
displayedName={participantName}
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -241,13 +169,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
{t('toolbarHint', {
|
||||
<KeyboardShortcutHint
|
||||
hint={t('toolbarHint', {
|
||||
shortcut: formatShortcutLabel(
|
||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
/>
|
||||
</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 { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
|
||||
type LowerAllHandsButtonProps = {
|
||||
participants: Array<Participant>
|
||||
}
|
||||
|
||||
export const LowerAllHandsButton = ({
|
||||
const LowerAllHandsButtonInner = ({
|
||||
participants,
|
||||
}: LowerAllHandsButtonProps) => {
|
||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
if (!isAdminOrOwner) return null
|
||||
if (!participants.length) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -36,3 +35,13 @@ export const LowerAllHandsButton = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const LowerAllHandsButton = ({
|
||||
participants,
|
||||
}: LowerAllHandsButtonProps) => {
|
||||
return (
|
||||
<AdminOrOwnerOnly>
|
||||
<LowerAllHandsButtonInner participants={participants} />
|
||||
</AdminOrOwnerOnly>
|
||||
)
|
||||
}
|
||||
+5
-7
@@ -5,7 +5,7 @@ import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
|
||||
import { getParticipantIsRoomOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
@@ -16,11 +16,11 @@ import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -93,9 +93,7 @@ type ParticipantListItemProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
return (
|
||||
@@ -148,7 +146,7 @@ export const ParticipantListItem = ({
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
{getParticipantIsRoomOwner(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
@@ -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 { useTranslation } from 'react-i18next'
|
||||
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 { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
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 = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const HandRaisedListItem = ({
|
||||
participant,
|
||||
}: HandRaisedListItemProps) => {
|
||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -69,19 +89,9 @@ export const HandRaisedListItem = ({
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
{isAdminOrOwner && (
|
||||
<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>
|
||||
)}
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -3,10 +3,10 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
export const WaitingParticipantRow = ({
|
||||
participant,
|
||||
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 type { Participant } from 'livekit-client'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { PinMenuItem } from './PinMenuItem'
|
||||
import { RemoveMenuItem } from './RemoveMenuItem'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||
|
||||
export const ParticipantMenu = ({
|
||||
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 { HStack } from '@/styled-system/jsx'
|
||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
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 }) => {
|
||||
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 (
|
||||
<MenuItem
|
||||
@@ -17,7 +30,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||
name: participant.name,
|
||||
})}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={toggle}
|
||||
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||
>
|
||||
<HStack gap={0.25}>
|
||||
{inFocus ? (
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
||||
import {
|
||||
useListWaitingParticipants,
|
||||
type WaitingParticipant,
|
||||
} from '../api/listWaitingParticipants'
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
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'
|
||||
|
||||
type PipFocusLayoutProps = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
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 { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
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'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 }) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'pictureInPicture',
|
||||
})
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const { enabled: isScreenSharing } = useTrackToggle({
|
||||
source: Track.Source.ScreenShare,
|
||||
})
|
||||
|
||||
return (
|
||||
<Container
|
||||
role="region"
|
||||
aria-label={t('stage')}
|
||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||
{...(!isScreenSharing ? { inert: '' } : {})}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||
|
||||
import {
|
||||
useSettingsDialog,
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
@@ -35,6 +34,7 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -48,8 +48,6 @@ export const TranscriptSidePanel = () => {
|
||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
|
||||
@@ -28,3 +28,6 @@ export type ApiRoom = {
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const useParticipantRole = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const updateParticipantRole = async (
|
||||
participant: Participant,
|
||||
role: AssignableParticipantRole
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
try {
|
||||
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
role: role,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { updateParticipantRole }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
@@ -67,35 +68,36 @@ export type ActiveSpeakerProps = {
|
||||
pushToTalk?: boolean
|
||||
}
|
||||
|
||||
export const ActiveSpeaker = ({
|
||||
isSpeaking,
|
||||
pushToTalk,
|
||||
}: ActiveSpeakerProps) => {
|
||||
return (
|
||||
<StyledContainer pushToTalk={pushToTalk}>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '300ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '500ms',
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
export const ActiveSpeaker = React.memo(
|
||||
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
||||
return (
|
||||
<StyledContainer pushToTalk={pushToTalk}>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '300ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
style={{
|
||||
animationDelay: '100ms',
|
||||
}}
|
||||
/>
|
||||
<StyledChild
|
||||
pushToTalk={pushToTalk}
|
||||
active={isSpeaking}
|
||||
size="small"
|
||||
style={{
|
||||
animationDelay: '500ms',
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
||||
|
||||
@@ -170,7 +170,6 @@ export const Conference = ({
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
@@ -303,13 +302,7 @@ export const Conference = ({
|
||||
}}
|
||||
>
|
||||
<VideoConference />
|
||||
{showInviteDialog && !isMobile && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
onOpenChange={setShowInviteDialog}
|
||||
onClose={() => setShowInviteDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && <InviteDialog mode={mode} />}
|
||||
<MediaDeviceErrorAlert
|
||||
{...mediaDeviceError}
|
||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
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 roomData = useRoomData()
|
||||
@@ -58,163 +60,161 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(roomData)
|
||||
|
||||
if (!showInviteDialog) return null
|
||||
return (
|
||||
<StyledRACDialog {...props}>
|
||||
{({ close }) => (
|
||||
<VStack
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
props.onClose?.()
|
||||
close()
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<StyledRACDialog>
|
||||
<VStack
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
setShowInviteDialog(false)
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'visible',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
) : (
|
||||
<RiFileCopyLine aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
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={24} style={{ marginRight: '8px' }} />
|
||||
<RiCheckLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||
<HStack>
|
||||
<div
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
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({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</StyledRACDialog>
|
||||
)
|
||||
}
|
||||
|
||||
+19
-9
@@ -1,27 +1,35 @@
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import {
|
||||
useRemoteParticipants,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-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 { 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
|
||||
|
||||
export const useConnectionObserver = () => {
|
||||
export const ConnectionObserver = () => {
|
||||
const room = useRoomContext()
|
||||
const connectionStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||
const isMobile = isMobileBrowser()
|
||||
|
||||
const isAdvancedConnectionObserverEnabled =
|
||||
useIsAdvancedConnectionObserverEnabled()
|
||||
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
|
||||
@@ -232,4 +240,6 @@ export const useConnectionObserver = () => {
|
||||
connectionStartTimeRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||
@@ -94,7 +94,6 @@ const Description = () => {
|
||||
|
||||
const Settings = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
return (
|
||||
<P>
|
||||
{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,73 +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 { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = ({
|
||||
participant,
|
||||
}: ParticipantPlaceholderProps) => {
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const participantColor = getParticipantColor(participant)
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(participantColor),
|
||||
[participantColor]
|
||||
)
|
||||
|
||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = useSize(placeholderEl)
|
||||
|
||||
const minDimension = Math.min(width, height)
|
||||
const avatarSize = useMemo(
|
||||
() => Math.min(Math.round(minDimension * 0.9), 160),
|
||||
[minDimension]
|
||||
)
|
||||
|
||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
ref={placeholderEl}
|
||||
style={{
|
||||
backgroundColor: participantColor,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
||||
height: 'auto',
|
||||
aspectRatio: '1/1',
|
||||
width: '80%',
|
||||
maxWidth: `${avatarSize}px`,
|
||||
fontSize: `${initialSize}px`,
|
||||
}}
|
||||
>
|
||||
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
||||
<Avatar
|
||||
name={participant.name}
|
||||
bgColor={participantColor}
|
||||
context="placeholder"
|
||||
/>
|
||||
</div>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
@@ -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,10 +5,10 @@ import { text } from '@/primitives/Text'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
|
||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
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 { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
@@ -218,7 +218,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
|
||||
+5
-4
@@ -11,16 +11,16 @@ import { useSnapshot } from 'valtio'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
|
||||
/**
|
||||
* This hook sets initial video quality for new participants as they join.
|
||||
* Sets initial video quality for new participants as they join.
|
||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||
*/
|
||||
export const useVideoResolutionSubscription = () => {
|
||||
export const VideoResolutionSubscription = () => {
|
||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
|
||||
const handleTrackPublished = (
|
||||
publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant
|
||||
@@ -41,9 +41,10 @@ export const useVideoResolutionSubscription = () => {
|
||||
}
|
||||
|
||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
}
|
||||
}, [room, videoSubscribeQuality])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ReceivedChatMessage } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
import type { MessageFormatter } from '@livekit/components-react'
|
||||
|
||||
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
entry: ReceivedChatMessage
|
||||
hideMetadata?: boolean
|
||||
messageFormatter?: MessageFormatter
|
||||
}
|
||||
|
||||
export const ChatEntry: (
|
||||
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLLIElement,
|
||||
ChatEntryProps
|
||||
>(function ChatEntry(
|
||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||
ref
|
||||
) {
|
||||
const formattedMessage = React.useMemo(() => {
|
||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||
}, [entry.message, messageFormatter])
|
||||
const time = new Date(entry.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
return (
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})}
|
||||
ref={ref}
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
||||
{...props}
|
||||
>
|
||||
{!hideMetadata && (
|
||||
<span
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<Text bold={true} variant="sm">
|
||||
{entry.from?.name ?? entry.from?.identity}
|
||||
</Text>
|
||||
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Button, TextArea } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
const MAX_ROWS = 6
|
||||
|
||||
interface ChatInputProps {
|
||||
inputRef: RefObject<HTMLTextAreaElement>
|
||||
onSubmit: (text: string) => void
|
||||
isSending: boolean
|
||||
}
|
||||
|
||||
export const ChatInput = ({
|
||||
inputRef,
|
||||
onSubmit,
|
||||
isSending,
|
||||
}: ChatInputProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||
const [text, setText] = useState('')
|
||||
const [rows, setRows] = useState(1)
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
const isDisabled = !text.trim() || isSending
|
||||
|
||||
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
||||
e.preventDefault()
|
||||
if (!isDisabled) handleSubmit()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (!inputRef.current) return
|
||||
|
||||
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
||||
const previousRows = inputRef.current.rows
|
||||
inputRef.current.rows = 1
|
||||
|
||||
const currentRows = Math.floor(
|
||||
inputRef.current.scrollHeight / textAreaLineHeight
|
||||
)
|
||||
|
||||
if (currentRows === previousRows) {
|
||||
inputRef.current.rows = currentRows
|
||||
}
|
||||
|
||||
if (currentRows >= MAX_ROWS) {
|
||||
inputRef.current.rows = MAX_ROWS
|
||||
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
||||
}
|
||||
|
||||
if (currentRows < MAX_ROWS) {
|
||||
inputRef.current.style.overflowY = 'hidden'
|
||||
} else {
|
||||
inputRef.current.style.overflowY = 'auto'
|
||||
}
|
||||
|
||||
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
||||
}
|
||||
|
||||
resize()
|
||||
}, [text, inputRef])
|
||||
|
||||
return (
|
||||
<HStack
|
||||
className={css({
|
||||
margin: '0.75rem 0 1.5rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: 4,
|
||||
})}
|
||||
>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
submitOnEnter(e)
|
||||
}}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
}}
|
||||
rows={rows || 1}
|
||||
style={{
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: `34px`,
|
||||
lineHeight: 1.25,
|
||||
padding: '7px 10px',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
placeholderStyle={'strong'}
|
||||
spellCheck={false}
|
||||
maxLength={2000}
|
||||
aria-label={t('textArea.label')}
|
||||
/>
|
||||
<Button
|
||||
square
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="sm"
|
||||
onPress={handleSubmit}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={t('button.label')}
|
||||
>
|
||||
<RiSendPlane2Fill />
|
||||
</Button>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
+1
-2
@@ -2,7 +2,7 @@ import { Button } from '@/primitives'
|
||||
import { RiSettings3Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
|
||||
export const SettingsButton = ({
|
||||
settingTab,
|
||||
@@ -12,7 +12,6 @@ export const SettingsButton = ({
|
||||
onPress?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -9,8 +9,8 @@ import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
@@ -169,7 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
const room = useRoomContext()
|
||||
const isSpeaking = useIsSpeaking(room.localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const DocumentationMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.documentation_url) return
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={data.documentation_url}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiBookOpenLine size={20} />
|
||||
{t('documentation')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user