Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 976abba1d6 ♻️(backend) refactor analytics backend from Protocol to abstract class
Switch the analytics backend interface from a typing Protocol to an
abstract base class, so the contract that backends must implement
is explicit and enforced at instantiation time.
2026-07-07 11:46:03 +02:00
191 changed files with 3457 additions and 6982 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "18"
- name: Restore the mail templates
uses: actions/cache@v5
-67
View File
@@ -8,64 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
## [1.24.0] - 2026-07-21
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
- ✨(all) allow forcing SSO display name for authenticated users
- (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
- ✨(addon) show add-in tools when creating meetings in shared calendars
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
- 🚸(frontend) initialize the join input name with the persisted full name
- ♻️(frontend) refactor background processors to use the new API
- ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🐛(summary) whisper call error handling
## [1.23.0] - 2026-07-08
### Added
- ✨(backend) extend analytics module to support feature flags
- ✨(backend) implement feature flags in Posthog analytics backend
- ✨(agents) report errors to Sentry for all LiveKit agents
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
@@ -73,14 +15,6 @@ and this project adheres to
- 💥(summary) remove v1 related code #1362
- ✨(meet) use compatible with summary v2 #1362
- ♻️(backend) refactor analytics backend from Protocol to abstract class
- 🔥(summary) remove call to summary enabled feature flag
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
- ⬆️(frontend) update the frontend build image to Node 22
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
- ✨(summary) more precise analytics events
### Fixed
@@ -100,7 +34,6 @@ and this project adheres to
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed
+1 -1
View File
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:22 AS mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
-12
View File
@@ -15,15 +15,3 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
-7
View File
@@ -113,12 +113,6 @@ update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Run uv lock in agents
print_info "Running uv lock in agents..."
cd "src/agents"
uv lock
cd -
# Update CHANGELOG
print_info "Updating CHANGELOG..."
@@ -164,7 +158,6 @@ 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:"
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ----
FROM node:22-alpine AS frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
USER root
-1
View File
@@ -344,7 +344,6 @@ 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
View File
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
### How to Use
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
```javascript
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
FRONTEND_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_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
}
```
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
> [!IMPORTANT]
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
@@ -9,6 +9,3 @@ DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
+1 -170
View File
@@ -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>1.0.0.0</Version>
<Version>0.0.2.0</Version>
<ProviderName>__APP_NAME__</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="__APP_NAME__"/>
@@ -205,174 +205,5 @@
</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>
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "^26.3.2",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9364,9 +9364,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.49.0",
"i18next": "26.3.2",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -57,7 +57,8 @@
data-i18n="footer.feedback"
></a>
<div id="footer-right">
<span class="version-number">1.0.0</span>
<span class="version-badge">beta</span>
<span class="version-number">0.0.2</span>
</div>
</footer>
</body>
+10 -26
View File
@@ -32,8 +32,6 @@ from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
@@ -44,7 +42,6 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(AGENT_NAME)
proc.userdata["vad"] = silero.VAD.load()
@@ -177,13 +174,7 @@ class MetadataCollector:
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"process chat stream from {participant_identity}",
)
)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Serialize collected events and upload as JSON to S3."""
@@ -279,18 +270,16 @@ class MetadataCollector:
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close VAD session for {participant.identity}",
on_success=lambda _: logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
),
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
@@ -371,8 +360,6 @@ async def handle_job_request(job_req: JobRequest) -> None:
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
@@ -390,7 +377,4 @@ async def entrypoint(ctx: JobContext):
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(AGENT_NAME)
cli.run_app(server)
+10 -25
View File
@@ -25,9 +25,6 @@ from livekit.agents import (
)
from livekit.plugins import deepgram, silero
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
logger = logging.getLogger("transcriber")
@@ -102,29 +99,24 @@ class MultiUserTranscriber:
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"start transcription session for {participant.identity}",
)
)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity, None)) is None:
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close transcription session for {participant.identity}",
)
)
task.add_done_callback(lambda _: self._tasks.discard(task))
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
@@ -147,7 +139,6 @@ class MultiUserTranscriber:
participant_identity=participant.identity,
)
)
self._sessions[participant.identity] = session
return session
async def _close_session(self, sess: AgentSession) -> None:
@@ -158,8 +149,6 @@ class MultiUserTranscriber:
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
@@ -204,15 +193,11 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(TRANSCRIBER_AGENT_NAME)
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(TRANSCRIBER_AGENT_NAME)
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
-83
View File
@@ -1,83 +0,0 @@
"""Sentry helpers for the LiveKit agents."""
import logging
import os
import tomllib
from os import path
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
logger = logging.getLogger("observability")
BASE_DIR = path.dirname(path.abspath(__file__))
def get_release():
"""Get the current release of the application.
By release, we mean the ``version`` declared in ``pyproject.toml``.
If the file cannot be read or declares no version, it defaults to "NA".
"""
try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
def configure_sentry(agent_name: str) -> None:
"""Initialize Sentry for the current agent process.
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
unconditionally so the calling process gets its own live transport.
Must be called once per process: in the worker entrypoint and again in the
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
forked process. A forked child inherits the parent's initialized Sentry
client but not its background transport thread (threads do not survive
``fork()``), so it must re-init to get a working transport. For that reason,
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
as ``True`` and would skip init, silently dropping every event.
Args:
agent_name: Identifier of the agent, attached as a tag to Sentry issues
"""
# Read the DSN at call time so it picks up variables that load_dotenv()
# populated after this module was first imported.
sentry_dsn = os.getenv("SENTRY_DSN")
if not sentry_dsn:
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
return
sentry_sdk.init(
dsn=sentry_dsn,
environment=os.getenv("SENTRY_ENVIRONMENT"),
release=get_release(),
debug=False,
integrations=[
# Capture log records emitted at ERROR and above as Sentry events.
# This covers the agents' explicit logger.exception(...) calls as
# well as asyncio's "Exception in callback" / "Task exception was
# never retrieved" records, so unhandled task failures surface too.
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
sentry_sdk.set_tag("application", "agents")
sentry_sdk.set_tag("agent", agent_name)
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
"""Tag the current Sentry scope with the LiveKit job being handled.
Args:
room: Name of the room the job is serving.
job_id: LiveKit job identifier.
"""
scope = sentry_sdk.get_current_scope()
if room is not None:
scope.set_tag("room", room)
if job_id is not None:
scope.set_tag("job_id", job_id)
+3 -4
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.24.0"
version = "1.22.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.4",
@@ -9,9 +9,8 @@ dependencies = [
"livekit-plugins-silero==1.6.4",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.60.0",
"protobuf>=6.33.5",
"minio==7.2.20"
]
[project.optional-dependencies]
-42
View File
@@ -1,42 +0,0 @@
"""Helpers for managing asyncio tasks."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
def done_callback(
logger: logging.Logger,
tasks: set[asyncio.Task],
description: str,
*,
on_success: Callable[[Any], None] | None = None,
) -> Callable[[asyncio.Task], None]:
"""Build a done-callback for a background task.
Meant to be passed to `asyncio.Task.add_done_callback`.
Args:
logger: Logger used to report failures, so records keep the caller's
logger name.
tasks: Set the task was registered in; the task is discarded from it.
description: Human-readable intended action
on_success: Optional callback invoked with the task's result when it
completes without error.
Returns:
A callback suitable for ``task.add_done_callback(...)``.
"""
def _finalize(task: asyncio.Task) -> None:
tasks.discard(task)
if task.cancelled():
return
if (exc := task.exception()) is not None:
logger.exception("failed to %s", description, exc_info=exc)
return
if on_success is not None:
on_success(task.result())
return _finalize
+117 -148
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.24.0"
version = "1.22.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
@@ -19,7 +19,6 @@ dependencies = [
{ name = "minio" },
{ name = "protobuf" },
{ name = "python-dotenv" },
{ name = "sentry-sdk" },
]
[package.optional-dependencies]
@@ -34,10 +33,9 @@ requires-dist = [
{ name = "livekit-plugins-kyutai-lasuite", specifier = "==0.0.6" },
{ name = "livekit-plugins-silero", specifier = "==1.6.4" },
{ name = "minio", specifier = "==7.2.20" },
{ name = "protobuf", specifier = "==6.33.6" },
{ name = "protobuf", specifier = ">=6.33.5" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.19" },
{ name = "sentry-sdk", specifier = "==2.60.0" },
]
provides-extras = ["dev"]
@@ -292,148 +290,132 @@ wheels = [
[[package]]
name = "cffi"
version = "2.1.0"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" },
{ url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" },
{ url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" },
{ url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" },
{ url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" },
{ url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" },
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.8"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" },
{ url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" },
{ url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" },
{ url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" },
{ url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" },
{ url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" },
{ url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" },
{ url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" },
{ url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" },
{ url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" },
{ url = "https://files.pythonhosted.org/packages/11/49/fe5a8572a70cd9cba79f80af9388ac8c5c914ed4459b956f940244e499a5/charset_normalizer-3.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:057f8609f7341618c98e5aa9a6109fa116acff2a658497d47ab3325b5e8f2b08", size = 317424, upload-time = "2026-07-06T15:26:32.23Z" },
{ url = "https://files.pythonhosted.org/packages/e5/59/d71c96616b6825425a876f79f38fa440db30b32cc1166179a839f6259150/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c591f9a82adc5b89a039b90df74e43de2b9177fb46771172bed7b80722a70db0", size = 214723, upload-time = "2026-07-06T15:26:33.635Z" },
{ url = "https://files.pythonhosted.org/packages/c6/35/dc9eeb297f19b7b6ada39709ccb74937e6c51f0947958ae601a977cedd5d/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b56f449132d9adefe55b87635d05177a914ed5d070438a74725e1d77a280002", size = 236200, upload-time = "2026-07-06T15:26:34.99Z" },
{ url = "https://files.pythonhosted.org/packages/0a/37/6775fe852b4acad8bf7e0575fbe8aa9f41b546e33251acbded3c04a6b0d9/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1004c3b5831a301dadfb9e916f38e78e2ff3e08db24a1ad7c354db8ee3dea9c3", size = 231740, upload-time = "2026-07-06T15:26:36.498Z" },
{ url = "https://files.pythonhosted.org/packages/e4/28/1bcc3f5f3bac81532384adcfcdd9362c7f46a188a19deacc1ddaf7bdaa00/charset_normalizer-3.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60883e22821d17c9e5b4f3ca1ef8074f766e3db28791f851b665929c515635c0", size = 222888, upload-time = "2026-07-06T15:26:38.161Z" },
{ url = "https://files.pythonhosted.org/packages/47/81/9f3993ca62ef090c58059da641e49e3129e74700a6a3beb58436cdb8d4b9/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:21e4dbb942c8a6342e2685f232dd2a7bc73465697bd26ead4f118271d28be383", size = 207649, upload-time = "2026-07-06T15:26:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/11/f4/679f636bcbdc2d53d06b1f4039be310450dca95a9f76bbf22f09985556e8/charset_normalizer-3.4.8-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84bcae14c65e645ca66b661339183d32b8c846a17c96e3e81ab3d346e1c498d4", size = 218908, upload-time = "2026-07-06T15:26:40.911Z" },
{ url = "https://files.pythonhosted.org/packages/7e/44/96e8c81867ba8a45ff893c8e7474c2d6b9633f7aa663da7901d040214d3e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b4e4d44b8287aa13a25e16e29393d494b0643b24894f7c8266c6f6788dd36337", size = 217096, upload-time = "2026-07-06T15:26:42.148Z" },
{ url = "https://files.pythonhosted.org/packages/a5/bf/4d53f04f29bdb22601701f4f9f4d038edfb27976c296fcb7400c02736a6e/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f68545d1b267dbfafd5d253b6d1cb161562c4e61ab25b5c4cdb7d9e5923e441e", size = 209355, upload-time = "2026-07-06T15:26:43.557Z" },
{ url = "https://files.pythonhosted.org/packages/ad/60/92b3f630798d777fa880ad289a3f9f2fc663e4b4beb24783c53318820254/charset_normalizer-3.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1936e48214adea74922a20c8ab41b1393ae27cc9e329eb1f0b937d3416824f36", size = 224732, upload-time = "2026-07-06T15:26:44.892Z" },
{ url = "https://files.pythonhosted.org/packages/bd/85/eafa0a3c7bb6fe9f02f4c7901f02071933cac85ee634197e17280818c6de/charset_normalizer-3.4.8-cp313-cp313-win32.whl", hash = "sha256:1f8e3521860187d597f3867d8466da225b9179ea2833bb26de1bb026144d07c3", size = 150358, upload-time = "2026-07-06T15:26:46.153Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8c/879fafff7b47bb1166d289f2d2472cb31b9922f9f4ca1f392edf85ec16be/charset_normalizer-3.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:8b654b6f52a0a9a6be38e88f3e1dc68f1093ebeb2abbadafc7c82da0786a34be", size = 161685, upload-time = "2026-07-06T15:26:47.423Z" },
{ url = "https://files.pythonhosted.org/packages/c3/46/b57c7e778a7b578f28d35fd38544687d4f8d9c019585eebc5ad936073fad/charset_normalizer-3.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:d2d5a250ee26e29468b7607d97479221b069fa8aaf6f929ac84ec0e962e15154", size = 152333, upload-time = "2026-07-06T15:26:48.689Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/0a8540b8cd494951cca1428606373942803f5ffcec40fe798f819c5a8adb/charset_normalizer-3.4.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:77e993ecf65f21ab1f82266ff5e84a7de2c879e7d9b8bc006009df83f22a1d5e", size = 316993, upload-time = "2026-07-06T15:26:49.962Z" },
{ url = "https://files.pythonhosted.org/packages/0e/99/a0868f0a1f0a045fd374d1f2cf7042d8ad5d7fb4dd1f4ac7365e319f7e32/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:524939917f17f6de502dfda30b472550965740d7f126659d4c4f8dd1569cce22", size = 215638, upload-time = "2026-07-06T15:26:51.338Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e9/43c4d09a09b5557cc5fe1d87c9d96f86a3942aec0517d2b5408cef87ca75/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4508989ba8e2ce43ef989453d18188b261546e8188cbdd4ef451fb9e4c3b467", size = 236456, upload-time = "2026-07-06T15:26:52.531Z" },
{ url = "https://files.pythonhosted.org/packages/e2/67/492ca98b3ab785b736b5da10c1bc233e1c8fec6c0cdb29b482c38bfc52a2/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9e44127f7d11eee4548ad2cdf1f4e1b6eaaddd5cb92d15ad65f6ecc9bcf403ab", size = 232253, upload-time = "2026-07-06T15:26:53.838Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/1e6eff58c14f1aace1e26d80defbeaea2d35e075dbe4b611111ee4b47fa8/charset_normalizer-3.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb90317359f7e67bb6df615999a95e0980877468e617ddce8b6c2f8e7fe60d95", size = 222886, upload-time = "2026-07-06T15:26:55.009Z" },
{ url = "https://files.pythonhosted.org/packages/40/7a/90056a5326b0c4b9a3f924d337729c344c11542e5bc7191e50410db61587/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:35d9e7a9c960520ae89d1f4e305d1c047a74dea2e0f73a0e84f879356c2e8776", size = 206482, upload-time = "2026-07-06T15:26:56.306Z" },
{ url = "https://files.pythonhosted.org/packages/18/ff/94761d31a33878dbb5008ddbd918615061fcf5c0a612aa3075450e60f628/charset_normalizer-3.4.8-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e322b054c7ff886f78feab7360736bb45de2e18cf4a0ee84e8fc5a08d53a19", size = 218929, upload-time = "2026-07-06T15:26:57.422Z" },
{ url = "https://files.pythonhosted.org/packages/2b/dc/00b9675acd7c4b926b9102ee3f0d1a570ce943901be73b87485001393fe1/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c0086d97094363556206dc3bcf43f7edcfc043ea7a568a46f45efea74858bd1", size = 218069, upload-time = "2026-07-06T15:26:58.719Z" },
{ url = "https://files.pythonhosted.org/packages/04/11/94ada5a0482ee4bf688d04be4c7d6fd945d37370d04a95671040dfe2b416/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0752c849b51198267df2aba013c4de3a2955bd014a4fd70828809946c1acbc0c", size = 207146, upload-time = "2026-07-06T15:27:00.058Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/246bd36762207ab4752cd436b64e5d81a1668b15ddea7b5b2d0e8545e727/charset_normalizer-3.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2a4707e09eca11e81ece4fced600c5a0a801f568b962244f6f517bc274745fc9", size = 224896, upload-time = "2026-07-06T15:27:01.599Z" },
{ url = "https://files.pythonhosted.org/packages/6f/f7/3510622d1fbe13b0ebf827c475e40a27e2be427140d792878b63ab6425cc/charset_normalizer-3.4.8-cp314-cp314-win32.whl", hash = "sha256:8ea67f427c073ae3da0923aa55f3715131fa613a61a7f2f8d762bde75eaf00ae", size = 150851, upload-time = "2026-07-06T15:27:02.964Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/9ce65dd21672b55cf800cca5f4433afa1586fda1d78731067ec9ec544c62/charset_normalizer-3.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ff71018850863362e5c7533769d0a9f77715c31af1502d523630ce822922f5c9", size = 162549, upload-time = "2026-07-06T15:27:04.249Z" },
{ url = "https://files.pythonhosted.org/packages/2f/34/9a5967eed666a88f31a0866884606d9ec3c2cd6091e2ccd7e0b4c4176c35/charset_normalizer-3.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:44464e66f4da2f21dea7145c7693f9f60717ca4794a954dea5bf8c2c932678bd", size = 153079, upload-time = "2026-07-06T15:27:05.608Z" },
{ url = "https://files.pythonhosted.org/packages/02/4f/aa44cc81d8987f105352c74c0bf919007f8b80e9880d28bcf0393c1a816e/charset_normalizer-3.4.8-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50a0c2e58ad2c203adb616fef28941b7e13716adbc25e0dfaeec29f5afe6382f", size = 338586, upload-time = "2026-07-06T15:27:06.86Z" },
{ url = "https://files.pythonhosted.org/packages/1d/2b/b0392e2b235c08ff0623d905c2ee8ac820620544043c1ce92ce0b3d64c55/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1e589fdb95c76f08288bbb346230cdd8994db74903db6637b380f7b5fc9336", size = 222764, upload-time = "2026-07-06T15:27:08.23Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/7d466879190731f5559662c22232646f2ae2dace2323c3e5aefcf78d458a/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3d7c887444c5a7ef0d68d358d81e758a850bc626f8e639e2ca5667153272b20", size = 241331, upload-time = "2026-07-06T15:27:09.512Z" },
{ url = "https://files.pythonhosted.org/packages/70/17/8b89e797137aa28c8fb0bafbafc243246a7afe21620a13b00e37624ece1d/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:65c389b96c0cfff3a3f0458fa1c7ce554a30e23101a88a49f03997afce6a929f", size = 239323, upload-time = "2026-07-06T15:27:10.86Z" },
{ url = "https://files.pythonhosted.org/packages/d7/98/1c1940730ed22d50983be4e243c722c89d5136d6f073bd840d1128bfddcb/charset_normalizer-3.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593403fc47dcdf55e2987b2e3cc2e064127e2b908929f1f18b2e4a4652cbd780", size = 229964, upload-time = "2026-07-06T15:27:12.113Z" },
{ url = "https://files.pythonhosted.org/packages/53/2d/bb8e81b7ff603d3f77e9a8a5d1ad34fcabbf3c54d300c29d99fba581fa23/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:606088e9fa2b7469ab9c42d4da8e05a415622a07714edd2fcd8fed48dda4c853", size = 212405, upload-time = "2026-07-06T15:27:13.447Z" },
{ url = "https://files.pythonhosted.org/packages/ce/1f/e52a3a53b13da591bb8f21d29e63877268eadf20686b7762351d4b89062c/charset_normalizer-3.4.8-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0317406326fed512f42a1632ad91a96228a7616c06547666a6dd79967f1bd6ca", size = 226918, upload-time = "2026-07-06T15:27:14.89Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f9/32996d79c57189af9722fe618f46d8a86b7be035ca98887b8d0c3821f141/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b67d50ee47e5c57a0064a9cb575b963a7125819dfd1fd094d44d378fff94659b", size = 225113, upload-time = "2026-07-06T15:27:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d2/9248c18e695696513774523a794cfb8b677521ce9ad7554d301cb10a9b20/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79e402b869f270140afa5e2b0e2ac100585358d812fe3dd093d424f7a72964e0", size = 214966, upload-time = "2026-07-06T15:27:17.418Z" },
{ url = "https://files.pythonhosted.org/packages/1e/9d/4b19432d406179a40f924691906ee5b15ac664b408971c973295192444ea/charset_normalizer-3.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2970b9f7ab69ec3a0423ec6b6ac718e79fbf4a282c0bc103ef88c1ef50dfa15a", size = 231699, upload-time = "2026-07-06T15:27:19.131Z" },
{ url = "https://files.pythonhosted.org/packages/be/41/bdbdf71e8c3ccff10ef3cc2bb9467a7fdb3dc94b9a406d1a3c44afd39632/charset_normalizer-3.4.8-cp314-cp314t-win32.whl", hash = "sha256:458c2972a78043b7261c9726670029f15f722e70669bcbe961153a01968f589f", size = 155333, upload-time = "2026-07-06T15:27:20.681Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f8/e05c69323bd50091ec39f5f885385b884624b0131a6885a0c83a6217ba7a/charset_normalizer-3.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:0c926329a1df7cd56d7d8349fe354460d20aefd2e394c9e159e479d018b2b359", size = 167378, upload-time = "2026-07-06T15:27:22.042Z" },
{ url = "https://files.pythonhosted.org/packages/c2/04/cbaf1a2f5e2bbf70760e774380cbf052b10849fc35e770905df31af5cf00/charset_normalizer-3.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:2232baea80a2b01783679fed4e625ccdb19a974f44c9cf0fba21a777a4c8179c", size = 157782, upload-time = "2026-07-06T15:27:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" },
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
@@ -1776,19 +1758,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
+1 -8
View File
@@ -402,14 +402,7 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = (
"id",
"status",
-8
View File
@@ -19,7 +19,6 @@ from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
__all__ = [
"get_analytics",
@@ -27,8 +26,6 @@ __all__ = [
"capture",
"AnalyticsBackend",
"AnalyticsEvent",
"is_user_feature_flag_enabled",
"UserFeatureFlag",
]
@@ -60,8 +57,3 @@ def capture(
) -> None:
"""Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties)
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
"""Check if a feature is enabled at the user level."""
return analytics_instance.is_user_feature_enabled(user, feature_name)
+1 -17
View File
@@ -1,11 +1,10 @@
"""Analytics backend protocol and default no-op implementation."""
from abc import ABC, abstractmethod
from typing import Any, Mapping
from typing import Any
from ..models import User
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
class AnalyticsBackend(ABC):
@@ -36,21 +35,6 @@ class AnalyticsBackend(ABC):
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
def get_user_feature_flags(
self,
user: User, # pylint: disable=unused-argument
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return a dict of feature flags for the given user."""
# We return an empty dict here by default to avoid a breaking change
# By making this method abstract.
return {}
def is_user_feature_enabled(
self, user: User, feature_name: UserFeatureFlag
) -> bool:
"""Check if a feature is enabled at the user level."""
return self.get_user_feature_flags(user).get(feature_name, False) is True
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
+2 -43
View File
@@ -1,16 +1,13 @@
"""PostHog implementation of the analytics backend protocol."""
import logging
from typing import Any, Mapping
from django.core.cache import cache
from typing import Any
from posthog import Posthog
from ..models import User
from .base import AnalyticsBackend
from . import AnalyticsBackend
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
logger = logging.getLogger(__name__)
@@ -23,8 +20,6 @@ class PostHogAnalytics(AnalyticsBackend):
*,
api_key: str,
host: str = "https://eu.i.posthog.com",
feature_flags_cache_ttl: int = 60,
feature_flags_cache_prefix: str = "user_feature_flags:",
**kwargs: Any,
) -> None:
@@ -35,8 +30,6 @@ class PostHogAnalytics(AnalyticsBackend):
host=host,
**kwargs,
)
self._feature_flags_cache_ttl = feature_flags_cache_ttl
self._feature_flags_cache_prefix = feature_flags_cache_prefix
@staticmethod
def _distinct_id(user: User) -> str | None:
@@ -80,37 +73,3 @@ class PostHogAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
self._client.shutdown()
def _fetch_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Compute feature flags for a user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
flags = self._client.evaluate_flags(distinct_id)
out: dict[UserFeatureFlag, bool | str | None] = {}
for flag_key in UserFeatureFlag:
out[flag_key] = flags.get_flag(flag_key.value)
return out
def get_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Get feature flags for a user. Caches the result for a short time."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
try:
return cache.get_or_set(
f"{self._feature_flags_cache_prefix}{distinct_id}",
default=lambda: self._fetch_user_feature_flags(user),
timeout=self._feature_flags_cache_ttl,
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to get feature flags for user %s", user.pk)
return {}
@@ -1,9 +0,0 @@
"""Catalog of all analytics feature flags used by the backend."""
from enum import StrEnum
class UserFeatureFlag(StrEnum):
"""All feature flags configured in the app."""
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
-3
View File
@@ -68,9 +68,6 @@ def get_frontend_configuration(request):
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
),
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
-32
View File
@@ -6,11 +6,6 @@ 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"}
@@ -171,30 +166,3 @@ 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
+3 -11
View File
@@ -183,12 +183,13 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user,
username=username,
configuration=output["configuration"],
role=role,
is_authenticated=request.user.is_authenticated,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
return output
@@ -311,15 +312,6 @@ 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"]
-55
View File
@@ -88,15 +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
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
from . import permissions, serializers, throttling
from .feature_flag import FeatureFlag
@@ -272,9 +267,6 @@ class RoomViewSet(
username = request.query_params.get("username", None)
data = {
"id": None,
"slug": slug,
"is_administrable": False,
"access_level": RoomAccessLevel.PUBLIC,
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
@@ -643,53 +635,6 @@ 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"],
@@ -215,6 +215,5 @@ class RoomViewSet(
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
@@ -18,7 +18,6 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -372,9 +371,8 @@ class NotificationService:
),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"form_link": form_link,
"auto_create_summary": is_user_feature_flag_enabled(
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
# For now the feature flag logic is handled on summary side
"auto_create_summary": True,
},
"metadata": metadata_payload,
}
+2
View File
@@ -162,6 +162,7 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -182,6 +183,7 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
-178
View File
@@ -1,178 +0,0 @@
"""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
@@ -12,7 +12,6 @@ import pytest
from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@@ -249,60 +248,6 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# feature flags
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
"""Should map every declared feature flag key to the SDK evaluated value."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
True
)
flags = backend._fetch_user_feature_flags(user)
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
)
@patch("core.analytics.posthog.cache.get_or_set")
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_uses_cache_get_or_set(
mock_posthog_cls, mock_cache_get_or_set
):
"""Should cache feature flags by user distinct id with configured TTL."""
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
mock_cache_get_or_set.return_value = cached_flags
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
user = UserFactory()
flags = backend.get_user_feature_flags(user)
assert flags == cached_flags
mock_cache_get_or_set.assert_called_once()
args, kwargs = mock_cache_get_or_set.call_args
assert kwargs["timeout"] == 120
assert args[0] == f"user_feature_flags:{user.pk}"
assert callable(kwargs["default"])
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
"""Should swallow failures and return an empty mapping."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
assert backend.get_user_feature_flags(user) == {}
# ==============================
# shutdown
# ==============================
@@ -13,7 +13,6 @@ from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.analytics import UserFeatureFlag
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@@ -313,7 +312,7 @@ def test_notify_summary_service_post_args_with_metadata(
"user_email": owner.email,
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": False,
"auto_create_summary": True,
"form_link": None,
},
"metadata": {
@@ -344,12 +343,10 @@ def test_notify_summary_service_post_args_with_metadata(
@mock.patch.object(
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
)
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
def test_notify_summary_service_post_args_without_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
auto_create_summary_enabled,
settings,
):
"""Test summary notification computed request args when metadata is not available."""
@@ -379,11 +376,7 @@ def test_notify_summary_service_post_args_without_metadata(
mock_response.json.return_value = {"job_id": "job-51"}
mock_post.return_value = mock_response
with mock.patch(
"core.recording.event.notification.is_user_feature_flag_enabled",
return_value=auto_create_summary_enabled,
) as mock_is_feature_flag_enabled:
result = NotificationService._notify_summary_service(recording)
result = NotificationService._notify_summary_service(recording)
assert result is True
expected_payload = {
@@ -396,7 +389,7 @@ def test_notify_summary_service_post_args_without_metadata(
"user_email": owner.email,
"title": "Transcription",
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
"auto_create_summary": auto_create_summary_enabled,
"auto_create_summary": True,
"form_link": None,
},
"metadata": None,
@@ -415,6 +408,3 @@ def test_notify_summary_service_post_args_without_metadata(
recording.key, expires_in=60 * 60 * 24, override_domain=False
)
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@@ -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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
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)
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
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)
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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)
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
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 RoleChoices, RoomAccessLevel
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -31,6 +31,7 @@ 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,
}
@@ -50,6 +51,7 @@ 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,
}
@@ -68,6 +70,7 @@ 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,
}
@@ -84,6 +87,7 @@ 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,
}
@@ -100,6 +104,7 @@ 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,
}
@@ -125,9 +130,6 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "unregistered-room",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "unregistered-room",
@@ -160,9 +162,6 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
assert response.status_code == 200
assert response.json() == {
"id": None,
"slug": "reunion",
"access_level": "public",
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": "reunion",
@@ -209,6 +208,7 @@ 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,
@@ -255,6 +255,7 @@ 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,
@@ -271,9 +272,8 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None,
color=None,
sources=["camera"],
role=None,
is_admin_or_owner=False,
participant_id=None,
is_authenticated=True,
)
@@ -307,6 +307,7 @@ 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,
@@ -323,9 +324,8 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None,
color=None,
sources=None,
role=None,
is_admin_or_owner=False,
participant_id=None,
is_authenticated=True,
)
@@ -349,6 +349,7 @@ def test_api_rooms_retrieve_authenticated():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -394,6 +395,7 @@ 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,
@@ -410,9 +412,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None,
color=None,
sources=["camera"],
role=str(RoleChoices.MEMBER),
is_admin_or_owner=False,
participant_id=None,
is_authenticated=True,
)
@@ -486,6 +487,7 @@ 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",
@@ -503,7 +505,6 @@ def test_api_rooms_retrieve_administrators(
username=None,
color=None,
sources=None,
role=str(user_access.role),
is_admin_or_owner=True,
participant_id=None,
is_authenticated=True,
)
@@ -1,319 +0,0 @@
"""
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,6 +266,7 @@ def test_request_entry_public_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
@@ -304,6 +305,7 @@ def test_request_entry_trusted_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
@@ -398,6 +400,7 @@ 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)
+1 -97
View File
@@ -2,109 +2,13 @@
Test utils functions
"""
# pylint: disable=W0621
import json
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
import jwt
import pytest
from livekit.api import TwirpError
from core.factories import UserFactory
from core.utils import (
NotificationError,
create_livekit_client,
generate_token,
notify_participants,
)
pytestmark = pytest.mark.django_db
def decode_token(token: str) -> dict:
"""Decode a LiveKit JWT access token for inspection."""
return jwt.decode(
token,
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
)
def test_generate_token_authenticated_uses_full_name():
"""The token's display name should default to the user's full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
assert claims["sub"] == str(user.sub)
def test_generate_token_authenticated_fallback_user_representation():
"""
When the user has no full name, the token's display name should fall back
to the user's string representation.
"""
user = UserFactory(full_name=None)
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == str(user)
def test_generate_token_explicit_username_overrides_default():
"""An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Custom Name"
def test_authenticated_username_ignored_when_editing_disabled(settings):
"""With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user, username="Custom Name")
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
"""Disabling editing doesn't disturb the default full-name path."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe")
token = generate_token(room="my-room", user=user)
claims = decode_token(token)
assert claims["name"] == "Jane Doe"
def test_anonymous_uses_username_when_provided():
"""An anonymous user's provided username is used as the display name."""
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_username_used_even_when_editing_disabled(settings):
"""The setting governs authenticated users only; anonymous can still set a name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
claims = decode_token(token)
assert claims["name"] == "Guest42"
def test_anonymous_falls_back_to_anonymous_label():
"""With no username, an anonymous user is labelled 'Anonymous'."""
token = generate_token(room="my-room", user=AnonymousUser())
claims = decode_token(token)
assert claims["name"] == "Anonymous"
from core.utils import NotificationError, create_livekit_client, notify_participants
@mock.patch("asyncio.get_running_loop")
+8 -19
View File
@@ -66,9 +66,8 @@ def generate_token(
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
role: Optional[str] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
is_authenticated: Optional[bool] = False,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -81,16 +80,14 @@ 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.
role (Optional[str]): Room's access role if any
is_admin_or_owner (bool): Whether user has admin privileges
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
@@ -112,16 +109,11 @@ def generate_token(
default_username = "Anonymous"
else:
identity = str(user.sub)
default_username = user.full_name or str(user)
default_username = str(user)
if color is None:
color = generate_color(identity)
can_edit = (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
)
display_name = (username or default_username) if can_edit else default_username
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -129,9 +121,9 @@ def generate_token(
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(display_name)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_role": role, "is_authenticated": 'true' if is_authenticated else 'false'}
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
@@ -142,11 +134,10 @@ def generate_livekit_config(
room_id: str,
user,
username: str,
role: Optional[str] = None,
is_admin_or_owner: bool,
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.
@@ -154,12 +145,11 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
role (str): Room's access role if any
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
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
@@ -178,9 +168,8 @@ def generate_livekit_config(
username=username,
color=color,
sources=sources,
role=role,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
is_authenticated=is_authenticated,
),
}
+1 -24
View File
@@ -13,7 +13,6 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines
import json
import warnings
from os import path
from socket import gethostbyname, gethostname
@@ -398,9 +397,6 @@ 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
),
@@ -674,11 +670,6 @@ class Base(Configuration):
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
environ_prefix=None,
)
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
True,
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
environ_prefix=None,
)
# Recording settings
RECORDING_ENABLE = values.BooleanValue(
@@ -1137,25 +1128,11 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
):
warnings.warn(
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
"The legacy v1 API has been removed from the experimental "
"Summary service. Please update your Summary service deployment to "
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
# We use UserWarning to make sure it shows up in production deployment
UserWarning,
stacklevel=2,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.24.0"
version = "1.22.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.24.0"
version = "1.22.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS frontend-deps
FROM node:20-alpine AS frontend-deps
USER node
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
USER root
+54 -239
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.24.0",
"version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.24.0",
"version": "1.22.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
@@ -15,11 +15,10 @@
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@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.1",
"@tanstack/react-query": "5.100.14",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
@@ -29,14 +28,14 @@
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"livekit-client": "2.19.0",
"posthog-js": "1.386.5",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -64,7 +63,6 @@
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
}
},
@@ -112,62 +110,6 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.49.0",
"react-stately": "3.47.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1028,9 +970,9 @@
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.46.6",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@@ -1049,18 +991,12 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"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",
@@ -1816,9 +1752,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -2421,9 +2357,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"version": "5.100.14",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2442,12 +2378,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"version": "5.100.14",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.1"
"@tanstack/query-core": "5.100.14"
},
"funding": {
"type": "github",
@@ -3256,19 +3192,6 @@
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
@@ -7171,19 +7094,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-boolean-object": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@@ -8125,13 +8035,13 @@
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.46.6",
"@livekit/protocol": "1.45.8",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
@@ -8765,19 +8675,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-manager-detector": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
@@ -9136,19 +9033,19 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.395.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
"version": "1.386.5",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "^1.38.0",
"@posthog/types": "^1.391.1",
"@posthog/core": "^1.32.3",
"@posthog/types": "^1.386.3",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
"preact": "^10.29.2",
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0"
"web-vitals": "^5.1.0"
}
},
"node_modules/powershell-utils": {
@@ -9165,21 +9062,13 @@
}
},
"node_modules/preact": {
"version": "10.29.7",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
"version": "10.28.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prelude-ls": {
@@ -9386,19 +9275,19 @@
}
},
"node_modules/react-aria": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -9407,17 +9296,17 @@
}
},
"node_modules/react-aria-components": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.50.0",
"react-stately": "3.48.0"
"react-aria": "3.49.0",
"react-stately": "3.47.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -9471,15 +9360,15 @@
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
@@ -11248,80 +11137,6 @@
}
}
},
"node_modules/vite-plugin-static-copy": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.4",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.17"
},
"engines": {
"node": "^22.0.0 || >=24.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/sapphi-red"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/vite-plugin-svgr": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
@@ -11655,9 +11470,9 @@
}
},
"node_modules/web-vitals": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
+8 -10
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.24.0",
"version": "1.22.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -17,16 +17,15 @@
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@fontsource/opendyslexic": "5.2.5",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@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.1",
"@tanstack/react-query": "5.100.14",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
@@ -36,14 +35,14 @@
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"livekit-client": "2.19.0",
"posthog-js": "1.386.5",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -71,7 +70,6 @@
"typescript": "6.0.3",
"typescript-eslint": "8.60.1",
"vite": "8.0.14",
"vite-plugin-static-copy": "4.1.1",
"vite-plugin-svgr": "5.2.0"
}
}
-4
View File
@@ -114,10 +114,6 @@ 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.
-2
View File
@@ -20,7 +20,6 @@ export interface ApiConfig {
feedback: {
url: string
}
documentation_url?: string
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
@@ -59,7 +58,6 @@ export interface ApiConfig {
transcription_destination?: string
max_participants_for_sound: number
auto_mute_on_join_threshold: number
authenticated_users_can_edit_display_name: boolean
}
const fetchConfig = (): Promise<ApiConfig> => {
+49 -36
View File
@@ -7,20 +7,36 @@ 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' },
list: { width: '32px', height: '32px' },
placeholder: { width: '100%', height: '100%' },
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%',
},
},
notification: {
true: { border: '2px solid white' },
true: {
border: '2px solid white',
},
},
},
defaultVariants: {
@@ -33,35 +49,32 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
bgColor?: string
} & RecipeVariantProps<typeof avatar>
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}
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',
})}
>
<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'
{initial}
</span>
</div>
)
}
@@ -1,50 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Text } from '@/primitives'
import { ChatMessages } from './ChatMessages'
import { ChatTextArea } from './ChatTextArea'
import { styled } from '@/styled-system/jsx'
const ChatContainer = styled('div', {
base: {
display: 'flex',
padding: '0 1.5rem',
flexGrow: 1,
flexDirection: 'column',
minHeight: 0,
},
})
const ChatMessagesContainer = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
},
})
const TextContainer = styled('div', {
base: {
display: 'flex',
padding: '0.75rem',
backgroundColor: 'greyscale.50',
borderRadius: 4,
marginBottom: '0.75rem',
},
})
export const Chat = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
return (
<ChatContainer>
<TextContainer>
<Text variant="sm">{t('disclaimer')}</Text>
</TextContainer>
<ChatMessagesContainer>
<ChatMessages />
</ChatMessagesContainer>
<ChatTextArea />
</ChatContainer>
)
}
@@ -1,34 +0,0 @@
import type { ChatRow } from '@/stores/chat'
import { styled } from '@/styled-system/jsx'
import { ChatMessageMetadata } from './ChatMessageMedata'
import { ChatMessageBody } from './ChatMessageBody'
const StyledContainer = styled('li', {
base: {
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
},
})
type ChatMessageProps = {
item: ChatRow
}
export const ChatMessage = ({ item }: ChatMessageProps) => {
const time = new Date(item.timestamp)
const locale = navigator ? navigator.language : 'en-US'
return (
<StyledContainer
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
>
{!item.hideMetadata && (
<ChatMessageMetadata
timestamp={item.timestamp}
identity={item.identity}
/>
)}
<ChatMessageBody message={item.message} />
</StyledContainer>
)
}
@@ -1,33 +0,0 @@
import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react'
import { formatChatMessageLinks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
export const ChatMessageBody = React.memo(
({ message }: ChatMessageBodyProps) => {
const formattedMessage = useMemo(() => {
return formatChatMessageLinks(message)
}, [message])
return (
<Text
variant="sm"
margin={false}
className={css({
whiteSpace: 'pre-wrap',
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
)
}
)
ChatMessageBody.displayName = 'ChatMessageBody'
@@ -1,41 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { ChatRow, chatStore } from '@/stores/chat'
import React, { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { Text } from '@/primitives'
const StyledContainer = styled('span', {
base: {
display: 'flex',
gap: '0.5rem',
paddingTop: '0.75rem',
},
})
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
export const ChatMessageMetadata = React.memo(
({ identity, timestamp }: ChatMessageMetadataProps) => {
const time = new Date(timestamp)
const locale = navigator ? navigator.language : 'en-US'
const { names } = useSnapshot(chatStore)
const currentDisplayName = useMemo(() => {
if (identity) return names[identity] ?? identity
}, [names, identity])
return (
<StyledContainer>
<Text bold={true} variant="sm">
{currentDisplayName}
</Text>
<Text variant="smNote" wrap="no">
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</StyledContainer>
)
}
)
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
@@ -1,79 +0,0 @@
import {
ListBox,
ListBoxItem,
ListLayout,
Virtualizer,
} from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ChatMessage } from './ChatMessage.tsx'
import { useLayoutEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { ChatRow, chatStore } from '@/stores/chat'
// Estimated height of a chat entry in px. ListLayout measures the real
// rendered height of each row; this value is only used to size the
// scrollbar before rows have been measured.
const ESTIMATED_ROW_HEIGHT = 56
const BOTTOM_THRESHOLD_PX = 32
export const ChatMessages = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
const { rows: items } = useSnapshot(chatStore)
const listRef = useRef<HTMLDivElement>(null)
const stick = useRef(true)
useLayoutEffect(() => {
const el = listRef.current
if (!el) return
const pin = () => {
if (stick.current) el.scrollTop = el.scrollHeight
}
const onScroll = () => {
stick.current =
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
}
const ro = new ResizeObserver(pin)
// Sizer div = ListLayout's content size. Fires on append, and again each
// time an estimated row height is replaced by a measured one.
if (el.firstElementChild) ro.observe(el.firstElementChild)
// Container. Height goes 0 -> N as the panel animates in, and on resize.
ro.observe(el)
el.addEventListener('scroll', onScroll, { passive: true })
pin()
return () => {
ro.disconnect()
el.removeEventListener('scroll', onScroll)
}
}, [])
return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
gap: 4,
padding: 4,
}}
>
<ListBox
ref={listRef}
aria-label={t('messagesLabel', 'Chat messages')}
items={items}
selectionMode="none"
style={{
display: 'block',
height: '100%',
width: '100%',
overflow: 'auto',
}}
>
{(item: ChatRow) => (
<ListBoxItem style={{ width: '100%' }}>
<ChatMessage item={item} />
</ListBoxItem>
)}
</ListBox>
</Virtualizer>
)
}
@@ -1,83 +0,0 @@
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
import { ref } from 'valtio'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import React, { useEffect } from 'react'
import { useChat, useRoomContext } from '@livekit/components-react'
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
import type { ChatMessage } from '@livekit/components-core'
import {
LocalParticipant,
Participant,
RemoteParticipant,
RoomEvent,
} from 'livekit-client'
export const ChatProvider = () => {
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
const { send, chatMessages, isSending } = useChat()
const { isChatOpen } = useSidePanel()
const room = useRoomContext()
useEffect(() => {
resetChatStore()
}, [])
// Tigger the message notification (temporary)
useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking
// due to recent LiveKit useChat changes breaking the previous implementation
// (see https://github.com/livekit/components-js/issues/1158)
// Remove this once we refactor chat to use the new text stream approach
const latestMessage = chatMessages.slice(-1)[0]
if (!latestMessage) return
const from = latestMessage.from as
| RemoteParticipant
| LocalParticipant
| undefined
room.emit(RoomEvent.ChatMessage, latestMessage, from)
}, [chatMessages, room])
useEffect(() => {
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
appendRow(chatMessages[i])
}
}, [chatMessages])
useEffect(() => {
chatStore.send = ref(send)
}, [send])
useEffect(() => {
chatStore.isSending = isSending
}, [isSending])
// Set the unread messages count
useEffect(() => {
if (chatMessages.length === 0) return
const last = chatMessages[chatMessages.length - 1]
if (isChatOpen) {
lastReadMsgAt.current = last.timestamp
chatStore.unreadMessages = 0
return
}
chatStore.unreadMessages = chatMessages.filter(
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
).length
}, [chatMessages, isChatOpen])
// Listen to participant name changes
useEffect(() => {
const setName = (p: Participant) => {
chatStore.names[p.identity] = p.name || p.identity
}
const onNameChanged = (_name: string, p: Participant) => setName(p)
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
return () => {
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
}
}, [room])
return null
}
@@ -1,30 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives'
import { RiSendPlane2Fill } from '@remixicon/react'
type ChatSubmitButtonProps = {
handleSubmit: () => Promise<void>
isDisabled: boolean
}
export const ChatSubmitButton = React.memo(
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
return (
<Button
square
invisible
variant="tertiaryText"
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
)
}
)
ChatSubmitButton.displayName = 'ChatSubmitButton'
@@ -1,92 +0,0 @@
import { TextArea } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
chatStore,
clearTextAreaValue,
persistTextAreaValue,
} from '@/stores/chat'
import { ChatSubmitButton } from './ChatSubmitButton'
const StyledContainer = styled('div', {
base: {
display: 'flex',
margin: '0.75rem 0 1.5rem',
padding: '0.5rem',
backgroundColor: 'gray.100',
borderRadius: 4,
},
})
export const ChatTextArea = () => {
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
const inputRef = React.useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const el = inputRef.current
if (!el) return
const raf = requestAnimationFrame(() => {
el.focus({ preventScroll: true })
const end = el.value.length
el.setSelectionRange(end, end)
})
return () => cancelAnimationFrame(raf)
}, [])
const handleSubmit = useCallback(async () => {
const text = chatStore.textAreaValue
if (!send || !text) return
await send(text)
inputRef?.current?.focus({ preventScroll: true })
clearTextAreaValue()
}, [send, inputRef])
const isDisabled = !textAreaValue.trim() || isSending
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
return
e.preventDefault()
await handleSubmit()
}
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
}
return (
<StyledContainer>
<TextArea
ref={inputRef}
value={textAreaValue}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onChange={(e) => {
persistTextAreaValue(e.target.value)
}}
fieldSizing={'content'}
style={{
border: 'none',
resize: 'none',
height: 'auto',
maxHeight: '240px',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
}}
placeholderStyle="strong"
spellCheck={false}
maxLength={2000}
placeholder={t('textArea.placeholder')}
aria-label={t('textArea.label')}
/>
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
</StyledContainer>
)
}
@@ -9,11 +9,10 @@ import { useState } from 'react'
import { menuRecipe } from '@/primitives/menuRecipe'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { useSnapshot } from 'valtio'
import { userStore } from '@/stores/user'
import { loadUserChoices } from '@livekit/components-core'
export const CreateMeetingMenu = () => {
const { username } = useSnapshot(userStore)
const { username } = loadUserChoices()
const { t } = useTranslation('home')
const { mutateAsync: createRoom } = useCreateRoom()
@@ -3,7 +3,6 @@ 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
@@ -11,72 +10,6 @@ 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[]
@@ -107,42 +40,52 @@ 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 [layout, setLayout] = React.useState<CarouselLayoutState>({
orientation: orientation ?? 'vertical',
maxVisibleTiles: MIN_VISIBLE_TILES,
})
const tileSpan =
carouselOrientation === 'vertical'
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
const scrollBarWidth = getScrollBarWidth()
// Stable callback + identity check: the parent only re-renders when the
// 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
)
}, [])
const tilesThatFit =
carouselOrientation === 'vertical'
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
let maxVisibleTiles = Math.round(tilesThatFit)
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
maxVisibleTiles = Math.round(prevTiles)
} else if (prevTiles !== tilesThatFit) {
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
React.useLayoutEffect(() => {
if (asideEl.current) {
asideEl.current.dataset.lkOrientation = carouselOrientation
asideEl.current.style.setProperty(
'--lk-max-visible-tiles',
maxVisibleTiles.toString()
)
}
}, [maxVisibleTiles, carouselOrientation])
return (
<>
<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>
</>
<aside
key={carouselOrientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
)
}
@@ -10,36 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { 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
@@ -67,15 +37,14 @@ export interface GridLayoutProps
* @public
*/
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = useRef<HTMLDivElement>(null)
const [maxTiles, setMaxTiles] = useState(1)
const gridEl = React.createRef<HTMLDivElement>()
const elementProps = React.useMemo(
() => mergeProps(props, { className: 'lk-grid-layout' }),
[props]
)
const pagination = usePagination(maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -88,13 +57,8 @@ 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 > maxTiles && (
{tracks.length > layout.maxTiles && (
<>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
@@ -1,6 +0,0 @@
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
export const PinAnnouncer = () => {
usePinAnnouncements()
return null
}
@@ -1,113 +0,0 @@
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { FocusLayout } from '@/features/layout/components/FocusLayout'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { GridLayout } from '@/features/layout/components/GridLayout'
import {
isEqualTrackRef,
isTrackReference,
log,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useRef } from 'react'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
useRef<TrackReferenceOrPlaceholder | null>(null)
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [], onlySubscribed: false }
)
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: screenShareTracks[0],
})
setPinnedTrack(screenShareTracks[0])
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) =>
track.publication.trackSid ===
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
)
) {
log.debug('Auto clearing screen share focus.')
clearPinnedTrack()
lastAutoFocusedScreenShareTrack.current = null
}
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
const updatedFocusTrack = tracks.find(
(tr) =>
tr.participant.identity === pinnedTrackRef.participant.identity &&
tr.source === pinnedTrackRef.source
)
if (
updatedFocusTrack !== pinnedTrackRef &&
isTrackReference(updatedFocusTrack)
) {
setPinnedTrack(updatedFocusTrack)
}
}
}, [
screenShareTracks
.map(
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
)
.join(),
pinnedTrackRef?.publication?.trackSid,
tracks,
])
/* eslint-enable react-hooks/exhaustive-deps */
return (
<>
{!pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
<FocusLayout trackRef={pinnedTrackRef} />
</FocusLayoutContainer>
</div>
)}
</>
)
}
@@ -1,70 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import { layoutStore } from '@/stores/layout'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const usePinAnnouncements = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const { pinnedTrackRef } = useSnapshot(layoutStore)
const lastPinnedIdentityRef = useRef<string | null>(null)
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
if (!participant) return tRooms('participants.unknown')
return participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
},
[tRooms]
)
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
useEffect(() => {
// 1. unpin
if (!pinnedIdentity) {
const lastIdentity = lastPinnedIdentityRef.current
if (!lastIdentity) return
const lastParticipant =
room.localParticipant.identity === lastIdentity
? room.localParticipant
: room.remoteParticipants.get(lastIdentity)
announce(
lastParticipant?.isLocal
? t('self.unpin')
: t('unpin', { name: getAnnouncementName(lastParticipant) })
)
lastPinnedIdentityRef.current = null
return
}
// 2. same pin → do nothing
if (lastPinnedIdentityRef.current === pinnedIdentity) return
// 3. new pin
const participant =
room.localParticipant.identity === pinnedIdentity
? room.localParticipant
: room.remoteParticipants.get(pinnedIdentity)
lastPinnedIdentityRef.current = pinnedIdentity
announce(
participant?.isLocal
? t('self.pin')
: t('pin', { name: getAnnouncementName(participant) })
)
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
}
@@ -1,48 +0,0 @@
import { RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useReducer, useRef } from 'react'
/**
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of
* their tiles are within the visible span (`maxVisibleTiles`). That render
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
* state and performs the actual swap.
*
* Speakers already visible are ignored. Everything else costs zero React work.
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
sortedTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hiddenSpeakerExists) {
forceRender()
}
}
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
}
}, [room])
}
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
import Source = Track.Source
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
import { useMaybeLayoutContext } from '@livekit/components-react'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { type ToastProps } from './Toast'
import { 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,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
state,
ref
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
if (!participant) return
@@ -37,13 +38,18 @@ 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) => {
setPinnedTrack(trackReference)
pinParticipant()
closeButtonProps.onPress?.(e)
}}
>
@@ -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 type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications'
@@ -1,54 +0,0 @@
import {
ConnectionQualityIndicator,
LockLockedIcon,
ScreenShareIcon,
useIsEncrypted,
} from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx'
import { Participant } from 'livekit-client'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantName } from './ParticipantName'
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
export const ParticipantMetadata = ({
displayedName,
participant,
isScreenShare,
}: {
displayedName: string
participant: Participant
isScreenShare: boolean
}) => {
const isEncrypted = useIsEncrypted(participant)
return (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
{!isScreenShare && <MutedMicIndicator participant={participant} />}
<RaisedHandMetadataWrapper
participant={participant}
enabled={!isScreenShare}
>
{isScreenShare && (
<ScreenShareIcon
style={{
maxWidth: '20px',
width: '100%',
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
displayedName={displayedName}
isScreenShare={isScreenShare}
/>
</div>
</RaisedHandMetadataWrapper>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
)
}
@@ -1,42 +0,0 @@
import React, { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = React.memo(
({
displayedName,
isScreenShare = false,
}: {
displayedName?: string
isScreenShare: boolean
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
if (isScreenShare) {
return (
<Text variant="sm" style={participantNameScreenShareStyles}>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
{displayedName}
</Text>
)
}
)
ParticipantName.displayName = 'ParticipantName'
@@ -1,55 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import React, { useMemo } from 'react'
const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
containerType: 'size',
},
})
const StyledAvatarWrapper = styled('div', {
base: {
borderRadius: '50%',
aspectRatio: '1 / 1',
width: 'min(90cqmin, 160px)',
fontSize: 'min(27cqmin, 48px)',
'[data-lk-speaking="true"] &': {
animation: 'pulse 1s infinite',
},
},
})
type ParticipantPlaceholderProps = {
color: string
displayedNamed: string
}
export const ParticipantPlaceholder = React.memo(
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(color),
[color]
)
return (
<StyledParticipantPlaceHolder
style={{
backgroundColor: color,
backgroundImage: backgroundGradient,
}}
>
<StyledAvatarWrapper>
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
</StyledAvatarWrapper>
</StyledParticipantPlaceHolder>
)
}
)
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
@@ -1,91 +0,0 @@
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
import {
useRaisedHand,
useRaisedHandPosition,
} from '@/features/rooms/livekit/hooks/useRaisedHand'
import { Participant } from 'livekit-client'
import { RiHand } from '@remixicon/react'
const PositionInQueue = React.memo(
({ positionInQueue }: { positionInQueue?: number }) => {
if (!positionInQueue) return
return (
<span
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
>
<span>{positionInQueue}</span>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
marginLeft: '0.1rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
</span>
)
}
)
PositionInQueue.displayName = 'PositionInQueue'
const RaisedHandActiveItem = ({
participant,
targetRef,
}: {
participant: Participant
targetRef: RefObject<HTMLElement | null>
}) => {
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant,
})
useEffect(() => {
const el = targetRef.current
if (!el) return
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
el.style.color = 'black'
return () => {
el.style.backgroundColor = ''
el.style.color = ''
}
}, [targetRef, firstInQueue])
return <PositionInQueue positionInQueue={positionInQueue} />
}
export const RaisedHandMetadataWrapper = ({
participant,
enabled = true,
children,
}: {
participant: Participant
enabled?: boolean
children: ReactNode
}) => {
const ref = useRef(null)
const { isHandRaised } = useRaisedHand({ participant })
const showHand = isHandRaised && enabled
return (
<div
ref={ref}
className="lk-participant-metadata-item"
style={{
padding: '0.1rem 0.25rem',
transition: 'background 200ms ease',
}}
>
{showHand && (
<RaisedHandActiveItem participant={participant} targetRef={ref} />
)}
{children}
</div>
)
}
@@ -1,23 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Button } from '@/primitives'
import { RiImageCircleAiFill } from '@remixicon/react'
export const EffectsButton = React.memo(() => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size={'sm'}
variant={'primaryTextDark'}
square
tooltip={t('effects')}
onPress={() => !isEffectsOpen && toggleEffects()}
>
<RiImageCircleAiFill />
</Button>
)
})
EffectsButton.displayName = 'EffectsButton'
@@ -1,32 +0,0 @@
import {
isEqualTrackRef,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { Button } from '@/primitives'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
export const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
@@ -1,45 +0,0 @@
import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Track.Source.Microphone,
})
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
isDisabled={isMuted}
size={'sm'}
variant={'primaryTextDark'}
square
onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })}
>
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
@@ -1,112 +0,0 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { Track } from 'livekit-client'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { ZoomButton } from './ZoomButton'
const MOUSE_IDLE_TIME = 3000
type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
}
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
const clearIdleTimer = () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
}
const armIdleTimer = () => {
clearIdleTimer()
idleTimerRef.current = window.setTimeout(() => {
setActive(false)
}, MOUSE_IDLE_TIME)
}
const handleActivity = () => {
setActive(true)
armIdleTimer()
}
useEffect(() => clearIdleTimer, [])
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
data-visible={isVisible || undefined}
aria-hidden={!isVisible}
onMouseEnter={handleActivity}
onMouseMove={handleActivity}
onMouseLeave={() => {
clearIdleTimer()
setActive(false)
}}
>
{isVisible && children}
</div>
)
}
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = participant.isLocal
const canMute = useCanMute(participant)
return (
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
<div
className={css({
backgroundColor: 'primaryDark.50',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
opacity: 0.6,
animation: 'overlayIn 200ms linear 300ms backwards',
_hover: {
opacity: 0.95,
},
})}
>
<HStack gap={0.5} padding={0.5}>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
</FadeOverlay>
)
}
@@ -1,32 +0,0 @@
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
import { Button } from '@/primitives'
import { RiFullscreenLine } from '@remixicon/react'
export const ZoomButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
trackRef,
})
if (!isFullscreenAvailable) {
return
}
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={t('fullScreen')}
onPress={() => toggleFullScreen()}
>
<RiFullscreenLine />
</Button>
)
}
@@ -1,93 +0,0 @@
import { ReactNode, useId, useState } from 'react'
import { RiArrowUpSLine } from '@remixicon/react'
import { styled, HStack, VStack } from '@/styled-system/jsx'
const Container = styled('div', {
base: {
border: '1px solid',
borderColor: 'greyscale.250',
borderRadius: '8px',
margin: '0 .625rem 0.9375rem',
},
})
const Header = styled('button', {
base: {
minHeight: '2.5rem',
paddingX: '1.25rem',
paddingY: '0.5rem',
gap: '0.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
fontSize: '1rem',
transition: 'background 200ms',
borderTopRadius: '7px', // container radius (8) minus its 1px border
_hover: { backgroundColor: 'greyscale.50' },
},
variants: {
isOpen: { false: { borderRadius: '7px' } },
},
})
const Chevron = styled(RiArrowUpSLine, {
base: { transition: 'transform 200ms', flexShrink: 0 },
variants: {
isOpen: { false: { transform: 'rotate(180deg)' } },
},
})
const List = styled(VStack, {
base: {
borderTop: '1px solid',
borderTopColor: 'greyscale.250',
alignItems: 'start',
minHeight: 0,
flexGrow: 1,
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
export type ParticipantsCollapsibleSectionProps = {
heading: string
count: number
action?: ReactNode
children: ReactNode
}
export const ParticipantsCollapsibleSection = ({
heading,
count,
action,
children,
}: ParticipantsCollapsibleSectionProps) => {
const [isOpen, setIsOpen] = useState(true)
const listId = useId()
return (
<Container>
<Header
type="button"
isOpen={isOpen}
aria-expanded={isOpen}
aria-controls={listId}
onClick={() => setIsOpen((open) => !open)}
>
<HStack justify="space-between" width="100%">
<span>{heading}</span>
<span>{count}</span>
</HStack>
<Chevron size={32} isOpen={isOpen} aria-hidden />
</Header>
{isOpen && (
<List id={listId} role="list">
{action}
{children}
</List>
)}
</Container>
)
}
@@ -1,52 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useRemoteParticipants } from '@livekit/components-react'
import { srOnly } from '@/styles/a11y'
import { css } from '@/styled-system/css'
import { RiInfinityLine } from '@remixicon/react'
const badgeStyles = css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1.25rem',
height: '1.25rem',
backgroundColor: 'gray',
borderRadius: '50%',
color: 'white',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
zIndex: 1,
userSelect: 'none',
})
/**
* Isolated so participant join/leave events only re-render the badge,
* not the button, tooltip or shortcut registration.
*/
export const ParticipantsCount = React.memo(
({ describedById }: { describedById: string }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'controls.participants',
})
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [],
})
const count = (remoteParticipants?.length ?? 0) + 1
return (
<>
<span id={describedById} className={srOnly}>
{t('count', { count })}
</span>
<div className={badgeStyles} aria-hidden>
{count < 100 ? count : <RiInfinityLine size={10} />}
</div>
</>
)
}
)
ParticipantsCount.displayName = 'ParticipantsCount'
@@ -1,103 +0,0 @@
import { css } from '@/styled-system/css'
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { ParticipantRow } from './ParticipantRow'
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
import { LowerAllHandsButton } from './LowerAllHandsButton'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
import { RaisedHandRow } from './RaisedHandRow'
const JoinedParticipantsSections = () => {
const room = useRoomContext()
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
// `updateOnlyOn` filters on the room event *type*, not on which participant
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
// are room-wide and fire for the local participant too, so this hook re-emits
// (and we re-render) on local changes, despite its name.
//
// That is deliberate here: we prepend `room.localParticipant` to the list below,
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
// local events waking this component up, raising your own hand would not
// update the UI. Do not "optimise" these away.
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [
RoomEvent.ParticipantNameChanged,
RoomEvent.ParticipantAttributesChanged,
],
})
const allParticipants = [
room.localParticipant,
...[...remoteParticipants].sort((a, b) =>
(a.name || a.identity).localeCompare(b.name || b.identity)
),
]
const participantsWithRaisedHands = allParticipants
.filter((p) => !!p.attributes.handRaisedAt)
.sort((a, b) => {
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
return (
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
)
})
return (
<>
{participantsWithRaisedHands.length > 0 && (
<ParticipantsCollapsibleSection
heading={t('raisedHands')}
count={participantsWithRaisedHands.length}
action={
<LowerAllHandsButton participants={participantsWithRaisedHands} />
}
>
{participantsWithRaisedHands.map((p) => (
<RaisedHandRow key={p.identity} participant={p} />
))}
</ParticipantsCollapsibleSection>
)}
<ParticipantsCollapsibleSection
heading={t('contributors')}
count={allParticipants.length}
action={<MuteEveryoneButton participants={remoteParticipants} />}
>
{allParticipants.map((p) => (
<ParticipantRow key={p.identity} participant={p} />
))}
</ParticipantsCollapsibleSection>
</>
)
}
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
return (
<Div overflowY="scroll">
<H
lvl={2}
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'greyscale.600',
padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{t('subheading').toUpperCase()}
</H>
<WaitingParticipantsSection />
<JoinedParticipantsSections />
</Div>
)
}
@@ -1,52 +0,0 @@
import { Participant, Track } from 'livekit-client'
import { RiPushpin2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { isEqualTrackRef } from '@livekit/components-core'
import Source = Track.Source
export const PinBadge = ({ participant }: { participant: Participant }) => {
const cameraTrackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
}, [participant])
const screenShareTrackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.ScreenShare),
source: Source.ScreenShare,
}
}, [participant])
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus =
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
if (!inFocus) return
return (
<div
className={css({
height: '18px',
width: '18px',
borderRadius: '100%',
background: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
bottom: '-2px',
right: '-4px',
})}
>
<RiPushpin2Fill size={14} aria-hidden />
</div>
)
}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next'
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
import { WaitingParticipantRow } from './WaitingParticipantRow'
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
export const WaitingParticipantsSection = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
if (waitingParticipants?.length == 0) return
return (
<ParticipantsCollapsibleSection
heading={t('title')}
count={waitingParticipants.length}
>
{waitingParticipants.map((p) => (
<WaitingParticipantRow
key={p.id}
participant={p}
onAction={handleParticipantEntry}
/>
))}
</ParticipantsCollapsibleSection>
)
}
@@ -1,7 +1,7 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
@@ -26,7 +26,6 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
@@ -35,7 +34,6 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
@@ -50,10 +48,9 @@ const FocusContainer = styled('div', {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
@@ -61,8 +58,6 @@ const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
@@ -72,13 +67,13 @@ const MainSlot = styled('div', {
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
right: '1rem',
bottom: '1rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '8px',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
@@ -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/participantTile/components/ParticipantTile'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} disableTileControls />
<ParticipantTile trackRef={track} />
</GridCell>
))}
</GridContainer>
@@ -54,8 +54,7 @@ const GridContainer = styled('div', {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.5rem',
boxSizing: 'border-box',
gap: '0.25rem',
},
})
@@ -64,7 +63,7 @@ const GridCell = styled('div', {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '8px',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
@@ -1,97 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
cameraTracks: TrackReferenceOrPlaceholder[]
}
/**
* Layout when a screen share is active.
* Camera tiles are shown as a compact row at the top; the screen share occupies
* the remaining space below, much larger than the camera tiles.
*/
export const PipScreenShareLayout = memo(
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
return (
<LayoutContainer>
{cameraTracks.length > 0 && (
<CameraTilesRow>
{cameraTracks.map((track) => (
<CameraTile key={getTrackKey(track)}>
<ParticipantTile trackRef={track} disableTileControls />
</CameraTile>
))}
</CameraTilesRow>
)}
<ScreenShareSlot>
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
</ScreenShareSlot>
</LayoutContainer>
)
}
)
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
const LayoutContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
boxSizing: 'border-box',
overflow: 'hidden',
},
})
const CameraTilesRow = styled('div', {
base: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
gap: '0.5rem',
flexShrink: 0,
height: '22%',
minHeight: '60px',
maxHeight: '120px',
},
})
const CameraTile = styled('div', {
base: {
position: 'relative',
flex: '0 1 auto',
height: '100%',
aspectRatio: '16 / 9',
minWidth: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
const ScreenShareSlot = styled('div', {
base: {
position: 'relative',
flex: 1,
minHeight: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -1,11 +1,10 @@
import React, { useMemo } from 'react'
import { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
import {
@@ -14,12 +13,9 @@ import {
} from '@livekit/components-core'
/**
* PipStage picks between three layouts:
* - Screen share mode (any screen share active):
* small camera tiles in a row at the top, large screen share below.
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
* - Focus mode ( 2 camera tracks, no screen share): one main track
* + one thumbnail overlay.
* PipStage picks between two layouts based on track count:
* - Focus mode ( 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
*/
export const PipStage = () => {
const tracks = useTracks(
@@ -45,55 +41,51 @@ export const PipStage = () => {
[tracks]
)
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
// Grid mode order: screen share leads, then cameras (already ordered by
// active speaker via the `ActiveSpeakersChanged` update above).
const gridTracks = useMemo(
() =>
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
[screenShareTrack, cameraTracks]
)
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
// page visually stable (active/recent speakers stay put) via its internal
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
if (tracks.length === 0) return null
// Screen share active
if (screenShareTrack) {
// Solo presenter: screen share fills the area, camera as small thumbnail
if (cameraTracks.length <= 1) {
return (
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (gridTracks.length > 2) {
return (
<StageWrapper>
<StageFrame>
<PipFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
/>
<PipGridLayout tracks={pagination.tracks} />
</StageFrame>
)
}
// Multiple cameras: camera row at top, screen share below
return (
<PaginatedStage pagination={paginatedCameraTracks}>
<PipScreenShareLayout
screenShareTrack={screenShareTrack}
cameraTracks={paginatedCameraTracks.tracks}
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</PaginatedStage>
</StageWrapper>
)
}
// 3+ camera tracks → adaptive grid
if (cameraTracks.length > 2) {
return (
<PaginatedStage pagination={pagination}>
<PipGridLayout tracks={pagination.tracks} />
</PaginatedStage>
)
}
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = remoteCameraTrack ?? localCameraTrack
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
@@ -104,31 +96,6 @@ export const PipStage = () => {
)
}
type PaginationResult = {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
const PaginatedStage = ({
pagination,
children,
}: {
pagination: PaginationResult
children: React.ReactNode
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
const StageWrapper = styled('div', {
base: {
display: 'flex',
@@ -1,21 +1,18 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { useTrackToggle } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useLocalParticipant } from '@livekit/components-react'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { enabled: isScreenSharing } = useTrackToggle({
source: Track.Source.ScreenShare,
})
const { localParticipant } = useLocalParticipant()
return (
<Container
role="region"
aria-label={t('stage')}
{...(!isScreenSharing ? { inert: '' } : {})}
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
>
{children}
</Container>
@@ -28,8 +25,8 @@ const Container = styled('div', {
flex: 1,
minWidth: 0,
minHeight: 0,
padding: '0.5rem',
boxSizing: 'border-box',
marginLeft: '0.5rem',
marginRight: '0.5rem',
borderRadius: '4px',
overflow: 'hidden',
},
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
import {
useSettingsDialog,
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
@@ -34,7 +35,6 @@ 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,6 +48,8 @@ export const TranscriptSidePanel = () => {
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { openSettingsDialog } = useSettingsDialog()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -22,12 +22,9 @@ export type ApiRoom = {
id: string
name: string
slug: string
pin_code?: string
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -1,32 +0,0 @@
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,4 +1,3 @@
import React from 'react'
import { styled } from '@/styled-system/jsx'
const StyledContainer = styled('div', {
@@ -68,36 +67,35 @@ export type ActiveSpeakerProps = {
pushToTalk?: boolean
}
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'
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>
)
}
@@ -36,7 +36,6 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
export const Conference = ({
roomId,
@@ -54,8 +53,6 @@ export const Conference = ({
userChoices: LocalUserChoices
}
const { username } = useSnapshot(userStore)
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
@@ -86,10 +83,10 @@ export const Conference = ({
queryFn: () =>
fetchRoom({
roomId: roomId as string,
username: username,
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({ slug: roomId, username })
createRoom({ slug: roomId, username: userConfig.username })
}
}),
retry: false,
@@ -170,6 +167,7 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
@@ -302,7 +300,13 @@ export const Conference = ({
}}
>
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
onClose={() => setShowInviteDialog(false)}
/>
)}
<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, P, Bold } from '@/primitives'
import { Div, Button, type DialogProps, 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, useState } from 'react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
@@ -39,9 +39,7 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
@@ -60,161 +58,163 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
if (!showInviteDialog) return null
return (
<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({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
<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')}
>
<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>
<RiCloseLine />
</Button>
</Div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<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>
<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>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
variant={isCopied ? 'success' : 'tertiary'}
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"
/>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
</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}
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
fill: 'primary.500',
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
)}
</StyledRACDialog>
)
}
@@ -42,17 +42,14 @@ import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveUsername,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { saveUsername, userStore } from '@/stores/user'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useSnapshot } from 'valtio'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
const onError = (e: Error) => console.error('ERROR', e)
@@ -117,9 +114,6 @@ export const Join = ({
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const {
audioEnabled,
videoEnabled,
@@ -127,10 +121,9 @@ export const Join = ({
audioOutputDeviceId,
videoDeviceId,
processorConfig,
username,
} = useSnapshot(userChoicesStore)
const { username } = useSnapshot(userStore)
const initialUserChoices = useRef<LocalUserChoices | null>(null)
if (initialUserChoices.current === null) {
@@ -141,6 +134,7 @@ export const Join = ({
audioOutputDeviceId,
videoDeviceId,
processorConfig,
username,
}
}
@@ -454,23 +448,20 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{(!isLoggedIn ||
configData?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username || user?.full_name}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
</VStack>
</Form>
)
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
import {
useListWaitingParticipants,
type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants'
} from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
@@ -1,4 +1,4 @@
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
import { ParticipantTile } from './ParticipantTile'
import type { FocusLayoutProps } from '@livekit/components-react'
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {

Some files were not shown because too many files have changed in this diff Show More