Compare commits

...

70 Commits

Author SHA1 Message Date
lebaudantoine 135ce78609 remove the support menu section, are they both opening / interacting in the main window 2026-05-07 12:51:36 +02:00
lebaudantoine 7f23cf1735 wip remove subtitle options as they're not rendered in pip 2026-05-07 12:50:50 +02:00
lebaudantoine 1936c692b2 revert change on the menu primitives 2026-05-07 11:37:06 +02:00
Cyril 435990f589 ♻️(frontend) fix VisualOnlyTooltip clipping in PiP
Flip when top overflows; clamp left/right so PiP tooltips stay visible.
2026-04-29 14:38:00 +02:00
Cyril 1e872e4c3c ♻️(frontend) address Sonar warnings in PiP and side panel
Replace nested ternary logic, use globalThis instead of window, and switch side-panel enum re-exports to export-from for clearer and safer patterns.

Made-with: Cursor
2026-04-29 14:28:17 +02:00
Cyril 7e9b3a6290 ♻️(frontend) normalize PiP component patterns
Extract OverflowItems, replace `any`, convert function declarations to const arrows.
2026-04-29 14:13:24 +02:00
Cyril 1080407bb3 ♻️(frontend) harden PiP notifications stacking and dedupe
Group banner and toasts in one stack, cap visibility, fix chat dedup.
2026-04-29 07:21:42 +02:00
Cyril d7fb31ef2c (frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
2026-04-27 11:12:54 +02:00
Cyril c8ae87b082 (frontend) restore toolbar shortcuts inside PiP window
Forward PiP keydown events and register PiP-specific handlers.
2026-04-24 14:26:58 +02:00
Cyril 636d298c18 🐛(frontend) close native PiP window when leaving the room
Force-close PiP on unmount and reset PiP state so next room starts clean.
2026-04-24 13:51:04 +02:00
Cyril f0c5a8c241 🐛(frontend) fix PiP tile corner clipping and unify 4px radius
Set a single clipping owner per PiP layout and use a consistent 4px radius.
2026-04-24 13:36:15 +02:00
Cyril 946da58fb2 ♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
2026-04-24 13:06:44 +02:00
Cyril 96edb3725a ️(frontend) PiP accessibility and cross-document focus foundation
Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce.
2026-04-24 09:30:34 +02:00
Cyril 204d40e0ca 🌐(i18n) add PiP accessibility strings to rooms
Labels for PiP window, stage, toolbar, reactions, and SR open/close.
2026-04-24 09:29:59 +02:00
Cyril c16f4ca846 🎨(frontend) tune PiP window default dimensions
Start PiP in a taller shape closer to a typical video call layout.
2026-04-23 15:05:11 +02:00
Cyril 5362f37883 (frontend) add adaptive PiP stage with focus and grid layouts
Render all participants with a focus mode and animated adaptive grid.
2026-04-23 15:04:59 +02:00
Cyril bac7261fff ♻️(frontend) share a PiP-aware element size hook
Reuse a single ResizeObserver hook in the PiP control bar.
2026-04-22 18:00:37 +02:00
Cyril 40da9c0bf9 (frontend) add PiP reactions toolbar and isolated state
PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
2026-04-10 09:24:48 +02:00
Cyril 284c1ccc8a 🐛(frontend) fix pip controls responsive overflow in realtime
Use PiP document ResizeObserver so controls hide into "..." while resizing.
2026-04-08 10:59:13 +02:00
Cyril 69c9559843 🐛(frontend) align pip layout with google meet
Show a main tile with a bottom-right thumbnail in PiP, like Google Meet.
2026-04-08 10:19:25 +02:00
Cyril 07ed4d701c ♻️(frontend) decouple pip state and simplify pip options
Prevent PiP actions from affecting main panel state and reduce PiP-only noise.
2026-04-08 09:52:08 +02:00
Cyril a40c76bc4d (perf) hide main layout when pip is open
avoid rendering main video tiles while pip is active
2026-04-08 09:36:40 +02:00
Cyril e9684e0c01 ♻️(refactor) move pip feature into dedicated module
group pip components and hooks under features/pip for isolation
2026-04-08 09:34:18 +02:00
Cyril 1c5b6b1e1f (feat) move pip state to valtio store
replace context provider with shared store for pip ui state
2026-04-08 09:34:17 +02:00
Cyril e38d0861b1 🐛(frontend) clamp pip tooltips on right edge
prevent pip tooltips from overflowing the right side of the window
2026-04-08 09:32:50 +02:00
Cyril 0ab2e2f18f fixup! 🌐(i18n) add PiP labels for each supported language 2026-04-08 09:32:49 +02:00
Cyril 99dcd34482 fixup! (feat) connect PiP to room and options menu 2026-04-08 09:32:49 +02:00
Cyril 2bdd8ddbaf fixup! 🐛(fix) add react-aria overlays type shim 2026-04-08 09:30:49 +02:00
Cyril 94488f31cf (feat) connect PiP to room and options menu
wire room-level PiP state to the options menu
2026-04-08 09:30:49 +02:00
Cyril e49355985a 💄(style) align pip layout with room styling
match pip background and spacing to
2026-04-08 09:27:29 +02:00
Cyril 8e3cd3f9a3 💄(style) adjust tooltip positioning and fix ts error
raise tooltip offset and set arrowBoundaryOffset
2026-04-08 09:27:29 +02:00
Cyril 742c1b467b 🐛(fix) add react-aria overlays type shim
fix missing useUNSAFE_PortalContext export typing
2026-04-08 09:27:28 +02:00
Cyril 37b5dba12f (feat) add pip lateral menu component
add pip chevron menu
2026-04-08 09:27:28 +02:00
Cyril 6912d4115d 📝(docs) add pip component documentation
add jsdoc comments to pip components and hooks
2026-04-08 09:27:28 +02:00
Cyril d1a3052f28 🐛(fix) close pip menu on item action
close options menu when selecting transcription or
2026-04-08 09:27:27 +02:00
Cyril fcfcb3eff3 ♻️(refactor) extract pip control bar component
centralize pip controls in reusable component
2026-04-08 09:27:27 +02:00
Cyril 5863b4495a ♻️(feat) extract pip options menu component
keep pip options layout reusable and isolated
2026-04-08 09:27:26 +02:00
Cyril cf37d636db 💄(fix) align overlays in pip
route menus/tooltips to the pip document
2026-04-08 09:27:26 +02:00
Cyril 3fd265f291 (refactor) stabilize pip layout and controls
avoid layout jumps and prepare for more buttons
2026-04-08 09:27:26 +02:00
Cyril 2c30535235 🐛(fix) rebuild pip root on reopen
prevent black screen after closing and reopening pip
2026-04-08 09:27:25 +02:00
Cyril cea373a00f 🌐(i18n) add PiP labels for each supported language
add labels for PiP in each supported language
2026-04-08 09:27:25 +02:00
Cyril 857dccd00b (feat) connect PiP to room and options menu
connecting pip and room
2026-04-08 09:27:24 +02:00
Cyril f0f6c4bf56 (feat) add pip portal and base layout
mount react content into pip window with styles
2026-04-08 09:27:24 +02:00
Cyril 85e13f7e44 (feat) add document pip hook
utility hook to cleanly open/close the pip window
2026-04-08 09:27:19 +02:00
dependabot[bot] 34212be6e2 ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
Update indirect dependency to include latest fixes and improvements.

See release notes and commits for detailed changes.

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 19:31:38 +02:00
renovate[bot] d8ccd02bb2 ⬆️(dependencies) update vite to v7.3.2 [SECURITY] 2026-04-07 19:12:24 +02:00
renovate[bot] 08aa63ecb2 ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY] 2026-04-07 17:22:56 +02:00
lebaudantoine bbc8f61221 🔖(helm) release chart 0.0.19 2026-04-07 14:38:18 +02:00
lebaudantoine 6b656eefd7 (backend) add unit tests for JwtTokenService
Introduce minimal unit test coverage for the JwtTokenService
to ensure its core behavior is validated.
2026-04-02 23:18:39 +02:00
fheslouin 264f267ac3 🔒️(helm) add pod and container securityContext
This commit aim at adding a securityContext for
pod and container in Deployment and Job,
it include livekit pods as well

It adds 2 values :
- podSecurityContext : for pods
- securityContext : for containers

Please note that `celeryBackend` Deployment does
not have any values defined in `values.meet.yaml` at the moment.
2026-04-02 13:49:22 +02:00
Florent Chehab 4bf3ba4c48 🔖(helm) release chart 0.0.18 2026-04-02 10:34:13 +02:00
Florent Chehab 4fdc2eee11 📝(backend) move and improve summary method documentation
Quick change post PR review.
2026-04-01 17:43:43 +02:00
Florent Chehab 19c2a378e7 (summary) taskV2 closer to target API gateway contract
Updated taskV2 API contract to be closer to the target gateway contract.
GET operations return the same things as the webhook payload.
Also store the summary on S3 to be iso with transcript.
2026-04-01 17:43:43 +02:00
Florent Chehab 5a70604f01 (summary) add multi-tenant support and v2 tasks / API
Add multitenancy support to Summary sub-app. The V1 routes / tasks
behave like before, with the default tenant being "meet".

V2 routes / tasks support being called frm any tenant, and don't have
meet related logic.
V2 tasks are created in separate queues to avoid mix / match,i
2026-04-01 17:43:43 +02:00
lebaudantoine 7e422e5846 🔖(minor) bump release to 1.13.0 2026-04-01 10:24:36 +02:00
lebaudantoine d915b93caa 🚨(backend) fix InsecureKeyLengthWarning in test suite
Resolve warnings raised in jwt/api_jwt.py:365 by ensuring test
keys meet the required security length.

Align test configuration with expected cryptographic standards.
2026-03-28 16:18:16 +01:00
dependabot[bot] 9d9ec794aa Bump cryptography from 46.0.5 to 46.0.6 in /src/backend
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:44:38 +01:00
dependabot[bot] 570d57d5f5 ⬆️ Bump requests from 2.32.5 to 2.33.0 in /src/summary
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:39:28 +01:00
dependabot[bot] 7469ccfdf1 ⬆️ Bump requests from 2.32.5 to 2.33.0 in /src/backend
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 15:36:22 +01:00
Florent Chehab 0d3bd2727e 🧑‍💻(tilt) load secret vars from new env file
To avoid commiting secrets, I am introducing a new kube-secret
env file that is loaded by tilt as a secret. Dev helm
values are updated accordingly.
2026-03-28 10:36:37 +01:00
lebaudantoine 660b022eb1 ️(frontend) enhance sidepanel navigation accessibility
Mark the more options area as an explicit navigation
region for screen readers.

Update each sidepanel toggle to use `aria-expanded` to indicate
whether the panel is open, improving accessibility feedback.

Also, avoid render for screen reader the number of participants
as it's already added the the aria label
2026-03-28 00:06:34 +01:00
lebaudantoine a28b611ecc ️(frontend) add explicit region for call controls
Declare a dedicated ARIA region for call controls
to improve accessibility.

Extract this region into a reusable component for better
consistency and maintainability.
2026-03-28 00:06:33 +01:00
lebaudantoine dfa6092c72 ️(frontend) improve accessibility of the reaction toolbar
Add an aria-label to clearly describe the purpose of the toolbar.

Avoid redundant wording in reaction button labels to improve
clarity for screen reader users.
2026-03-28 00:06:33 +01:00
lebaudantoine 7d8c166c7f ♻️(frontend) introduce persistent reaction toolbar
Inspired by proprietary solutions.

Replace the dialog-based reaction UI with a toolbar integrated
directly into the DOM.

Allow it to remain open and support proper keyboard interaction,
improving accessibility and user experience.
2026-03-28 00:06:33 +01:00
lebaudantoine f7dd1f8fd7 ♻️(frontend) extract layout constants for alignment
Move hardcoded values used for layout alignment and animations
between the videoconference and side panel into shared constants.

This improves readability and makes future adjustments easier to
maintain.
2026-03-28 00:06:33 +01:00
lebaudantoine 416411b843 ♻️(frontend) simplify videoconference layout and clarify component roles
Remove unnecessary wrapper divs to reduce layout complexity.

Explicitly name components to better reflect their
responsibilities, including RoomContentArea which handles the
video track viewport.
2026-03-28 00:06:33 +01:00
lebaudantoine 45e0665cf0 ♻️(frontend) extract layout components into a dedicated feature
Group layout-related components under a single feature directory
to improve structure and readability.

This is a first step toward cleaning up the project and clarifying
the organization of the video call layout.
2026-03-28 00:06:32 +01:00
lebaudantoine a3eabf8f66 ♻️(frontend) move reaction-related code into a dedicated feature folder
Group all reaction components, hooks, and logic under a single
feature directory to improve code organization and maintainability.
2026-03-28 00:06:32 +01:00
lebaudantoine 7c81947681 ♻️(frontend) refactor reaction system to unify state and rendering
Use a single store, hook, and portal system to handle both local
and remote emoji reactions.

Improve code quality and reduce duplication through better
factorization of shared logic.
2026-03-28 00:06:32 +01:00
lebaudantoine 2424817523 🔥(tilt) remove tooling for Tilt development stack
Clean up unused development tooling related to running
the Tilt dev environment.
2026-03-27 23:48:33 +01:00
138 changed files with 5903 additions and 1002 deletions
+2 -5
View File
@@ -223,8 +223,6 @@ jobs:
DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
@@ -306,7 +304,8 @@ jobs:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
@@ -318,8 +317,6 @@ jobs:
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
+22
View File
@@ -8,9 +8,29 @@ and this project adheres to
## [Unreleased]
### Added
- 🔒️(helm) Add pod and container securityContext #1197
- ✨(summary) add routes v2 for async STT and summary tasks #1171
- ✅(backend) add unit tests for JwtTokenService #1232
### Changed
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
### Fixed
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
## [1.13.0] - 2026-03-31
### Changed
- ⬆️(dependencies) update python dependencies
- ♿️(frontend) add explicit region for call controls #1216
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
### Fixed
@@ -160,6 +180,8 @@ and this project adheres to
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
### Changed
+5 -5
View File
@@ -73,7 +73,8 @@ create-env-files: \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/summary \
env.d/development/kube-secret
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -265,6 +266,9 @@ env.d/development/kc_postgresql:
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
# -- Internationalization
env.d/development/crowdin:
@@ -359,10 +363,6 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+9 -2
View File
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -95,12 +95,19 @@ docker_build(
)
clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
load('ext://secret', 'secret_yaml_generic')
k8s_yaml(secret_yaml_generic(
name="secret-dev",
from_env_file="../env.d/development/kube-secret"
))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
+4
View File
@@ -0,0 +1,4 @@
WHISPERX_BASE_URL=https://configure-your-url.com
WHISPERX_API_KEY=<key>
LLM_BASE_URL=https://configure-your-url.com
LLM_API_KEY=<key>
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.12.0"
version = "1.13.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
@@ -205,7 +205,7 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!"
room = RoomFactory()
client = APIClient()
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
"""Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key",
"api_secret": "test_api_secret",
"api_secret": "test_api_secret_padded_to_32bytes!",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
@@ -0,0 +1,541 @@
"""
Tests for JWT token service.
"""
# pylint: disable=W0212,W0621
import uuid
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
import jwt as pyjwt
import pytest
from freezegun import freeze_time
from core.services.jwt_token import (
JwtTokenService,
TokenDecodeError,
TokenExpiredError,
TokenInvalidError,
)
# -- Fixtures --
@pytest.fixture
def jwt_service():
"""Create a JWT token service for testing."""
return JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="test-issuer",
audience="test-audience",
expiration_seconds=3600,
token_type="Bearer",
)
@pytest.fixture
def mock_user():
"""Create a mock user with a string ID."""
user = mock.Mock()
user.id = "test-user-id"
return user
# -- __init__ / Configuration --
def test_init_missing_secret_key():
"""Missing secret key should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
JwtTokenService(
secret_key="",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_none_secret_key():
"""None secret key should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
JwtTokenService(
secret_key=None,
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_missing_algorithm():
"""Missing algorithm should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_none_algorithm():
"""None algorithm should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm=None,
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_missing_token_type():
"""Missing token type should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="",
)
def test_init_none_token_type():
"""None token type should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type=None,
)
def test_init_none_expiration_seconds():
"""None expiration seconds should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=None,
token_type="Bearer",
)
def test_init_zero_expiration_seconds_is_accepted():
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=0,
token_type="Bearer",
)
assert service._expiration_seconds == 0
def test_init_stores_config_correctly():
"""All config values should be stored correctly on the instance."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="my-issuer",
audience="my-audience",
expiration_seconds=1800,
token_type="Bearer",
)
assert service._key == "test-secret-padded-to-32-bytes!!"
assert service._algorithm == "HS256"
assert service._issuer == "my-issuer"
assert service._audience == "my-audience"
assert service._expiration_seconds == 1800
assert service._token_type == "Bearer"
# -- generate_jwt / Return shape --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
"""Response always contains access_token, token_type, and expires_in."""
result = jwt_service.generate_jwt(mock_user, scope="read")
assert "access_token" in result
assert "token_type" in result
assert "expires_in" in result
assert result["token_type"] == "Bearer"
assert result["expires_in"] == 3600
assert isinstance(result["access_token"], str)
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
"""scope key should be present in response when scope is provided."""
result = jwt_service.generate_jwt(mock_user, scope="read write")
assert result["scope"] == "read write"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
"""scope key should be absent from response when scope is empty."""
result = jwt_service.generate_jwt(mock_user, scope="")
assert "scope" not in result
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
"""scope key should be absent from response when scope is None."""
result = jwt_service.generate_jwt(mock_user, scope=None)
assert "scope" not in result
# -- generate_jwt / Payload correctness --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
"""Payload should always contain iat, exp, and user_id."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iat"] == 1673784000
assert payload["exp"] == 1673787600
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
"""exp should equal iat + expiration_seconds exactly."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=900,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = service.decode_jwt(result["access_token"])
assert payload["exp"] - payload["iat"] == 900
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
"""iss should be present in payload when issuer is non-empty."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iss"] == "test-issuer"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
"""aud should be present in payload when audience is non-empty."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["aud"] == "test-audience"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_absent_when_empty(mock_user):
"""iss should be absent from payload when issuer is empty string."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="",
audience="",
expiration_seconds=3600,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
options={"verify_aud": False},
)
assert "iss" not in payload
assert "aud" not in payload
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_absent_when_none(mock_user):
"""iss should be absent from payload when issuer is None."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer=None,
audience=None,
expiration_seconds=3600,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
options={"verify_aud": False},
)
assert "iss" not in payload
assert "aud" not in payload
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
"""scope should be absent from payload when not provided."""
result = jwt_service.generate_jwt(mock_user, scope="")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
issuer="test-issuer",
audience="test-audience",
)
assert "scope" not in payload
# -- generate_jwt / extra_payload handling --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
"""extra_payload=None should not crash and produce a valid token."""
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
jwt_service, mock_user
):
"""Non-colliding extra_payload keys should appear in decoded token."""
result = jwt_service.generate_jwt(
mock_user,
scope="read",
extra_payload={"client_id": "my-app", "delegated": True},
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["client_id"] == "my-app"
assert payload["delegated"] is True
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
"""iat in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iat"] == 1673784000
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
"""exp in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"exp": 9999999999}
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["exp"] == 1673787600
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
jwt_service, mock_user
):
"""user_id in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"user_id": "hacked"}
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
"""generate_jwt should not mutate the original extra_payload dict."""
extra = {"client_id": "my-app"}
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
assert extra == {"client_id": "my-app"}
# -- generate_jwt / user.id casting --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
"""user.id as UUID should be cast to str in payload."""
user = mock.Mock()
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
result = jwt_service.generate_jwt(user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
# -- decode_jwt / Happy path --
def test_decode_jwt_roundtrip(jwt_service, mock_user):
"""Valid token should decode to correct payload."""
with freeze_time("2023-01-15 12:00:00"):
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"client_id": "my-app"}
)
with freeze_time("2023-01-15 12:30:00"):
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
assert payload["scope"] == "read"
assert payload["client_id"] == "my-app"
assert payload["iss"] == "test-issuer"
assert payload["aud"] == "test-audience"
# -- decode_jwt / Error mapping --
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
"""Expired token should raise TokenExpiredError."""
with freeze_time("2023-01-15 12:00:00"):
result = jwt_service.generate_jwt(mock_user, scope="read")
with freeze_time("2099-01-01 00:00:00"):
with pytest.raises(TokenExpiredError):
jwt_service.decode_jwt(result["access_token"])
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
"""Token with wrong issuer should raise TokenInvalidError."""
service_a = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer-a",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
service_b = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer-b",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_a.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenInvalidError):
service_b.decode_jwt(result["access_token"])
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
"""Token with wrong audience should raise TokenInvalidError."""
service_a = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience-a",
expiration_seconds=3600,
token_type="Bearer",
)
service_b = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience-b",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_a.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenInvalidError):
service_b.decode_jwt(result["access_token"])
def test_decode_jwt_tampered_signature_raises_token_decode_error(
jwt_service, mock_user
):
"""Token with tampered signature should raise TokenDecodeError."""
result = jwt_service.generate_jwt(mock_user, scope="read")
header, payload, _ = result["access_token"].split(".")
tampered_token = f"{header}.{payload}.invalidsignature"
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt(tampered_token)
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
"""Garbage string should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt("this.is.not.a.valid.token")
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
"""Empty string should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt("")
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
"""None should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt(None)
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
service_hs256 = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
service_rs256 = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="RS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_hs256.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenDecodeError):
service_rs256.decode_jwt(result["access_token"])
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
}
token = jwt.encode(
payload,
"invalid-private-key",
"invalid-private-key-padded-to-32b!",
algorithm=settings.APPLICATION_JWT_ALG,
)
+7 -1
View File
@@ -981,7 +981,13 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
LIVEKIT_CONFIGURATION = {
"api_key": "devkey-padded-for-minimum-len!-livekit",
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
"url": "http://127.0.0.1.nip.io:7880",
}
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True
+3 -3
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.12.0"
version = "1.13.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -55,12 +55,12 @@ dependencies = [
"PyJWT==2.12.1",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"requests==2.33.0",
"sentry-sdk==2.54.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.3",
"aiohttp==3.13.4",
]
[project.urls]
+103 -103
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -24,59 +24,59 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
@@ -474,55 +474,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.5"
version = "46.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
]
[[package]]
@@ -1157,7 +1157,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.12.0"
version = "1.13.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1221,7 +1221,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.13.3" },
{ name = "aiohttp", specifier = "==3.13.4" },
{ name = "boto3", specifier = "==1.42.68" },
{ name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" },
@@ -1255,7 +1255,7 @@ requires-dist = [
{ name = "python-frontmatter", specifier = "==1.1.0" },
{ name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "requests", specifier = "==2.33.0" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -1965,7 +1965,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1973,9 +1973,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
]
[[package]]
+18
View File
@@ -0,0 +1,18 @@
import type * as React from 'react';
declare module '@react-aria/overlays' {
export type PortalProviderContextValue = {
getContainer: () => HTMLElement | null;
};
export type PortalProviderProps = {
getContainer: () => HTMLElement | null;
children: React.ReactNode;
};
export function useUNSAFE_PortalContext(): PortalProviderContextValue;
export function UNSAFE_PortalProvider(
props: PortalProviderProps,
): JSX.Element;
}
+5 -1
View File
@@ -1,5 +1,7 @@
FROM node:20-alpine AS frontend-deps
USER node
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
@@ -8,7 +10,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
COPY --chown=node:node ./src/frontend/ .
### ---- Front-end builder image ----
FROM frontend-deps AS meet
@@ -17,6 +19,8 @@ WORKDIR /home/frontend
FROM frontend-deps AS meet-dev
USER node
WORKDIR /home/frontend
EXPOSE 8080
+116 -116
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.12.0",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.12.0",
"version": "1.13.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
@@ -57,7 +57,7 @@
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-tsconfig-paths": "6.1.1"
}
},
@@ -822,9 +822,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
@@ -11334,9 +11334,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11424,9 +11424,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
@@ -11441,9 +11441,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
@@ -11458,9 +11458,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
@@ -11475,9 +11475,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
@@ -11492,9 +11492,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
@@ -11509,9 +11509,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
@@ -11526,9 +11526,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
@@ -11543,9 +11543,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
@@ -11560,9 +11560,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
@@ -11577,9 +11577,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
@@ -11594,9 +11594,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
@@ -11611,9 +11611,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
@@ -11628,9 +11628,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
@@ -11645,9 +11645,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
@@ -11662,9 +11662,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
@@ -11679,9 +11679,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
@@ -11696,9 +11696,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
@@ -11713,9 +11713,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
@@ -11730,9 +11730,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
@@ -11747,9 +11747,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
@@ -11764,9 +11764,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
@@ -11781,9 +11781,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
@@ -11798,9 +11798,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
@@ -11815,9 +11815,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
@@ -11832,9 +11832,9 @@
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
@@ -11849,9 +11849,9 @@
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -11862,32 +11862,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/vite/node_modules/fdir": {
@@ -11909,9 +11909,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.12.0",
"version": "1.13.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -62,7 +62,7 @@
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-tsconfig-paths": "6.1.1"
}
}
+5
View File
@@ -274,6 +274,11 @@ const config: Config = {
min: { value: 'min-content' },
max: { value: 'max-content' },
fit: { value: 'fit-content' },
// room layout
'room-side-panel': { value: '360px' },
'room-side-panel-margin': { value: '1.5rem' },
'room-control-bar': { value: '80px' },
'room-reaction-toolbar-height': { value: '42px' },
},
spacing,
}),
@@ -0,0 +1,44 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
const controlBarRegion = cva({
base: {
display: 'flex',
alignItems: 'center',
flex: '1 1 33%',
justifyContent: 'center',
gap: '0.65rem',
},
variants: {
mobile: {
true: {
justifyContent: 'space-between',
width: '330px',
},
},
},
defaultVariants: {
mobile: false,
},
})
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
RecipeVariantProps<typeof controlBarRegion>
export function ControlBarRegion({
children,
mobile,
...props
}: ControlBarRegionProps) {
const { t } = useTranslation('rooms')
return (
<div
role="region"
aria-label={t('controls.region')}
className={controlBarRegion({ mobile })}
{...props}
>
{children}
</div>
)
}
@@ -7,9 +7,9 @@ import {
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from '../controls/PaginationIndicator'
import { useGridLayout } from '../../hooks/useGridLayout'
import { PaginationControl } from '../controls/PaginationControl'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
/** @public */
export interface GridLayoutProps
@@ -0,0 +1,86 @@
// RoomContentArea.tsx
import React from 'react'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
const RoomViewport = styled(
'div',
cva({
base: {
position: 'absolute',
maxHeight: '100%',
transition:
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
},
variants: {
isSidePanelOpen: {
true: {
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
},
false: {
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
},
},
isReactionToolbarOpen: {
true: {
paddingBottom:
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
},
false: {
paddingBottom: '0',
},
},
},
})
)
const TrackAreaContainer = styled(
'div',
cva({
base: {
position: 'relative',
display: 'flex',
width: '100%',
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
},
variants: {
areSubtitlesOpen: {
true: {
height: 'calc(100% - 12rem)',
},
false: {
height: '100%',
},
},
},
})
)
interface RoomContentAreaProps {
children: React.ReactNode
}
export function RoomContentArea({ children }: RoomContentAreaProps) {
const { isSidePanelOpen } = useSidePanel()
const { areSubtitlesOpen } = useSubtitles()
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<RoomViewport
isSidePanelOpen={isSidePanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
>
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
{children}
</TrackAreaContainer>
<Subtitles />
<MainNotificationToast />
</RoomViewport>
)
}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -10,17 +10,15 @@ import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import {
Emoji,
Reaction,
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
// cover bursts and re-emits while staying negligible in memory.
const MAX_TRACKED_CHAT_IDS = 16
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -28,8 +26,11 @@ export const MainNotificationToast = () => {
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const { appendReaction } = useReactions()
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
// Dedupe against a small ring of recent ids.
const seenChatMsgIdsRef = useRef<string[]>([])
useEffect(() => {
const handleChatMessage = (
@@ -37,6 +38,13 @@ export const MainNotificationToast = () => {
participant?: Participant | undefined
) => {
if (!participant || participant.isLocal) return
const id = chatMessage.id
if (id) {
const seen = seenChatMsgIdsRef.current
if (seen.includes(id)) return
seen.push(id)
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
}
triggerNotificationSound(NotificationType.MessageReceived)
toastQueue.add(
{
@@ -62,21 +70,13 @@ export const MainNotificationToast = () => {
}
}, [room, triggerNotificationSound, announce, t])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
const id = instanceIdRef.current++
setReactions((prev) => [
...prev,
{
id,
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
const handleEmoji = useCallback(
(emoji: string, participant: Participant) => {
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
appendReaction(emoji as Emoji, participant)
},
[appendReaction]
)
useEffect(() => {
const handleDataReceived = (
@@ -149,7 +149,7 @@ export const MainNotificationToast = () => {
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room])
}, [room, handleEmoji])
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
@@ -252,7 +252,6 @@ export const MainNotificationToast = () => {
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<ReactionPortals reactions={reactions} />
</Div>
)
}
@@ -0,0 +1,234 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useDocumentPiP } from '../hooks/useDocumentPiP'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
// Minimal base styles so the PiP window renders correctly on first paint.
const ensureBaseStyles = (target: Document) => {
if (target.getElementById('pip-base-styles')) return
const style = target.createElement('style')
style.id = 'pip-base-styles'
style.textContent = `
html, body { margin: 0; padding: 0; height: 100%; background: #0b0f19; }
body { overflow: hidden; }
* { box-sizing: border-box; }
`
target.head.appendChild(style)
}
// Clone existing styles to keep the PiP window visually consistent.
const copyStyles = (source: Document, target: Document) => {
if (target.getElementById('pip-style-clone')) return
const marker = target.createElement('meta')
marker.id = 'pip-style-clone'
target.head.appendChild(marker)
source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
const cloned = node.cloneNode(true) as HTMLElement
target.head.appendChild(cloned)
})
}
const syncThemeAttribute = (source: Document, target: Document) => {
const theme = source.documentElement.getAttribute('data-lk-theme')
if (theme) {
target.documentElement.setAttribute('data-lk-theme', theme)
} else {
target.documentElement.removeAttribute('data-lk-theme')
}
}
const cssVarNameCacheByElement = new WeakMap<HTMLElement, string[]>()
const cssVarNameCacheByUri = new Map<string, string[]>()
const syncCssVariables = (source: Document, target: Document) => {
const sourceView = source.defaultView
if (!sourceView) return
const getCachedVarNames = () => {
const docEl = source.documentElement
if (!docEl) return []
const cachedByElement = cssVarNameCacheByElement.get(docEl)
if (cachedByElement) return cachedByElement
const cachedByUri = source.baseURI
? cssVarNameCacheByUri.get(source.baseURI)
: undefined
if (cachedByUri) return cachedByUri
const varNames = new Set<string>()
const collectVarsFrom = (element: HTMLElement | null) => {
if (!element) return
const styles = sourceView.getComputedStyle(element)
for (let i = 0; i < styles.length; i += 1) {
const property = styles[i]
if (property.startsWith('--')) {
varNames.add(property)
}
}
}
collectVarsFrom(source.documentElement)
collectVarsFrom(source.body)
const result = Array.from(varNames)
cssVarNameCacheByElement.set(docEl, result)
if (source.baseURI) {
cssVarNameCacheByUri.set(source.baseURI, result)
}
return result
}
const varNames = getCachedVarNames()
if (!varNames.length) return
const rootStyles = sourceView.getComputedStyle(source.documentElement)
const bodyStyles = source.body
? sourceView.getComputedStyle(source.body)
: null
varNames.forEach((property) => {
const bodyValue = bodyStyles?.getPropertyValue(property)
const value = bodyValue || rootStyles.getPropertyValue(property)
if (value) {
target.documentElement.style.setProperty(property, value)
}
})
}
/**
* React portal into a Document Picture-in-Picture window. Handles window
* lifecycle, style/theme sync and routes React Aria overlays via
* `UNSAFE_PortalProvider` so they render inside the PiP document.
*/
export const DocumentPiPPortal = ({
isOpen,
width,
height,
children,
onClose,
}: {
isOpen: boolean
width?: number
height?: number
children: React.ReactNode
onClose?: () => void
}): ReactNode => {
const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({
width,
height,
})
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
const announce = useScreenReaderAnnounce()
const [container, setContainer] = useState<HTMLElement | null>(null)
const containerRef = useRef<HTMLElement | null>(null)
const prevOpenRef = useRef(false)
useEffect(() => {
if (!isOpen) {
closePiP()
setContainer(null)
containerRef.current = null
return
}
if (!isSupported) return
let cancelled = false
openPiP().then((win) => {
if (!win || cancelled) return
const doc = win.document
ensureBaseStyles(doc)
copyStyles(document, doc)
syncThemeAttribute(document, doc)
syncCssVariables(document, doc)
doc.documentElement.setAttribute('lang', document.documentElement.lang)
doc.title = t('windowLabel')
const existingContainer = containerRef.current
if (!existingContainer || existingContainer.ownerDocument !== doc) {
const nextContainer = doc.createElement('div')
nextContainer.id = 'pip-root'
nextContainer.style.width = '100%'
nextContainer.style.height = '100%'
nextContainer.style.display = 'flex'
nextContainer.style.alignItems = 'stretch'
nextContainer.style.justifyContent = 'center'
doc.body.appendChild(nextContainer)
containerRef.current = nextContainer
setContainer(nextContainer)
} else {
setContainer(existingContainer)
}
})
return () => {
cancelled = true
}
}, [closePiP, isOpen, isSupported, openPiP, t])
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
useEffect(() => {
const wasOpen = prevOpenRef.current
prevOpenRef.current = isOpen
if (isOpen && !wasOpen) {
announce(t('opened'), 'polite')
}
if (!isOpen && wasOpen) {
announce(t('closed'), 'polite')
}
}, [isOpen, announce, t])
useRestoreFocus(isOpen, { restoreFocusRaf: true })
// Escape from either document closes PiP (unless a nested overlay handled it).
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
onClose?.()
}
document.addEventListener('keydown', handleKeyDown)
pipWindow?.document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
pipWindow?.document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose, pipWindow])
useEffect(() => {
if (!pipWindow) return
const handleClose = () => {
containerRef.current = null
setContainer(null)
onClose?.()
}
pipWindow.addEventListener('pagehide', handleClose)
pipWindow.addEventListener('beforeunload', handleClose)
return () => {
pipWindow.removeEventListener('pagehide', handleClose)
pipWindow.removeEventListener('beforeunload', handleClose)
}
}, [onClose, pipWindow])
const portal = useMemo(() => {
if (!container) return null
return createPortal(
<UNSAFE_PortalProvider getContainer={() => container}>
{children}
</UNSAFE_PortalProvider>,
container
)
}, [children, container])
return portal as unknown as ReactNode
}
@@ -0,0 +1,122 @@
import { styled } from '@/styled-system/jsx'
import { useRef, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { findFirstFocusable } from '@/utils/dom'
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { PipReactionsToggle } from './PipReactionsToggle'
export type CollapsibleControl =
| 'hand'
// | 'subtitles'
| 'screenShare'
| 'reactions'
const COLLAPSE_ORDER: CollapsibleControl[] = [
'hand',
// 'subtitles',
'screenShare',
'reactions',
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
const getHiddenControls = (
containerWidth: number,
showScreenShare: boolean
): Set<CollapsibleControl> => {
const hidden = new Set<CollapsibleControl>()
if (containerWidth <= 0) return hidden
const collapsible = showScreenShare
? COLLAPSE_ORDER
: COLLAPSE_ORDER.filter((c) => c !== 'screenShare')
const available = containerWidth - ESSENTIAL_WIDTH
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
for (let i = 0; i < collapsible.length - maxVisible; i++) {
hidden.add(collapsible[i])
}
return hidden
}
export const PipControlBar = ({
showScreenShare,
}: {
showScreenShare: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width } = usePipElementSize(containerRef)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
handler: useCallback(() => {
const doc = containerRef.current?.ownerDocument ?? document
findFirstFocusable(doc.getElementById('pip-control-bar'))?.focus()
}, []),
})
return (
<PipControls
ref={containerRef}
id="pip-control-bar"
role="toolbar"
aria-label={t('controlBar')}
>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has('reactions') && <PipReactionsToggle />}
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
{/*{!hidden.has('subtitles') && <SubtitlesToggle />}*/}
{!hidden.has('hand') && <HandToggle />}
<PipOptionsMenu overflowControls={hidden} />
<LeaveButton />
<StartMediaButton />
</PipControlsCenter>
</PipControls>
)
}
const PipControls = styled('div', {
base: {
flex: '0 0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'primaryDark.50',
width: '100%',
position: 'relative',
},
})
const PipControlsCenter = styled('div', {
base: {
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'center',
alignItems: 'center',
gap: '0.4rem',
flex: '1 1 auto',
},
})
@@ -0,0 +1,34 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { useSnapshot } from 'valtio'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const PipReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
const toggle = useCallback(() => {
pipLayoutStore.showReactionsToolbar = !pipLayoutStore.showReactionsToolbar
}, [])
useRegisterKeyboardShortcut({ id: 'reaction', handler: toggle })
return (
<ToggleButton
id="pip-reactions-toggle"
data-attr="pip-reactions-toggle"
square
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
>
<RiEmotionLine />
</ToggleButton>
)
}
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import { FocusScope } from '@react-aria/focus'
import { styled } from '@/styled-system/jsx'
import { useSnapshot } from 'valtio'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation'
import { PipReactionsPill } from './reactions/PipReactionsPill'
/**
* Reactions toolbar for the PiP window. Owns only the open/close orchestration;
* layout and pagination live in `PipReactionsPill`, keyboard nav in
* `PipReactionsKeyboardNavigation`.
*/
export const PipReactionsToolbar = () => {
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
// Unmount content after the close transition so hidden emojis leave the tab order.
const renderContent = useDelayUnmount(isOpen, 500)
const contentRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const { width: availableWidth } = usePipElementSize(wrapperRef)
// Mark the subtree inert during the fade-out so Tab can't land on it.
useEffect(() => {
const el = contentRef.current
if (!el) return
if (isOpen) el.removeAttribute('inert')
else el.setAttribute('inert', '')
}, [isOpen, renderContent])
return (
<Wrapper ref={wrapperRef} isOpen={isOpen}>
{renderContent && (
<div ref={contentRef}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<PipReactionsKeyboardNavigation>
<PipReactionsPill
isOpen={isOpen}
availableWidth={availableWidth}
/>
</PipReactionsKeyboardNavigation>
</FocusScope>
</div>
)}
</Wrapper>
)
}
const Wrapper = styled('div', {
base: {
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
maxHeight: 0,
padding: '0 0.5rem',
transition:
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
},
variants: {
isOpen: {
true: {
maxHeight: '60px',
padding: '0.5rem 0.5rem 0.25rem',
},
},
},
})
@@ -0,0 +1,102 @@
import { useCallback, useRef } from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
import { usePipKeyboardShortcuts } from '../hooks/usePipKeyboardShortcuts'
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
import { PipControlBar } from './PipControlBar'
import { PipReactionsToolbar } from './PipReactionsToolbar'
import { PipStage } from './layouts/PipStage'
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
export const PipView = () => {
const browserSupportsScreenSharing = supportsScreenSharing()
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
const containerRef = useRef<HTMLDivElement>(null)
const { isSidePanelOpen, closePanel } = useSidePanel(pipLayoutStore)
// Escape closes the side panel instead of the whole PiP window.
useEscapeDismiss(containerRef, isSidePanelOpen, closePanel)
// Forward keyboard shortcuts (Ctrl+D, Ctrl+E, etc.) to the main store.
usePipKeyboardShortcuts(containerRef)
// Side panels open via a menu item that unmounts on click; fall back to the
// options button so focus returns somewhere visible.
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
const doc = containerRef.current?.ownerDocument ?? document
return doc.getElementById('room-options-trigger')
}
return activeEl
}, [])
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
return (
<PipContainer
ref={containerRef}
role="region"
aria-label={t('windowLabel')}
>
<PipStage />
<PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
<SidePanel store={pipLayoutStore} />
<OverlayStack>
<PipConnectionStateToast />
<PipNotificationOverlay />
</OverlayStack>
</PipContainer>
)
}
const OverlayStack = styled('div', {
base: {
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
right: '0.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.375rem',
pointerEvents: 'none',
zIndex: 1000,
'& > *': { pointerEvents: 'auto' },
},
})
const PipContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
display: 'grid',
gridTemplateRows: 'minmax(0, 1fr) auto auto',
backgroundColor: 'primaryDark.50',
// Disable LiveKit's own border-radius on tiles so our containers
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
'--lk-border-radius': '4px',
'& .lk-participant-tile': {
height: '100%',
},
'& .lk-participant-media': {
height: '100%',
},
'& .lk-participant-media-video': {
height: '100%',
objectFit: 'cover',
},
'& .lk-grid-layout': {
height: '100%',
width: '100%',
},
},
})
@@ -0,0 +1,30 @@
import { useEffect, type ReactNode } from 'react'
import { roomPiPStore } from '@/stores/roomPiP'
import { DocumentPiPPortal } from './DocumentPiPPortal'
import { PipView } from './PipView'
import { useRoomPiP } from '../hooks/useRoomPiP'
/**
* Wrapper that mounts the PiP UI when room-level PiP state is enabled.
* Bridges Valtio-backed PiP state with DocumentPiPPortal and PipView rendering.
* PiP panel state is decoupled via explicit pipLayoutStore injection.
*/
export const RoomPiP = (): ReactNode => {
const { isOpen, close } = useRoomPiP()
// Reset PiP state on unmount (e.g. leaving the room) so the next session
// starts with PiP closed and doesn't try to auto-reopen without a user gesture.
useEffect(() => {
return () => {
roomPiPStore.isOpen = false
}
}, [])
const portal = DocumentPiPPortal({
isOpen,
onClose: close,
children: <PipView />,
})
return portal as ReactNode
}
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
import type { CollapsibleControl } from '../PipControlBar'
type PipOptionsMenuProps = {
overflowControls?: Set<CollapsibleControl>
}
/**
* PiP-native options menu. The shared `Menu` primitive mis-positions its
* popover and loses focus across documents, so we drive open/close, focus
* and dismissal ourselves.
*/
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useEscapeDismiss(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
const handleMenuItemClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
setIsOpen(false)
triggerRef.current?.focus()
})
}
}
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.contains(target)) return
setIsOpen(false)
}
doc.addEventListener('click', handleMenuItemClick, true)
doc.addEventListener('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
return (
<div
ref={wrapperRef}
className={css({
position: 'relative',
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
<RiMoreFill />
</Button>
{isOpen && (
<div
className={css({
position: 'absolute',
left: '50%',
bottom: 'calc(100% + 0.85rem)',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</FocusScope>
</div>
)}
</div>
)
}
@@ -0,0 +1,48 @@
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Separator } from '@/primitives/Separator'
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
import { PipOverflowItems } from './PipOverflowItems'
import type { CollapsibleControl } from '../PipControlBar'
type PipOptionsMenuItemsProps = {
overflowControls?: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOptionsMenuItemsProps) => {
const { t } = useTranslation('rooms')
const hasOverflow = overflowControls && overflowControls.size > 0
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
{hasOverflow && (
<>
<MenuSection>
<PipOverflowItems overflowControls={overflowControls} t={t} />
</MenuSection>
<Separator />
</>
)}
<MenuSection>
<PictureInPictureMenuItem />
<EffectsMenuItem store={pipLayoutStore} />
</MenuSection>
<Separator />
{/*<MenuSection>*/}
{/* <SupportMenuItem />*/}
{/* <FeedbackMenuItem />*/}
{/*</MenuSection>*/}
</RACMenu>
)
}
@@ -0,0 +1,74 @@
import { MenuItem } from 'react-aria-components'
import {
RiHand,
RiClosedCaptioningLine,
RiArrowUpLine,
RiEmotionLine,
} from '@remixicon/react'
import { TFunction } from 'i18next'
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { useAreSubtitlesAvailable } from '@/features/subtitle/hooks/useAreSubtitlesAvailable'
import { useSnapshot } from 'valtio'
import type { CollapsibleControl } from '../PipControlBar'
type PipOverflowItemsProps = {
overflowControls: Set<CollapsibleControl>
t: TFunction<'rooms'>
}
export const PipOverflowItems = ({
overflowControls,
t,
}: PipOverflowItemsProps) => {
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
const areSubtitlesAvailable = useAreSubtitlesAvailable()
const pipSnap = useSnapshot(pipLayoutStore)
const toggleReactions = () => {
pipLayoutStore.showReactionsToolbar = !pipSnap.showReactionsToolbar
}
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<>
{overflowControls.has('reactions') && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has('screenShare') && (
<MenuItem
onAction={() => {
/* screen share requires track toggle, handled externally */
}}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t('controls.screenShare.start')}
</MenuItem>
)}
{/*{overflowControls.has('subtitles') && areSubtitlesAvailable && (*/}
{/* <MenuItem onAction={toggleSubtitles} className={itemClass}>*/}
{/* <RiClosedCaptioningLine size={20} />*/}
{/* {areSubtitlesOpen*/}
{/* ? t('controls.subtitles.open')*/}
{/* : t('controls.subtitles.closed')}*/}
{/* </MenuItem>*/}
{/*)}*/}
{overflowControls.has('hand') && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</>
)
}
@@ -0,0 +1,81 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableMetadata
/>
</MainSlot>
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableMetadata
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -0,0 +1,81 @@
import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
}
/**
* Adaptive grid used when 3+ tracks are visible in the PiP window.
*
* All grid math (shape choice + partial-row stretching) is delegated to
* `computePipGridLayout`. This component only measures the container,
* applies the returned placements, and plays a FLIP animation when the
* tile set or grid shape changes (participant joins/leaves or shape shift).
*
* Tiles keep a stable key so resizing never remounts <video> elements.
*/
export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width, height } = usePipElementSize(containerRef)
const tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
const { rows, subColumns, placements } = useMemo(
() => computePipGridLayout(tracks.length, width, height),
[tracks.length, width, height]
)
const gridStyle = useMemo(
() => ({
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}),
[subColumns, rows]
)
usePipFlipAnimations(containerRef, tileKeys)
return (
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={tileKeys[index]} style={placements[index]}>
<ParticipantTile trackRef={track} disableMetadata />
</GridCell>
))}
</GridContainer>
)
})
PipGridLayout.displayName = 'PipGridLayout'
const GridContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
},
})
const GridCell = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
willChange: 'transform',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
@@ -0,0 +1,87 @@
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import {
isCameraTrack,
pickLocalCameraTrack,
pickRemoteCameraTrack,
pickScreenShareTrack,
} from '../../utils/pipTrackSelection'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
/**
* Above this count the PiP stage switches from the focus layout
* (main + thumbnail) to the adaptive grid layout.
*/
const FOCUS_MAX_TILES = 2
// Handles which layout to render inside the PiP stage.
export const PipStage = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false }
)
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
// Order the list so the "focus target" (screen share when available,
// otherwise a remote camera) is first. Both layouts consume this order.
const stageTracks = useMemo(() => {
const cameraTracks = tracks.filter(isCameraTrack)
if (!screenShareTrack) return cameraTracks
return [screenShareTrack, ...cameraTracks]
}, [tracks, screenShareTrack])
// avoid tabbing to the stage when it's not visible
const frameRef = useRef<HTMLDivElement>(null)
useEffect(() => {
frameRef.current?.setAttribute('inert', '')
}, [])
if (stageTracks.length === 0) return null
const stageLabel = t('stage')
if (stageTracks.length > FOCUS_MAX_TILES) {
return (
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
<PipGridLayout tracks={stageTracks} />
</StageFrame>
)
}
const localCameraTrack = pickLocalCameraTrack(stageTracks)
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
const thumbnailTrack =
localCameraTrack && localCameraTrack !== mainTrack
? localCameraTrack
: stageTracks.find((track) => track !== mainTrack)
return (
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame>
)
}
const StageFrame = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
margin: '0.5rem',
borderRadius: '4px',
overflow: 'hidden',
},
})
@@ -0,0 +1,45 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
/**
* Banner surfaced inside the PiP when the room connection degrades.
*
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
* to see while their attention is on the PiP rather than the main window.
*/
export const PipConnectionStateToast = () => {
const room = useRoomContext()
const state = useConnectionState(room)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture.connection',
})
const connectionLabels: Partial<Record<ConnectionState, string>> = {
[ConnectionState.Reconnecting]: t('reconnecting'),
[ConnectionState.Disconnected]: t('disconnected'),
}
const label = connectionLabels[state] ?? null
if (!label) return null
return <Banner role="status">{label}</Banner>
}
const Banner = styled('div', {
base: {
backgroundColor: 'greyscale.800',
color: 'white',
fontSize: '0.8125rem',
lineHeight: 1.3,
padding: '0.375rem 0.75rem',
borderRadius: '6px',
boxShadow:
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
animation: 'fade 200ms',
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
})
@@ -0,0 +1,79 @@
import { useToastQueue } from '@react-stately/toast'
import { RiCloseLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import {
toastQueue,
type ToastData,
} from '@/features/notifications/components/ToastProvider'
import { PipToastBody } from './PipToastBody'
/**
* Shows shared toasts in the PiP window.
* We use a local aria-live region so screen readers can read them in PiP.
*/
const MAX_VISIBLE = 3
export const PipNotificationOverlay = () => {
const state = useToastQueue<ToastData>(toastQueue)
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
if (state.visibleToasts.length === 0) return null
const toasts = state.visibleToasts.slice(0, MAX_VISIBLE)
return (
<Region
role="region"
aria-label={t('notificationsLabel')}
aria-live="polite"
>
{toasts.map((toast) => (
<ToastCard key={toast.key} aria-atomic="true">
<PipToastBody toast={toast} />
<Button
square
size="sm"
invisible
aria-label={t('dismissNotification')}
onPress={() => state.close(toast.key)}
>
<RiCloseLine size={16} color="white" aria-hidden="true" />
</Button>
</ToastCard>
))}
</Region>
)
}
const Region = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
alignItems: 'center',
width: '100%',
},
})
const ToastCard = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
maxWidth: '100%',
backgroundColor: 'greyscale.700',
color: 'white',
borderRadius: '6px',
boxShadow:
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
paddingRight: '0.25rem',
animation: 'fade 200ms',
'@media (prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
})
@@ -0,0 +1,119 @@
import type { QueuedToast } from '@react-stately/toast'
import { useTranslation } from 'react-i18next'
import { RiHand, RiMessage2Line } from '@remixicon/react'
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { NotificationType } from '@/features/notifications/NotificationType'
import type { ToastData } from '@/features/notifications/components/ToastProvider'
import { RecordingMode } from '@/features/recording'
type Props = {
toast: QueuedToast<ToastData>
}
/**
* Renders the toast content used in PiP.
* PiP stays display-only, so main-window actions are not shown here.
*/
export const PipToastBody = ({ toast }: Props) => {
const { t } = useTranslation('notifications')
const { type, participant, message, removedSources } = toast.content
const name = participant?.name || t('defaultName')
switch (type) {
case NotificationType.ParticipantJoined:
return <Line>{t('joined.description', { name })}</Line>
case NotificationType.ParticipantMuted:
return <Line>{t('muted', { name })}</Line>
case NotificationType.HandRaised:
return (
<Line>
<RiHand
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
{t('raised.description', { name })}
</Line>
)
case NotificationType.MessageReceived:
return (
<Line>
<RiMessage2Line
size={16}
color="white"
className={iconStyle}
aria-hidden="true"
/>
<span>
<strong>{name}</strong>
{message ? ` - ${message}` : null}
</span>
</Line>
)
case NotificationType.TranscriptionStarted:
return <Line>{t('transcript.started', { name })}</Line>
case NotificationType.TranscriptionStopped:
return <Line>{t('transcript.stopped', { name })}</Line>
case NotificationType.TranscriptionLimitReached:
return <Line>{t('transcript.limitReached')}</Line>
case NotificationType.TranscriptionRequested:
return <Line>{t('transcript.requested', { name })}</Line>
case NotificationType.ScreenRecordingStarted:
return <Line>{t('screenRecording.started', { name })}</Line>
case NotificationType.ScreenRecordingStopped:
return <Line>{t('screenRecording.stopped', { name })}</Line>
case NotificationType.ScreenRecordingLimitReached:
return <Line>{t('screenRecording.limitReached')}</Line>
case NotificationType.ScreenRecordingRequested:
return <Line>{t('screenRecording.requested', { name })}</Line>
case NotificationType.RecordingSaving: {
const mode = toast.content.mode as RecordingMode | undefined
const key =
mode === RecordingMode.ScreenRecording
? 'recordingSave.screenRecording.default'
: 'recordingSave.transcript.default'
return <Line>{t(key)}</Line>
}
case NotificationType.PermissionsRemoved: {
const key = resolvePermissionsKey(removedSources)
if (!key) return null
return <Line>{t(`permissionsRemoved.${key}`)}</Line>
}
default:
return message ? <Line>{message}</Line> : null
}
}
const resolvePermissionsKey = (sources: unknown): string | null => {
if (!Array.isArray(sources) || sources.length === 0) return null
if (sources.length === 1) return sources[0] as string
if (sources.includes('screen_share')) return 'screen_share'
return null
}
const Line = ({ children }: { children: ReactNode }) => (
<HStack
alignItems="center"
gap="0.5rem"
padding="0.625rem 0.75rem"
className={css({
fontSize: '0.8125rem',
lineHeight: 1.3,
})}
>
{children}
</HStack>
)
const iconStyle = css({ flexShrink: 0 })
@@ -0,0 +1,65 @@
import { useRef, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useFocusManager } from '@react-aria/focus'
import { findFirstFocusable } from '@/utils/dom'
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
const REACTIONS_TOGGLE_ID = 'pip-reactions-toggle'
const CONTROL_BAR_ID = 'pip-control-bar'
const closeToolbar = () => {
pipLayoutStore.showReactionsToolbar = false
}
/** Keyboard navigation for the PiP reactions toolbar (mirrors the main app). */
export const PipReactionsKeyboardNavigation = ({
children,
}: {
children: ReactNode
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const rootRef = useRef<HTMLDivElement>(null)
useEscapeDismiss(rootRef, true, () => {
const doc = rootRef.current?.ownerDocument ?? document
doc.getElementById(REACTIONS_TOGGLE_ID)?.focus()
closeToolbar()
})
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
if (fromOutside) focusManager?.focusFirst()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Tab':
if (!event.shiftKey) {
event.preventDefault()
const doc = rootRef.current?.ownerDocument ?? document
findFirstFocusable(doc.getElementById(CONTROL_BAR_ID))?.focus()
}
break
}
}
return (
<div
ref={rootRef}
role="toolbar"
aria-label={t('toolbar')}
onFocus={onFocus}
onKeyDown={onKeyDown}
>
{children}
</div>
)
}
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
import {
computeReactionsPage,
getMaxPageStart,
} from '../../utils/pipReactionsPagination'
type Props = { isOpen: boolean; availableWidth: number }
/**
* Paginated emoji pill with animated entry/exit. Responsibility: layout the
* visible emojis for the currently available width and expose prev/next arrows.
*/
export const PipReactionsPill = ({ isOpen, availableWidth }: Props) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'options.items.pictureInPicture',
})
const [isVisible, setIsVisible] = useState(false)
const [pageStart, setPageStart] = useState(0)
useEffect(() => {
if (!isOpen) {
setIsVisible(false)
return
}
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
}, [isOpen])
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight, visibleCount } =
useMemo(
() => computeReactionsPage(availableWidth, pageStart),
[availableWidth, pageStart]
)
// Clamp pageStart if the window was resized and the current page no longer fits.
useEffect(() => {
if (!hasOverflow) {
setPageStart(0)
return
}
const maxStart = getMaxPageStart(visibleCount)
if (pageStart > maxStart) setPageStart(maxStart)
}, [hasOverflow, pageStart, visibleCount])
const paginate = useCallback((direction: 'left' | 'right') => {
setPageStart((current) =>
direction === 'left' ? Math.max(0, current - 1) : current + 1
)
}, [])
return (
<Pill isVisible={isVisible}>
{hasOverflow && (
<ArrowSlot>
{canGoLeft && (
<ArrowButton
type="button"
onClick={() => paginate('left')}
aria-label={t('previousReactions')}
>
<RiArrowLeftSLine size={16} />
</ArrowButton>
)}
</ArrowSlot>
)}
<EmojiRow>
{visibleEmojis.map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</EmojiRow>
{hasOverflow && (
<ArrowSlot>
{canGoRight && (
<ArrowButton
type="button"
onClick={() => paginate('right')}
aria-label={t('nextReactions')}
>
<RiArrowRightSLine size={16} />
</ArrowButton>
)}
</ArrowSlot>
)}
</Pill>
)
}
const Pill = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
overflow: 'hidden',
width: 'fit-content',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
},
})
const EmojiRow = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
'& > *': {
flexShrink: 0,
},
},
})
const ArrowSlot = styled('div', {
base: {
width: '32px',
minWidth: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
})
const ArrowButton = styled('button', {
base: {
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '50%',
border: 'none',
backgroundColor: 'primaryDark.200',
color: 'white',
cursor: 'pointer',
opacity: 0.85,
_hover: {
opacity: 1,
backgroundColor: 'primaryDark.300',
},
},
})
@@ -0,0 +1,107 @@
import { useCallback, useEffect, useRef, useState } from 'react'
type DocumentPictureInPicture = {
requestWindow: (options?: {
width?: number
height?: number
}) => Promise<Window>
}
type WindowWithDocumentPiP = Window & {
documentPictureInPicture?: DocumentPictureInPicture
}
export const useDocumentPiP = ({
width = 400,
height = 480,
}: {
width?: number
height?: number
} = {}) => {
const [pipWindow, setPipWindow] = useState<Window | null>(null)
const pipWindowRef = useRef<Window | null>(null)
const pendingPiPRef = useRef<Promise<Window | null> | null>(null)
const [isSupported] = useState(() => {
if (typeof globalThis === 'undefined') return false
return 'documentPictureInPicture' in globalThis
})
const openPiP = useCallback(async () => {
if (!isSupported) return null
const existingWindow = pipWindowRef.current
if (existingWindow && !existingWindow.closed) return existingWindow
if (pendingPiPRef.current) return pendingPiPRef.current
// Request a new PiP window from the browser API.
const pip = (globalThis as unknown as WindowWithDocumentPiP)
.documentPictureInPicture
if (!pip) return null
const requestPromise = (async () => {
try {
const win = await pip.requestWindow({ width, height })
const currentWindow = pipWindowRef.current
if (currentWindow && !currentWindow.closed) return currentWindow
setPipWindow(win)
return win
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
} finally {
pendingPiPRef.current = null
}
})()
pendingPiPRef.current = requestPromise
return requestPromise
}, [height, isSupported, width])
const closePiP = useCallback(() => {
if (!pipWindow) return
if (!pipWindow.closed) {
pipWindow.close()
}
setPipWindow(null)
}, [pipWindow])
useEffect(() => {
pipWindowRef.current = pipWindow
}, [pipWindow])
// Force-close the native PiP window when the hook unmounts (e.g. the user
// hangs up and the room is navigated away before `closePiP` could run).
useEffect(() => {
return () => {
const win = pipWindowRef.current
if (win && !win.closed) win.close()
pipWindowRef.current = null
}
}, [])
useEffect(() => {
if (!pipWindow) return
const handleClose = () => {
setPipWindow(null)
}
pipWindow.addEventListener('pagehide', handleClose)
pipWindow.addEventListener('beforeunload', handleClose)
return () => {
pipWindow.removeEventListener('pagehide', handleClose)
pipWindow.removeEventListener('beforeunload', handleClose)
}
}, [pipWindow])
return {
isSupported,
isOpen: !!pipWindow && !pipWindow.closed,
pipWindow,
openPiP,
closePiP,
}
}
@@ -0,0 +1,28 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useEscapeDismiss = (
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onDismiss: () => void
) => {
const latestOnDismiss = useRef(onDismiss)
useEffect(() => {
latestOnDismiss.current = onDismiss
})
useEffect(() => {
if (!isActive) return
const el = ref.current
if (!el) return
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
latestOnDismiss.current()
}
el.addEventListener('keydown', handler)
return () => el.removeEventListener('keydown', handler)
}, [ref, isActive])
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
type Size = { width: number; height: number }
/**
* Observes an element's size, even when mounted in the PiP document.
* Resolves `ResizeObserver` from the element's own window.
*/
export const usePipElementSize = <T extends HTMLElement>(
ref: RefObject<T | null>
): Size => {
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
setSize({ width: rect.width, height: rect.height })
}, [ref])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
if (!RO) return
const observer = new RO((entries) => {
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
setSize({ width, height })
})
observer.observe(el)
return () => observer.disconnect()
}, [ref, measure])
return size
}
@@ -0,0 +1,91 @@
import { useLayoutEffect, useRef, type RefObject } from 'react'
type Options = {
/** Animation duration in ms. */
duration?: number
/** CSS easing function. */
easing?: string
}
/**
* FLIP (First, Last, Invert, Play) animation hook.
*
* For every keyed direct child of `containerRef`, records its position
* before a render (the "first" rect) and, once the DOM has committed, plays
* an inverse transform back to the identity position. The effect is a
* smooth slide whenever tiles are added, removed, reordered, or a new grid
* shape shifts them.
*
* Safe to call inside a Document PiP window: uses the element's own
* Web Animations API (element.animate) which lives in the PiP document.
* Respects `prefers-reduced-motion` and no-ops on the first mount.
*/
export const usePipFlipAnimations = <T extends HTMLElement>(
containerRef: RefObject<T | null>,
keys: ReadonlyArray<string>,
{ duration = 220, easing = 'cubic-bezier(0.2, 0, 0, 1)' }: Options = {}
) => {
const prevRectsRef = useRef<Map<string, DOMRect>>(new Map())
const firstRunRef = useRef(true)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const doc = container.ownerDocument
const view = doc.defaultView
const reduceMotion = view?.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
const children = Array.from(container.children) as HTMLElement[]
const nextRects = new Map<string, DOMRect>()
children.forEach((el, i) => {
const key = keys[i]
if (!key) return
nextRects.set(key, el.getBoundingClientRect())
})
if (firstRunRef.current) {
firstRunRef.current = false
prevRectsRef.current = nextRects
return
}
if (!reduceMotion) {
children.forEach((el, i) => {
const key = keys[i]
if (!key) return
const prev = prevRectsRef.current.get(key)
const next = nextRects.get(key)
if (!prev || !next) return
const dx = prev.left - next.left
const dy = prev.top - next.top
const sx = next.width === 0 ? 1 : prev.width / next.width
const sy = next.height === 0 ? 1 : prev.height / next.height
// Skip no-ops: sub-pixel shifts don't benefit from animation.
if (
Math.abs(dx) < 1 &&
Math.abs(dy) < 1 &&
Math.abs(sx - 1) < 0.01 &&
Math.abs(sy - 1) < 0.01
)
return
el.animate(
[
{
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
},
{ transform: 'translate(0, 0) scale(1, 1)' },
],
{ duration, easing, fill: 'backwards' }
)
})
}
prevRectsRef.current = nextRects
}, [containerRef, duration, easing, keys])
}
@@ -0,0 +1,45 @@
import { useEffect, type RefObject } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { isMacintosh } from '@/utils/livekit'
/**
* Mirror the main-window keyboard shortcuts inside the PiP document.
*
* The central `useKeyboardShortcuts` hook listens on `window`, which is the
* main document's window. Keydown events from the PiP document never reach
* it. This hook attaches the same dispatch logic to the PiP document so that
* Ctrl+D (mic), Ctrl+E (cam), etc. work identically in both contexts.
*/
export const usePipKeyboardShortcuts = (
containerRef: RefObject<HTMLElement | null>
) => {
useEffect(() => {
const doc = containerRef.current?.ownerDocument
if (!doc || doc === document) return
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
})
let handler = keyboardShortcutsStore.shortcuts.get(shortcutKey)
if (!handler && shortcutKey === 'ctrl+shift+?') {
handler = keyboardShortcutsStore.shortcuts.get('ctrl+shift+/')
}
if (!handler) return
e.preventDefault()
handler()
}
doc.addEventListener('keydown', onKeyDown)
return () => doc.removeEventListener('keydown', onKeyDown)
}, [containerRef])
}
@@ -0,0 +1,41 @@
import { useEffect, useRef, type RefObject } from 'react'
type Options = {
/** Remap the captured trigger (e.g. when it unmounts on click). */
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
}
/**
* `useRestoreFocus`: captures and restores focus via the PiP
* document instead of the main one.
*/
export const usePipRestoreFocus = (
ref: RefObject<HTMLElement | null>,
isOpen: boolean,
{ resolveTrigger }: Options = {}
) => {
const prevOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const doc = ref.current?.ownerDocument
const wasOpen = prevOpenRef.current
prevOpenRef.current = isOpen
if (!doc) return
if (!wasOpen && isOpen) {
const activeEl = doc.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
return
}
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
triggerRef.current = null
if (trigger && doc.contains(trigger)) {
requestAnimationFrame(() => trigger.focus({ preventScroll: true }))
}
}
}, [ref, isOpen, resolveTrigger])
}
@@ -0,0 +1,29 @@
import { useCallback } from 'react'
import { useSnapshot } from 'valtio'
import { roomPiPStore } from '@/stores/roomPiP'
export const useRoomPiP = () => {
const { isOpen } = useSnapshot(roomPiPStore)
const isSupported =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
const open = useCallback(() => {
roomPiPStore.isOpen = true
}, [])
const close = useCallback(() => {
roomPiPStore.isOpen = false
}, [])
const toggle = useCallback(() => {
roomPiPStore.isOpen = !roomPiPStore.isOpen
}, [])
return {
isSupported,
isOpen,
open,
close,
toggle,
}
}
@@ -0,0 +1,19 @@
import { proxy } from 'valtio'
import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
type PipLayoutState = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
}
/**
* Separate layout store for the PiP window.
* Decouples PiP side panel state from the main view so opening Chat/Info/etc.
* in PiP does not affect the main window and vice versa.
*/
export const pipLayoutStore = proxy<PipLayoutState>({
activePanelId: null,
activeSubPanelId: null,
showReactionsToolbar: false,
})
@@ -0,0 +1,114 @@
export type PipTilePlacement = {
gridColumn: string
gridRow: number
}
export type PipGridLayout = {
cols: number
rows: number
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
subColumns: number
/** One entry per tile, in input order. */
placements: PipTilePlacement[]
}
/**
* Target tile aspect ratio used to score candidate grid shapes.
*
* Video sources are 16:9, but picking 16:9 as the target makes the
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
* The UI works better with square, face-friendly tiles. This target keeps
* wide windows from collapsing to 2 columns with short, stretched rows
* and pushes the scorer to add a column instead.
*/
const TARGET_TILE_ASPECT = 1
/**
* Smallest count from which we force at least two columns.
* For 1-3 participants it is acceptable to stack vertically in tall
* windows, but from 4 people onwards we keep >=2 columns to
* avoid endless vertical scrolling; the scorer handles the rest.
*/
const FORCE_TWO_COLS_COUNT = 4
const pickGridShape = (
count: number,
width: number,
height: number
): { cols: number; rows: number } => {
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
let best = {
cols: minCols,
rows: Math.ceil(count / minCols),
score: -Infinity,
}
for (let cols = minCols; cols <= count; cols++) {
const rows = Math.ceil(count / cols)
const tileW = width / cols
const tileH = height / rows
if (tileW <= 0 || tileH <= 0) continue
// Score: aspect close to target, few empty cells, large tile area,
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
// resolve in favour of a shorter, wider grid.
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
const emptyCells = cols * rows - count
const fillScore = -emptyCells * 0.1
const areaScore = Math.log(tileW * tileH) * 0.5
const rowsPenalty = -rows * 0.01
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
if (score > best.score) best = { cols, rows, score }
}
return { cols: best.cols, rows: best.rows }
}
/**
* Pure function. Given a tile count and stage dimensions, returns the CSS
* grid layout for the PiP stage:
*
* - picks a cols x rows shape close to 16:9 tiles,
* - stretches any partial last row so its tiles share the full row width
* (no empty cells, no small centered tile).
*
* Callers consume the result directly: `subColumns` feeds
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
* `gridColumn`/`gridRow` from `placements`.
*/
export const computePipGridLayout = (
count: number,
width: number,
height: number
): PipGridLayout => {
if (count <= 0) {
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
}
const { cols, rows } = pickGridShape(count, width, height)
const tilesInLastRow = count - cols * (rows - 1)
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
const lastRowSpan = hasPartialRow ? cols : 1
const placements: PipTilePlacement[] = []
for (let i = 0; i < count; i++) {
const row = Math.floor(i / cols)
const colIndex = i % cols
const isLastRow = row === rows - 1 && hasPartialRow
const span = isLastRow ? lastRowSpan : fullRowSpan
const colStart = colIndex * span + 1
placements.push({
gridColumn: `${colStart} / span ${span}`,
gridRow: row + 1,
})
}
return { cols, rows, subColumns, placements }
}
@@ -0,0 +1,62 @@
import { Emoji } from '@/features/reactions/types'
export const EMOJI_SLOT_WIDTH = 40
export const ARROW_SLOT_WIDTH = 32
export const PILL_HORIZONTAL_PADDING = 12
export const WRAPPER_HORIZONTAL_PADDING = 16
const EMOJIS = Object.values(Emoji)
export type ReactionsPage = {
visibleEmojis: Emoji[]
hasOverflow: boolean
canGoLeft: boolean
canGoRight: boolean
visibleCount: number
}
/**
* Compute how many emojis fit in `availableWidth` and slice the visible page.
* Arrow slots are reserved only when the list overflows.
*/
export const computeReactionsPage = (
availableWidth: number,
pageStart: number
): ReactionsPage => {
const usableWidth =
availableWidth - WRAPPER_HORIZONTAL_PADDING - PILL_HORIZONTAL_PADDING
const maxWithoutArrows = Math.max(
1,
Math.floor(usableWidth / EMOJI_SLOT_WIDTH)
)
if (EMOJIS.length <= maxWithoutArrows) {
return {
visibleEmojis: EMOJIS,
hasOverflow: false,
canGoLeft: false,
canGoRight: false,
visibleCount: EMOJIS.length,
}
}
const visibleCount = Math.max(
1,
Math.floor((usableWidth - ARROW_SLOT_WIDTH * 2) / EMOJI_SLOT_WIDTH)
)
const clampedStart = Math.min(
Math.max(0, pageStart),
Math.max(0, EMOJIS.length - visibleCount)
)
return {
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
hasOverflow: true,
canGoLeft: clampedStart > 0,
canGoRight: clampedStart + visibleCount < EMOJIS.length,
visibleCount,
}
}
export const getMaxPageStart = (visibleCount: number): number =>
Math.max(0, EMOJIS.length - visibleCount)
@@ -0,0 +1,48 @@
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
/**
* Helpers used by the PiP layouts to classify/pick tracks.
* Kept free of React so they are trivially testable and cheap to call.
*/
export const pickScreenShareTrack = (
tracks: TrackReferenceOrPlaceholder[]
): TrackReferenceOrPlaceholder | undefined =>
tracks
.filter((track) => isTrackReference(track))
.find((track) => track.publication.source === Track.Source.ScreenShare)
export const pickLocalCameraTrack = (
tracks: TrackReferenceOrPlaceholder[]
): TrackReferenceOrPlaceholder | undefined =>
tracks.find(
(track) =>
track.source === Track.Source.Camera && track.participant?.isLocal
)
export const pickRemoteCameraTrack = (
tracks: TrackReferenceOrPlaceholder[]
): TrackReferenceOrPlaceholder | undefined =>
tracks.find(
(track) =>
track.source === Track.Source.Camera && !track.participant?.isLocal
)
export const isCameraTrack = (track: TrackReferenceOrPlaceholder): boolean =>
track.source === Track.Source.Camera
/**
* Produces a stable React key for a track so resizes/reshuffles of the grid
* do not remount the underlying <video> element.
*/
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
const identity = track.participant?.identity ?? 'unknown'
if (isTrackReference(track)) {
return `${identity}::${track.source}::${track.publication.trackSid}`
}
return `${identity}::${track.source}::placeholder`
}
@@ -2,19 +2,17 @@ import { createPortal } from 'react-dom'
import { useState, useEffect, useMemo } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
import { reactionsStore } from '@/stores/reactions'
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
import { Reaction } from '../types'
import {
ANIMATION_DISTANCE,
ANIMATION_DURATION,
FADE_OUT_THRESHOLD,
INITIAL_POSITION,
REACTION_SPAWN_WIDTH_RATIO,
} from '../constants'
interface FloatingReactionProps {
emoji: string
@@ -79,7 +77,7 @@ export function FloatingReaction({
>
<img
src={`/assets/reactions/${emoji}.png`}
alt={''}
alt=""
className={css({
height: '50px',
})}
@@ -116,14 +114,7 @@ export function FloatingReaction({
)
}
export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal(
@@ -138,51 +129,27 @@ export function ReactionPortal({
})}
>
<FloatingReaction
emoji={emoji}
emoji={reaction.emoji}
speed={speed}
scale={scale}
name={participant?.isLocal ? t('you') : participant.name}
isLocal={participant?.isLocal}
name={reaction.participantName}
isLocal={reaction.isLocal}
/>
</div>,
document.body
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
export const ReactionPortals = () => {
const { reactions } = useSnapshot(reactionsStore)
const latestReaction = reactions.at(-1)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
useAnnounceReaction(latestReaction)
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
<ReactionPortal key={instance.id} reaction={instance} />
))}
</>
)
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar()
useRegisterKeyboardShortcut({
id: 'reaction',
handler: toggle,
})
return (
<ToggleButton
id="reactions-toggle"
data-attr="reactions-toggle"
square
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
>
<RiEmotionLine />
</ToggleButton>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { Emoji } from '../../types'
import { useReactions } from '../../hooks/useReactions'
import { Button } from '@/primitives'
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { sendReaction } = useReactions()
return (
<Button
onPress={() => sendReaction(emoji)}
aria-label={t(`emojis.${emoji}`)}
variant="primaryTextDark"
size="sm"
round
data-attr={`send-reaction-${emoji}`}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
width: '28px',
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
)
}
@@ -0,0 +1,153 @@
import { FocusScope, useFocusManager } from '@react-aria/focus'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { ReactionButton } from './ReactionButton'
import { Emoji } from '../../types'
import { styled } from '@/styled-system/jsx'
import { layoutStore } from '@/stores/layout'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { useIsMobile } from '@/utils/useIsMobile'
import { useEffect, useRef, useState } from 'react'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { useTranslation } from 'react-i18next'
const Container = styled('div', {
base: {
display: 'flex',
justifyContent: 'center',
position: 'absolute',
bottom: 'var(--sizes-room-control-bar)',
left: 0,
right: 0,
pointerEvents: 'none',
},
})
const StyledStrip = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
desktopOffset: {
true: {
// Ideally this value should be calculated dynamically in JavaScript to keep
// the reaction toolbar perfectly centered relative to the reaction toggle.
// However, for simplicity and to follow a pragmatic 80/20 approach,
// this value is currently hardcoded in CSS.
marginRight: '30px',
},
},
},
})
const Strip = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isOpen) {
// defer one frame so the browser paints opacity:0 first
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
return (
<StyledStrip
ref={ref}
aria-hidden={!isOpen}
isVisible={isVisible}
desktopOffset={!isMobile}
>
{children}
</StyledStrip>
)
}
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById('reactions-toggle')?.focus()
layoutStore.showReactionsToolbar = false
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable('control-bar')?.focus()
}
break
}
}
return (
<div
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
export const ReactionsToolbar = () => {
const { isOpen } = useReactionsToolbar()
const shouldMount = useDelayUnmount(isOpen, 300)
if (!shouldMount) return null
return (
<Container>
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<KeyboardNavigation>
<Strip>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</Strip>
</KeyboardNavigation>
</FocusScope>
</Container>
)
}
@@ -0,0 +1,5 @@
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
@@ -0,0 +1,26 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { getEmojiLabel } from '../utils'
import { Reaction } from '../types'
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
const announce = useScreenReaderAnnounce()
useEffect(() => {
if (!announceReactions || !latestReaction) return
if (latestReaction.id === lastAnnouncedId) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participantName
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
}
@@ -0,0 +1,56 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { reactionsStore } from '@/stores/reactions'
import { NotificationType } from '@/features/notifications/NotificationType'
import { useNotifyParticipants } from '@/features/notifications'
import useRateLimiter from '@/hooks/useRateLimiter'
import { Participant } from 'livekit-client'
import { Emoji } from '../types'
import { ANIMATION_DURATION } from '../constants'
export const useReactions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { notifyParticipants } = useNotifyParticipants()
const appendReaction = useCallback(
(emoji: Emoji, participant?: Participant) => {
const newReaction = {
id: `${emoji}-${Date.now()}-${Math.random()}`,
emoji,
participantName: participant
? participant.name || participant.identity
: t('you'),
isLocal: !participant,
}
reactionsStore.reactions.push(newReaction)
setTimeout(() => {
const index = reactionsStore.reactions.findIndex(
(r) => r.id === newReaction.id
)
if (index !== -1) reactionsStore.reactions.splice(index, 1)
}, ANIMATION_DURATION)
},
[t]
)
const sendReaction = async (emoji: Emoji) => {
appendReaction(emoji)
await notifyParticipants({
type: NotificationType.ReactionReceived,
additionalData: { data: { emoji } },
})
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
return {
sendReaction: debouncedSendReaction,
appendReaction,
}
}
@@ -0,0 +1,13 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export const useReactionsToolbar = () => {
const layoutSnap = useSnapshot(layoutStore)
return {
isOpen: layoutSnap.showReactionsToolbar,
toggle: () => {
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
},
}
}
@@ -0,0 +1,17 @@
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: string
emoji: Emoji
participantName: string
isLocal: boolean
}
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next'
import { Emoji } from './types'
export const getEmojiLabel = (
emoji: string,
emoji: string | Emoji,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
@@ -32,6 +32,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
aria-expanded={isAdminOpen}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -1,4 +1,3 @@
import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
@@ -6,7 +5,7 @@ import { Button, Div } from '@/primitives'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { type SidePanelStore, useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
@@ -14,6 +13,7 @@ import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
type StyledSidePanelProps = {
title: string
@@ -25,6 +25,7 @@ type StyledSidePanelProps = {
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
isReactionToolbarOpen?: boolean
}
const StyledSidePanel = ({
@@ -33,6 +34,7 @@ const StyledSidePanel = ({
children,
onClose,
isClosed,
isReactionToolbarOpen,
closeButtonTooltip,
isSubmenu = false,
onBack,
@@ -51,17 +53,23 @@ const StyledSidePanel = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
margin: 'var(--sizes-room-side-panel-margin)',
marginLeft: 0,
marginBottom: 0,
padding: 0,
gap: 0,
right: 0,
top: 0,
bottom: '80px',
width: '360px',
width: 'var(--sizes-room-side-panel)',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
})}
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
transform: isClosed
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
: 'none',
bottom: isReactionToolbarOpen
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
: 'var(--sizes-room-control-bar)',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
@@ -135,7 +143,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
export const SidePanel = ({ store }: { store?: SidePanelStore }) => {
const {
activePanelId,
isParticipantsOpen,
@@ -147,25 +155,27 @@ export const SidePanel = () => {
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
} = useSidePanel()
closePanel,
goBack,
} = useSidePanel(store)
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<StyledSidePanel
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
onBack={goBack}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
@@ -37,6 +37,7 @@ export const ChatToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
aria-expanded={isChatOpen}
onPress={(e) => {
toggleChat()
onPress?.(e)
@@ -27,6 +27,7 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
aria-expanded={isInfoOpen}
onPress={(e) => {
toggleInfo()
onPress?.(e)
@@ -2,11 +2,11 @@ import { RiImageCircleAiFill } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { type SidePanelStore, useSidePanel } from '../../../hooks/useSidePanel'
export const EffectsMenuItem = () => {
export const EffectsMenuItem = ({ store }: { store?: SidePanelStore }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { toggleEffects } = useSidePanel(store)
return (
<MenuItem
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from './PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -21,6 +22,7 @@ export const OptionsMenuItems = () => {
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
<PictureInPictureMenuItem />
<EffectsMenuItem />
</MenuSection>
<Separator />
@@ -0,0 +1,23 @@
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { RiPictureInPicture2Line } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
export const PictureInPictureMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isSupported, isOpen, toggle } = useRoomPiP()
// Hide the entry when the browser doesn't support Document PiP.
if (!isSupported) return null
return (
<MenuItem
onAction={toggle}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiPictureInPicture2Line size={20} />
{isOpen ? t('pictureInPicture.exit') : t('pictureInPicture.enter')}
</MenuItem>
)
}
@@ -48,6 +48,7 @@ export const ParticipantsToggle = ({
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
aria-expanded={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
@@ -76,6 +77,7 @@ export const ParticipantsToggle = ({
zIndex: 1,
userSelect: 'none',
})}
aria-hidden={true}
>
{numParticipants < 100 ? (
numParticipants || 1
@@ -1,161 +0,0 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { ToggleButton, Button } from '@/primitives'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import {
Popover as RACPopover,
Dialog,
DialogTrigger,
} from 'react-aria-components'
import { FocusScope } from '@react-aria/focus'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: number
emoji: string
participant: Participant
}
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const room = useRoomContext()
const [isOpen, setIsOpen] = useState(false)
useRegisterKeyboardShortcut({
id: 'reaction',
handler: () => setIsOpen((prev) => !prev),
})
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ReactionReceived,
data: {
emoji: emoji,
},
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, { reliable: true })
const newReaction = {
id: instanceIdRef.current++,
emoji,
participant: room.localParticipant,
}
setReactions((prev) => [...prev, newReaction])
// Remove this reaction after animation
setTimeout(() => {
setReactions((prev) =>
prev.filter((instance) => instance.id !== newReaction.id)
)
}, ANIMATION_DURATION)
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
return (
<>
<div className={css({ position: 'relative' })}>
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
isSelected={isOpen}
onChange={setIsOpen}
>
<RiEmotionLine />
</ToggleButton>
<RACPopover
placement="top"
offset={8}
isNonModal
shouldCloseOnInteractOutside={() => false}
className={css({
borderRadius: '8px',
padding: '0.35rem',
backgroundColor: 'primaryDark.50',
'&[data-entering]': {
animation: 'fade 200ms ease',
},
'&[data-exiting]': {
animation: 'fade 200ms ease-in reverse',
},
})}
>
<Dialog className={css({ outline: 'none' })}>
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
<FocusScope contain autoFocus restoreFocus>
<div
role="toolbar"
aria-orientation="horizontal"
aria-label={t('button')}
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
width: '28px',
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
))}
</div>
</FocusScope>
</Dialog>
</RACPopover>
</DialogTrigger>
</div>
<ReactionPortals reactions={reactions} />
</>
)
}
@@ -34,6 +34,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
aria-expanded={isToolsOpen}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -1,74 +1,77 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { PanelId, SubPanelId } from '../types/panel'
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
export { PanelId, SubPanelId } from '../types/panel'
export type SidePanelStore = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
export const useSidePanel = (store: SidePanelStore = layoutStore) => {
const layoutSnap = useSnapshot(store)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isInfoOpen = activePanelId == PanelId.INFO
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId === PanelId.EFFECTS
const isChatOpen = activePanelId === PanelId.CHAT
const isToolsOpen = activePanelId === PanelId.TOOLS
const isAdminOpen = activePanelId === PanelId.ADMIN
const isInfoOpen = activePanelId === PanelId.INFO
const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
store.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
}
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
store.activeSubPanelId = SubPanelId.TRANSCRIPT
store.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
store.activeSubPanelId = SubPanelId.SCREEN_RECORDING
store.activePanelId = PanelId.TOOLS
}
const closePanel = () => {
store.activePanelId = null
store.activeSubPanelId = null
}
const goBack = () => {
store.activeSubPanelId = null
}
return {
@@ -82,6 +85,8 @@ export const useSidePanel = () => {
toggleInfo,
openTranscript,
openScreenRecording,
closePanel,
goBack,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -4,6 +4,8 @@ import * as React from 'react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { useIsMobile } from '@/utils/useIsMobile'
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
import { css } from '@/styled-system/css'
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
@@ -16,10 +18,32 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
export function ControlBar({ onDeviceError }: ControlBarProps) {
const isMobile = useIsMobile()
if (isMobile) {
return <MobileControlBar onDeviceError={onDeviceError} />
}
return <DesktopControlBar onDeviceError={onDeviceError} />
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
})}
>
<ReactionsToolbar />
<div
id="control-bar"
className={css({
zIndex: 100,
})}
>
{isMobile ? (
<MobileControlBar onDeviceError={onDeviceError} />
) : (
<DesktopControlBar onDeviceError={onDeviceError} />
)}
</div>
</div>
)
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
@@ -3,7 +3,6 @@ import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
@@ -15,6 +14,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
export function DesktopControlBar({
onDeviceError,
@@ -48,11 +49,7 @@ export function DesktopControlBar({
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
@@ -65,15 +62,7 @@ export function DesktopControlBar({
marginLeft: '0.5rem',
})}
/>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<ControlBarRegion>
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
@@ -97,7 +86,7 @@ export function DesktopControlBar({
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
</ControlBarRegion>
<MoreOptions parentElement={desktopControlBarEl} />
</div>
)
@@ -25,6 +25,7 @@ import { useConfig } from '@/api/useConfig'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
export function MobileControlBar({
onDeviceError,
@@ -43,21 +44,11 @@ export function MobileControlBar({
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
justifyContent: 'center',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '330px',
})}
>
<ControlBarRegion mobile>
<LeaveButton />
<AudioDevicesControl
onDeviceError={(error) =>
@@ -82,7 +73,7 @@ export function MobileControlBar({
>
<RiMore2Line />
</Button>
</div>
</ControlBarRegion>
</div>
<ResponsiveMenu
isOpened={isMenuOpened}
@@ -73,7 +73,7 @@ export const MoreOptions = ({
}) => {
const { width: parentWidth } = useSize(parentElement)
return (
<div
<nav
className={css({
display: 'flex',
justifyContent: 'flex-end',
@@ -88,6 +88,6 @@ export const MoreOptions = ({
) : (
<LateralMenu />
)}
</div>
</nav>
)
}
@@ -20,13 +20,9 @@ import {
import { useTranslation } from 'react-i18next'
import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
@@ -37,35 +33,15 @@ import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { RoomPiP } from '@/features/pip/components/RoomPiP'
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const LayoutWrapper = styled(
'div',
cva({
base: {
position: 'relative',
display: 'flex',
width: '100%',
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
},
variants: {
areSubtitlesOpen: {
true: {
height: 'calc(100% - 12rem)',
},
false: {
height: '100%',
},
},
},
})
)
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { GridLayout } from '@/features/layout/components/GridLayout'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
/**
* @public
@@ -253,8 +229,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
])
/* eslint-enable react-hooks/exhaustive-deps */
const { isSidePanelOpen } = useSidePanel()
const { areSubtitlesOpen } = useSubtitles()
const { isOpen: isPiPOpen } = useRoomPiP()
const shouldRenderMainLayout = !isPiPOpen
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
@@ -276,25 +252,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
<RoomContentArea>
{shouldRenderMainLayout && (
<>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
@@ -322,11 +282,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
</>
)}
</RoomContentArea>
<ControlBar
onDeviceError={(e) => {
console.error(e)
@@ -340,12 +298,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
}}
/>
<SidePanel />
<RoomPiP />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingProvider />
<SettingsDialogProvider />
<ReactionPortals />
</div>
)
}
@@ -0,0 +1,17 @@
/**
* Panel identifiers for the side panel (Info, Chat, Participants, etc.).
* Extracted to avoid circular dependencies between layout store and useSidePanel.
*/
export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react'
export const useDelayUnmount = (isMounted: boolean, delayTime: number) => {
const [shouldRender, setShouldRender] = useState(false)
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
if (isMounted && !shouldRender) {
setShouldRender(true)
} else if (!isMounted && shouldRender) {
timeoutId = setTimeout(() => setShouldRender(false), delayTime)
}
return () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
}, [isMounted, delayTime, shouldRender])
return shouldRender
}
+19 -1
View File
@@ -166,6 +166,7 @@
"countdownAnnouncement": "Das Meeting endet in {{duration}}."
},
"controls": {
"region": "Anrufsteuerung",
"microphone": "Mikrofon",
"camera": "Kamera",
"chat": {
@@ -215,7 +216,7 @@
"moreOptions": "Weitere Optionen",
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"toolbar": "Reaktion senden",
"announce": "{{name}} : {{emoji}}",
"you": "du",
"emojis": {
@@ -240,6 +241,23 @@
"username": "Deinen Namen aktualisieren",
"effects": "Effekte anwenden",
"switchCamera": "Kamera wechseln",
"pictureInPicture": {
"enter": "Bild-im-Bild",
"exit": "Bild-im-Bild schließen",
"opened": "Bild-im-Bild-Modus aktiviert",
"closed": "Bild-im-Bild-Modus deaktiviert",
"windowLabel": "Bild-im-Bild Besprechung",
"stage": "Teilnehmer",
"controlBar": "Besprechungssteuerung",
"previousReactions": "Vorherige Reaktionen",
"nextReactions": "Nächste Reaktionen",
"notificationsLabel": "Benachrichtigungen",
"dismissNotification": "Benachrichtigung schließen",
"connection": {
"reconnecting": "Verbindung wird wiederhergestellt…",
"disconnected": "Verbindung getrennt"
}
},
"fullscreen": {
"enter": "Vollbild",
"exit": "Vollbildmodus verlassen"
+19 -1
View File
@@ -166,6 +166,7 @@
"countdownAnnouncement": "Call ends in {{duration}}."
},
"controls": {
"region": "Call controls",
"microphone": "Microphone",
"camera": "Camera",
"chat": {
@@ -215,7 +216,7 @@
"moreOptions": "More options",
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"toolbar": "Send reaction",
"announce": "{{name}} : {{emoji}}",
"you": "you",
"emojis": {
@@ -240,6 +241,23 @@
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"switchCamera": "Switch camera",
"pictureInPicture": {
"enter": "Picture-in-picture",
"exit": "Close picture-in-picture",
"opened": "Picture-in-picture mode enabled",
"closed": "Picture-in-picture mode disabled",
"windowLabel": "Picture-in-picture meeting",
"stage": "Participants",
"controlBar": "Meeting controls",
"previousReactions": "Previous reactions",
"nextReactions": "Next reactions",
"notificationsLabel": "Notifications",
"dismissNotification": "Dismiss notification",
"connection": {
"reconnecting": "Reconnecting…",
"disconnected": "Disconnected"
}
},
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
+19 -1
View File
@@ -166,6 +166,7 @@
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
},
"controls": {
"region": "Commandes pour les appels",
"microphone": "Microphone",
"camera": "Camera",
"chat": {
@@ -215,7 +216,7 @@
"moreOptions": "Plus d'options",
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"toolbar": "Envoyer une réaction",
"announce": "{{name}} : {{emoji}}",
"you": "vous",
"emojis": {
@@ -240,6 +241,23 @@
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"switchCamera": "Changer de caméra",
"pictureInPicture": {
"enter": "Image dans l'image",
"exit": "Fermer l'image dans l'image",
"opened": "Mode image dans l'image activé",
"closed": "Mode image dans l'image désactivé",
"windowLabel": "Réunion en image dans l'image",
"stage": "Participants",
"controlBar": "Commandes de la réunion",
"previousReactions": "Réactions précédentes",
"nextReactions": "Réactions suivantes",
"notificationsLabel": "Notifications",
"dismissNotification": "Fermer la notification",
"connection": {
"reconnecting": "Reconnexion…",
"disconnected": "Déconnecté"
}
},
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
+19 -1
View File
@@ -166,6 +166,7 @@
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
},
"controls": {
"region": "Gespreksbesturing",
"microphone": "Microfoon",
"camera": "Camera",
"chat": {
@@ -215,7 +216,7 @@
"moreOptions": "Meer opties",
"reactions": {
"button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}",
"toolbar": "Stuur reactie",
"announce": "{{name}} : {{emoji}}",
"you": "U",
"emojis": {
@@ -240,6 +241,23 @@
"username": "Verander uw naam",
"effects": "Pas effecten toe",
"switchCamera": "Selecteer camera",
"pictureInPicture": {
"enter": "Beeld-in-beeld",
"exit": "Beeld-in-beeld sluiten",
"opened": "Beeld-in-beeld-modus ingeschakeld",
"closed": "Beeld-in-beeld-modus uitgeschakeld",
"windowLabel": "Beeld-in-beeld vergadering",
"stage": "Deelnemers",
"controlBar": "Vergaderbesturing",
"previousReactions": "Vorige reacties",
"nextReactions": "Volgende reacties",
"notificationsLabel": "Meldingen",
"dismissNotification": "Melding sluiten",
"connection": {
"reconnecting": "Opnieuw verbinden…",
"disconnected": "Verbinding verbroken"
}
},
"fullscreen": {
"enter": "Volledig scherm",
"exit": "Stop volledig scherm stand"
+28 -2
View File
@@ -1,10 +1,16 @@
import { ReactNode } from 'react'
import { ReactNode, useMemo } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
import { Box } from './Box'
import {
useOverlayBoundaryElement,
useOverlayPortalContainer,
} from './useOverlayPortalContainer'
/**
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
*
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
*/
export const Menu = ({
children,
@@ -16,10 +22,30 @@ export const Menu = ({
placement?: 'bottom' | 'top' | 'left' | 'right'
}) => {
const [trigger, menu] = children
// const boundaryElement = useOverlayBoundaryElement()
// const portalContainer = useOverlayPortalContainer()
// // Detect if we're in PiP: portal container is in a different document than the main window
// const isInPiP = useMemo(
// () =>
// portalContainer &&
// portalContainer.ownerDocument &&
// portalContainer.ownerDocument !== document,
// [portalContainer]
// )
// Default placement: 'bottom' in PiP, 'top' elsewhere (to match existing behavior)
// const defaultPlacement = isInPiP ? 'bottom' : 'top'
// const shouldFlip = isInPiP ? false : undefined
return (
<MenuTrigger>
{trigger}
<StyledPopover placement={placement}>
<StyledPopover
placement={placement}
// shouldFlip={shouldFlip}
// boundaryElement={boundaryElement}
>
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
export const StyledPopover = styled(RACPopover, {
base: {
@@ -65,6 +66,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
*
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
* This is here when needing to show unrestricted content in a box.
*
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
*/
export const Popover = ({
children,
@@ -82,10 +85,11 @@ export const Popover = ({
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const boundaryElement = useOverlayBoundaryElement()
return (
<DialogTrigger>
{trigger}
<StyledPopover>
<StyledPopover boundaryElement={boundaryElement}>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
+32 -8
View File
@@ -6,12 +6,17 @@ import {
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { useOverlayPortalContainer } from './useOverlayPortalContainer'
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
export type TooltipWrapperProps = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
const INSTANT_TOOLTIP_DELAY_MS = 150
const DELAYED_TOOLTIP_DELAY_MS = 1000
/**
* Wrap a component you want to apply a tooltip on (for example a Button)
*
@@ -24,11 +29,25 @@ export const TooltipWrapper = ({
}: {
children: ReactNode
} & TooltipWrapperProps) => {
const portalContainer = useOverlayPortalContainer()
const isExternalDocument =
portalContainer && portalContainer.ownerDocument !== document
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
isExternalDocument ? (
<VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
) : (
<TooltipTrigger
delay={
tooltipType === 'instant'
? INSTANT_TOOLTIP_DELAY_MS
: DELAYED_TOOLTIP_DELAY_MS
}
>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
)
) : (
children
)
@@ -39,6 +58,8 @@ export const TooltipWrapper = ({
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const DEFAULT_TOOLTIP_GAP_PX = 8
const StyledTooltip = styled(RACTooltip, {
base: {
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
@@ -53,11 +74,11 @@ const StyledTooltip = styled(RACTooltip, {
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: '8px',
marginBottom: `${DEFAULT_TOOLTIP_GAP_PX}px`,
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: '8px',
marginTop: `${DEFAULT_TOOLTIP_GAP_PX}px`,
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
@@ -107,10 +128,13 @@ const TooltipArrow = () => {
const Tooltip = ({
children,
arrowBoundaryOffset,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
}: {
children: ReactNode
} & Partial<Omit<TooltipProps, 'children'>>) => {
return (
<StyledTooltip {...props}>
<StyledTooltip arrowBoundaryOffset={arrowBoundaryOffset ?? 0} {...props}>
<TooltipArrow />
{children}
</StyledTooltip>
@@ -2,11 +2,14 @@ import {
type ReactElement,
cloneElement,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
export type VisualOnlyTooltipProps = {
children: ReactElement
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const [computedStyle, setComputedStyle] = useState<{
left: number
arrowLeft: number
} | null>(null)
const isBottom = tooltipPosition === 'bottom'
const [effectiveBottom, setEffectiveBottom] = useState(
tooltipPosition === 'bottom'
)
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const preferBottom = tooltipPosition === 'bottom'
setEffectiveBottom(preferBottom)
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
})
setIsVisible(true)
@@ -53,9 +66,47 @@ export const VisualOnlyTooltip = ({
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
setComputedStyle(null)
}
const tooltipData = isVisible && position ? { isVisible, position } : null
useLayoutEffect(() => {
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
return
const tooltipRect = tooltipRef.current.getBoundingClientRect()
const triggerRect = wrapperRef.current.getBoundingClientRect()
const doc = tooltipRef.current.ownerDocument
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
const padding = 8
// Vertical flip: if tooltip overflows the top, switch to bottom
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
const flippedTop = triggerRect.bottom + 8
setEffectiveBottom(true)
setPosition({ top: flippedTop, left: position.left })
return
}
// Horizontal clamping (both edges)
const desiredLeft = position.left - tooltipRect.width / 2
const minLeft = padding
const maxLeft = viewportWidth - padding - tooltipRect.width
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
setComputedStyle(null)
return
}
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
setComputedStyle({
left: clampedLeft,
arrowLeft: position.left - clampedLeft,
})
}, [isVisible, position, effectiveBottom])
const portalContainer = useMemo(() => {
if (getContainer) return getContainer()
return wrapperRef.current?.ownerDocument?.body ?? document.body
}, [getContainer])
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
@@ -73,11 +124,14 @@ export const VisualOnlyTooltip = ({
>
{wrappedChild}
</div>
{tooltipData &&
{isVisible &&
position &&
portalContainer &&
createPortal(
<div
aria-hidden="true"
role="presentation"
ref={tooltipRef}
className={css({
position: 'fixed',
padding: '2px 8px',
@@ -87,15 +141,15 @@ export const VisualOnlyTooltip = ({
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
zIndex: 100001,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
left: '50%',
left: 'var(--tooltip-arrow-left, 50%)',
transform: 'translateX(-50%)',
border: '4px solid transparent',
...(isBottom
...(effectiveBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
@@ -107,16 +161,27 @@ export const VisualOnlyTooltip = ({
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
top: `${position.top}px`,
left: computedStyle
? `${computedStyle.left}px`
: `${position.left}px`,
transform: computedStyle
? effectiveBottom
? 'translateY(0)'
: 'translateY(-100%)'
: effectiveBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
...(computedStyle
? {
'--tooltip-arrow-left': `${computedStyle.arrowLeft}px`,
}
: null),
}}
>
{tooltip}
</div>,
document.body
portalContainer
)}
</>
)
@@ -44,6 +44,13 @@ export const buttonRecipe = cva({
paddingY: 'var(--square-padding)',
},
},
round: {
true: {
borderRadius: '50%',
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
primary: {
backgroundColor: 'primary.800',
@@ -0,0 +1,21 @@
import { useMemo } from 'react'
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
/**
* Hook to retrieve the portal container for overlays (menus, tooltips, popovers).
* Returns the container from UNSAFE_PortalProvider context (pip-root in PiP, undefined in main window).
*/
export const useOverlayPortalContainer = () => {
const { getContainer } = useUNSAFE_PortalContext()
return useMemo(() => getContainer?.() ?? undefined, [getContainer])
}
/**
* Hook to retrieve the boundary element for overlay positioning.
* Returns the portal container in PiP (for PiP-relative positioning), undefined in main window.
*/
export const useOverlayBoundaryElement = () => {
const portalContainer = useOverlayPortalContainer()
return portalContainer
}
+3 -4
View File
@@ -1,8 +1,5 @@
import { proxy } from 'valtio'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
type State = {
showHeader: boolean
@@ -10,6 +7,7 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
}
export const layoutStore = proxy<State>({
@@ -18,4 +16,5 @@ export const layoutStore = proxy<State>({
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
showReactionsToolbar: false,
})
+10
View File
@@ -0,0 +1,10 @@
import { proxy } from 'valtio'
import { Reaction } from '@/features/reactions/types'
type State = {
reactions: Reaction[]
}
export const reactionsStore = proxy<State>({
reactions: [],
})
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const roomPiPStore = proxy<State>({
isOpen: false,
})
+20
View File
@@ -0,0 +1,20 @@
const FOCUSABLE_SELECTOR =
'input, select, textarea, button, object, a, area[href], [tabindex]'
/**
* Find the first focusable descendant of `root`.
* Works across documents (useful for the PiP window, which has its own
* `document`). Pass the result of `ownerDocument.getElementById(...)` to
* target an element in a specific document.
*/
export const findFirstFocusable = (
root: HTMLElement | null | undefined
): HTMLElement | null =>
root?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null
/**
* Wrapper for the main document. Use `findFirstFocusable` when
* working with a non-main document (e.g. the PiP window).
*/
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
findFirstFocusable(document.getElementById(id))
+38
View File
@@ -0,0 +1,38 @@
.podSecurityContext: &podSecurityContext
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: "OnRootMismatch"
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
.securityContext: &securityContext
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
frontend:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
backend:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
summary:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
celeryTranscribe:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
celerySummarize:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
agents:
podSecurityContext: *podSecurityContext
securityContext: *securityContext
@@ -28,7 +28,6 @@ livekit:
urls:
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
loadBalancer:
type: nginx
annotations:
@@ -6,13 +6,25 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
WHISPERX_API_KEY: your-secret-value
WHISPERX_BASE_URL: https://configure-your-url.com
WHISPERX_API_KEY:
secretKeyRef:
name: secret-dev
key: WHISPERX_API_KEY
WHISPERX_BASE_URL:
secretKeyRef:
name: secret-dev
key: WHISPERX_BASE_URL
WHISPERX_ASR_MODEL: large-v2
WHISPERX_DEFAULT_LANGUAGE: fr
LLM_BASE_URL: https://configure-your-url.com
LLM_API_KEY: your-secret-value
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
LLM_BASE_URL:
secretKeyRef:
name: secret-dev
key: LLM_BASE_URL
LLM_API_KEY:
secretKeyRef:
name: secret-dev
key: LLM_API_KEY
LLM_MODEL: Qwen/Qwen3-Coder-30B-A3B-Instruct
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
@@ -162,7 +174,7 @@ ingressWebhook:
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
@@ -28,7 +28,6 @@ livekit:
urls:
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
loadBalancer:
type: nginx
annotations:

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