Compare commits

..

6 Commits

Author SHA1 Message Date
Thomas Ramé a357fb04d5 (ci) try to pass more checks with rabbitai + remove no longer used advanced encryption client in keycloak 2026-05-18 11:23:49 +02:00
Thomas Ramé e2a3b286ca (ci) try to pass more checks with sonarqube 2026-05-18 10:59:25 +02:00
Thomas Ramé 668f093063 (backend) align tests 2026-05-13 19:43:20 +02:00
Thomas Ramé f3476950c3 📝 (changelog) document encrypted-meeting feature 2026-05-13 19:34:13 +02:00
Thomas Ramé 1aa8bc822d (encryption) remove advanced mode and keep a distinct encryption hash mode 2026-05-13 17:59:43 +02:00
Thomas Ramé 3698ac09eb (all) implement the simplified and advanced modes of encryption 2026-05-06 09:49:21 +02:00
133 changed files with 4244 additions and 1282 deletions
+2
View File
@@ -223,6 +223,8 @@ 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
+10 -4
View File
@@ -8,14 +8,20 @@ and this project adheres to
## [Unreleased]
## [1.13.0] - 2026-03-31
### Added
- ✨(encryption) opt-in end-to-end encryption for meetings, with the
passphrase carried in the URL hash and an explicit "Create an
encrypted meeting" entry in the home create menu (gated by a
per-user Security preference) #1337
- ✨(encryption) authenticated participant emails surfaced in the
participants list of encrypted rooms; anonymous participants get a
red badge with tooltip in the list, waiting room, and the floating
join-request notification
### 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
+5 -5
View File
@@ -73,8 +73,7 @@ create-env-files: \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary \
env.d/development/kube-secret
env.d/development/summary
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -266,9 +265,6 @@ 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:
@@ -363,6 +359,10 @@ 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
+44 -21
View File
@@ -2,7 +2,6 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -12,11 +11,11 @@
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</a>
</p>
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
<p align="center">
@@ -28,25 +27,54 @@
## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- End-to-end encryption with passphrase-in-link key distribution
- Meeting recording
- Meeting transcription & Summary (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- Customizable frontend style
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
#### How it works
- Each encrypted meeting carries a 48-character hex passphrase appended to the URL hash (`#…`) — 192 bits of entropy. The server never sees it; sharing the meeting link shares the key.
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag. The DB column (`Room.encryption_mode`) is only used as a sanity reference: if the URL hash and the server's claim disagree, the joining client surfaces an explicit mismatch screen instead of silently joining in clear or in a private encrypted bubble.
> **Threat model.** "Server doesn't see plaintext" — not "users are safe from a malicious server." A compromised server could still serve modified JavaScript to a participant, who would then leak their passphrase. The E2EE story protects the media path against a passive or compromised SFU, not against a fully compromised origin.
#### Encryption mode is set at creation, immutable after
`Room.encryption_mode` is a string enum (`none` / `basic`) chosen when the room is created and never mutated afterwards — changing it would change the link's semantics, since the passphrase lives in the URL hash. There is no mid-call "pause encryption" mechanism: while a meeting is encrypted, **recording and transcription endpoints reject requests with a 400** (`Recording is unavailable in encrypted rooms.` / `Subtitles are unavailable in encrypted rooms.`), the More-tools panel renders those items disabled with an explanatory banner, and the SIP gateway never gets a dispatch rule for encrypted rooms (so dial-in numbers and PINs aren't allocated). Encrypted rooms are also force-locked to `restricted` access level (lobby admission), since basic E2EE only meaningfully protects against passive eavesdropping if the host vets joiners before they receive the in-URL key.
#### Opt-in by user
End-to-end encryption is a per-user preference. In **Settings**, under the **Security** section, signed-in users can flip the **End-to-end encryption** toggle — once enabled, a third "Create an encrypted meeting" entry appears in the home-page create-menu (with its own confirmation modal that lists the disabled features and a "Treat this link like a password" connection-details dialog before the meeting starts). Joining is unaffected by the toggle: any participant clicking a meeting link that carries a valid hash joins encrypted, regardless of their own setting. Authenticated joiners of encrypted rooms cannot edit their displayed name — the server enforces the OIDC name on the JWT.
#### Configuration
```env
ENCRYPTION_ENABLED=true
```
Setting `ENCRYPTION_ENABLED=false` rejects encrypted-room creation at the API level. Existing encrypted rooms stay encrypted (the mode is immutable), but no new ones can be created.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
@@ -63,7 +91,6 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy)
- [Open source](#open-source)
## Get started
## Docs
@@ -82,15 +109,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access |
|---------------------------------------------------------------| --- | ------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| Url | Org | Access |
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosa.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
@@ -100,7 +127,6 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -109,7 +135,6 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -121,14 +146,13 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a>
## Credits
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
@@ -137,4 +161,3 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+2 -8
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-keycloak')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -95,13 +95,7 @@ docker_build(
)
clean_old_images('localhost:5001/meet-livekit')
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_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
+3 -3
View File
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": "true",
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": "true",
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": "true",
-4
View File
@@ -1,4 +0,0 @@
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.13.0"
version = "1.12.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
+3
View File
@@ -73,5 +73,8 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration["encryption"] = {
"enabled": settings.ENCRYPTION_ENABLED,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+85 -3
View File
@@ -30,9 +30,30 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
fields = [
"id",
"email",
"full_name",
"short_name",
"timezone",
"language",
"default_encryption_mode",
]
read_only_fields = ["id", "email", "full_name", "short_name"]
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled.
Keeps the user preference DB in sync with the deployment's posture:
if an operator flips ENCRYPTION_ENABLED off, no client should be able
to keep persisting `basic` as their default behind their back.
"""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
@@ -74,6 +95,7 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied(
"Only owners of a room can assign other users as owners."
)
return data
def validate_resource(self, resource):
@@ -128,9 +150,60 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
fields = [
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"encryption_mode",
]
read_only_fields = ["id", "slug", "pin_code"]
def validate_encryption_mode(self, value):
"""Encryption mode is part of the link's semantics (the passphrase
lives in the URL hash for `basic` rooms) so it cannot be changed once
the room exists."""
instance = self.instance
if instance and instance.encryption_mode != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation."
)
return value
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — the lobby is the only way
to enforce per-participant admission, and basic encryption relies on
the host vetting each joiner before they receive the in-URL key."""
instance = self.instance
if (
instance
and instance.encryption_mode != models.EncryptionMode.NONE
and value != models.RoomAccessLevel.RESTRICTED
):
raise serializers.ValidationError(
"Encrypted rooms require restricted access level."
)
return value
def validate(self, attrs):
"""Force encrypted rooms to RESTRICTED at creation time.
Doing this here (rather than in validate_access_level) lets the
client omit `access_level` entirely when creating an encrypted room
— we silently override whatever the default would have been.
"""
encryption_mode = attrs.get(
"encryption_mode",
self.instance.encryption_mode
if self.instance
else models.EncryptionMode.NONE,
)
if encryption_mode != models.EncryptionMode.NONE and not self.instance:
attrs["access_level"] = models.RoomAccessLevel.RESTRICTED
return super().validate(attrs)
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -172,12 +245,21 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room:
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users cannot pick an
# arbitrary display name — it must come from the OIDC profile.
# We enforce this server-side so a tampered client cannot
# override what other participants see.
if instance.is_encrypted and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
)
else:
del output["pin_code"]
@@ -265,7 +347,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
username = serializers.CharField(required=True, allow_blank=True)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
+28 -3
View File
@@ -281,6 +281,18 @@ class RoomViewSet(
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get(
"encryption_mode", models.EncryptionMode.NONE
)
if (
encryption_mode != models.EncryptionMode.NONE
and not settings.ENCRYPTION_ENABLED
):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
room = serializer.save()
models.ResourceAccess.objects.create(
resource=room,
@@ -304,16 +316,21 @@ class RoomViewSet(
"""Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
{"detail": "Invalid request."},
status=drf_status.HTTP_400_BAD_REQUEST,
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options")
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Recording is unavailable in encrypted rooms."}
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
@@ -396,12 +413,14 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True)
room = self.get_object()
validated_data = serializer.validated_data
lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
**serializer.validated_data,
**validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
@@ -464,6 +483,7 @@ class RoomViewSet(
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
return drf_response.Response({"participants": participants})
@decorators.action(
@@ -566,6 +586,11 @@ class RoomViewSet(
room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Subtitles are unavailable in encrypted rooms."}
)
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
@@ -0,0 +1,46 @@
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
We store the mode as an enum (CharField with choices) rather than a boolean
so a future "advanced" mode (per-user vault keys, etc.) can be added without
a schema migration.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
migrations.AddField(
model_name="user",
name="default_encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="Encryption mode pre-selected when this user creates a new meeting.",
max_length=20,
verbose_name="Default encryption mode",
),
),
]
+87 -2
View File
@@ -98,6 +98,17 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode for a room.
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
per-user key flow — can be added without another schema migration.
"""
NONE = "none", _("No encryption")
BASIC = "basic", _("Passphrase-in-URL encryption")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -200,6 +211,15 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts."
),
)
default_encryption_mode = models.CharField(
_("Default encryption mode"),
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
help_text=_(
"Encryption mode pre-selected when this user creates a new meeting."
),
)
objects = auth_models.UserManager()
@@ -388,6 +408,15 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Set at creation, immutable after (the URL hash carries the passphrase,
# so changing the mode would break every previously-shared link).
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
configuration = models.JSONField(
blank=True,
default=dict,
@@ -413,13 +442,64 @@ class Room(Resource):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
"""Generate a unique n-digit pin code for new rooms.
Skip PIN allocation for encrypted rooms — the SIP gateway will
always reject calls to them (no way to derive the key), and the
PIN namespace is finite (10**length): no point burning slots that
can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
"""
self.clean()
if (
settings.ROOM_TELEPHONY_ENABLED
and not self.pk
and not self.pin_code
and self.encryption_mode == EncryptionMode.NONE
):
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -442,6 +522,11 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@property
def is_encrypted(self):
"""Convenience: any non-none encryption mode counts as encrypted."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
+10 -1
View File
@@ -202,7 +202,16 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
# Note: `encryption_mode` is stamped into the LK room's metadata at
# creation time via the access token's RoomConfiguration (see
# `utils.generate_token`), so we don't need to patch it here.
# Phone dial-in is incompatible with end-to-end encryption — a SIP
# caller has no way to derive the room key, and the SIP gateway will
# play "encryption_not_supported" and hang up on them anyway. Skip
# the dispatch rule for encrypted rooms so no metadata mentions a
# PIN that won't be reachable.
if settings.ROOM_TELEPHONY_ENABLED and not room.is_encrypted:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
+35 -5
View File
@@ -46,14 +46,18 @@ class LobbyParticipant:
username: str
color: str
id: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, str]:
def to_dict(self) -> Dict[str, object]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
"is_authenticated": self.is_authenticated,
}
@classmethod
@@ -68,6 +72,7 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
is_authenticated=bool(data.get("is_authenticated", False)),
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -99,7 +104,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
secure=not settings.DEBUG,
samesite="Lax",
)
@@ -140,6 +145,19 @@ class LobbyService:
5. If denied, do nothing.
"""
# In encrypted rooms, authenticated users cannot pick an arbitrary
# display name — server enforces the OIDC name so a tampered client
# can't impersonate someone else with their account. If both
# full_name and email are absent (degenerate OIDC payload), fall
# back to a server-controlled technical name rather than trusting
# whatever the client posted.
if room.is_encrypted and request.user.is_authenticated:
username = (
request.user.full_name
or request.user.email
or f"noname-{request.user.id}"
)
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
@@ -152,6 +170,7 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
is_authenticated=request.user.is_authenticated,
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -164,19 +183,24 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
participant = self.enter(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
@@ -185,6 +209,7 @@ class LobbyService:
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
encryption_mode=room.encryption_mode,
)
return participant, livekit_config
@@ -201,7 +226,11 @@ class LobbyService:
)
def enter(
self, room_id: UUID, participant_id: str, username: str
self,
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
) -> LobbyParticipant:
"""Add participant to waiting lobby.
@@ -216,6 +245,7 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
is_authenticated=is_authenticated,
)
try:
@@ -59,6 +59,7 @@ def test_request_entry_anonymous(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": False,
"livekit": None,
}
@@ -108,6 +109,7 @@ def test_request_entry_authenticated_user(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": True,
"livekit": None,
}
@@ -180,6 +182,7 @@ def test_request_entry_with_existing_participants(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"is_authenticated": False,
"livekit": None,
}
@@ -232,6 +235,7 @@ def test_request_entry_public_room(settings):
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -284,6 +288,7 @@ def test_request_entry_authenticated_user_public_room(settings):
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"is_authenticated": True,
"livekit": {"token": "test-token"},
}
@@ -338,6 +343,7 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "accepted",
"color": "#123456",
"is_authenticated": False,
"livekit": {"token": "test-token"},
}
@@ -601,12 +607,14 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"is_authenticated": False,
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
"is_authenticated": False,
},
]
@@ -33,6 +33,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -52,6 +53,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -70,6 +72,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -86,6 +89,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -102,6 +106,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -211,6 +216,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once()
@@ -257,6 +263,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -267,6 +274,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -308,6 +316,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -318,6 +327,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
sources=None,
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -343,6 +353,7 @@ def test_api_rooms_retrieve_authenticated():
"is_administrable": False,
"name": room.name,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
@@ -394,6 +405,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -404,6 +416,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
encryption_mode="none",
)
@@ -453,6 +466,7 @@ def test_api_rooms_retrieve_administrators(
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -466,6 +480,7 @@ def test_api_rooms_retrieve_administrators(
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
"default_encryption_mode": "none",
},
"resource": str(room.id),
"role": user_access.role,
@@ -487,6 +502,7 @@ def test_api_rooms_retrieve_administrators(
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
"encryption_mode": room.encryption_mode,
}
mock_token.assert_called_once_with(
@@ -497,4 +513,5 @@ def test_api_rooms_retrieve_administrators(
sources=None,
is_admin_or_owner=True,
participant_id=None,
encryption_mode="none",
)
@@ -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-padded-to-32-bytes!!"
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
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_padded_to_32bytes!",
"api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
+10 -1
View File
@@ -268,6 +268,7 @@ def test_request_entry_public_room(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -307,6 +308,7 @@ def test_request_entry_trusted_room(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -337,7 +339,12 @@ def test_request_entry_new_participant(
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username)
mock_enter.assert_called_once_with(
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -402,6 +409,7 @@ def test_request_entry_accepted_participant(
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
encryption_mode="none",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -777,6 +785,7 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"is_authenticated": False,
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
+1
View File
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
"default_encryption_mode": "none",
}
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
}
token = jwt.encode(
payload,
"invalid-private-key-padded-to-32b!",
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
+54 -7
View File
@@ -32,6 +32,7 @@ from livekit.api import ( # pylint: disable=E0611
UpdateRoomMetadataRequest,
VideoGrants,
)
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
logger = logging.getLogger(__name__)
@@ -66,6 +67,7 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -86,17 +88,26 @@ def generate_token(
str: The LiveKit JWT access token.
"""
if is_admin_or_owner:
# Local import: core.models loads core.utils mid-import (see models.py:28),
# so importing EncryptionMode at module top would deadlock the bootstrap.
from core.models import ( # noqa: PLC0415 pylint: disable=import-outside-toplevel
EncryptionMode,
)
if is_admin_or_owner or sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, no one can change their name/attributes after the
# admin accepted them — otherwise a participant authenticated under one
# identity could rewrite their JWT-presented name to spoof someone else
# mid-meeting. In plain rooms, free naming is fine.
can_update_own_metadata = encryption_mode == EncryptionMode.NONE
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
can_update_own_metadata=True,
can_update_own_metadata=can_update_own_metadata,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
@@ -112,6 +123,27 @@ def generate_token(
if color is None:
color = generate_color(identity)
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Emit the email only for authenticated participants of *encrypted*
# rooms. LK signaling broadcasts attributes to every peer in the room,
# so making this conditional on the encryption gate is what prevents
# an anonymous joiner of a public/trusted room from harvesting all
# authenticated users' emails. Frontend hiding (the
# `isLoggedIn`-gated render in ParticipantListItem) is only
# defense-in-depth — anyone with devtools can read attributes
# otherwise.
if (
not user.is_anonymous
and encryption_mode != EncryptionMode.NONE
and getattr(user, "email", None)
):
attributes["email"] = user.email
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -120,11 +152,24 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
.with_attributes(attributes)
)
# Encode the encryption mode into the room's metadata at LK-creation
# time (via the access token's room_config). LiveKit creates the room
# lazily when the first participant joins; the embedded config tells
# it to stamp `{"encryption_mode": "<mode>"}` into the metadata at
# that moment — no extra round-trip, no race window where a SIP
# caller could read empty metadata before the `room_started` webhook
# has time to push it.
if encryption_mode != EncryptionMode.NONE:
token = token.with_room_config(
RoomConfiguration(
name=room,
metadata=json.dumps({"encryption_mode": encryption_mode}),
)
)
return token.to_jwt()
@@ -136,6 +181,7 @@ def generate_livekit_config(
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -168,6 +214,7 @@ def generate_livekit_config(
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
encryption_mode=encryption_mode,
),
}
+8 -7
View File
@@ -808,6 +808,13 @@ class Base(Configuration):
environ_prefix=None,
)
# End-to-end encryption (passphrase-in-URL-hash mode).
# When True, users may opt in (account preference) to have their meetings
# created as end-to-end encrypted by default.
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
# External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40,
@@ -981,13 +988,7 @@ class Test(Base):
USE_SWAGGER = True
EXTERNAL_API_ENABLED = True
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_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.13.0"
version = "1.12.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -55,7 +55,7 @@ dependencies = [
"PyJWT==2.12.1",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.33.0",
"requests==2.32.5",
"sentry-sdk==2.54.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
+49 -49
View File
@@ -474,55 +474,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.6"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1157,7 +1157,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.13.0"
version = "1.12.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -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.33.0" },
{ name = "requests", specifier = "==2.32.5" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -1965,7 +1965,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.33.0"
version = "2.32.5"
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/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+872 -3
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.13.0",
"version": "1.12.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -10,7 +10,9 @@
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
"check": "prettier --check ./src",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
@@ -59,10 +61,12 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"jsdom": "^29.0.2",
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1"
"vite-tsconfig-paths": "6.1.1",
"vitest": "^4.1.3"
}
}
-5
View File
@@ -274,11 +274,6 @@ 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,
}),
+4
View File
@@ -13,6 +13,7 @@ export interface ApiConfig {
help_article_transcript: string
help_article_recording: string
help_article_more_tools: string
help_article_encryption?: string
}
feedback: {
url: string
@@ -52,6 +53,9 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
encryption?: {
enabled: boolean
}
transcription_destination?: string
}
+2 -2
View File
@@ -57,7 +57,7 @@ export const Avatar = ({
style,
...props
}: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? ''
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
return (
<div
style={{
@@ -70,7 +70,7 @@ export const Avatar = ({
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
lineHeight: 1,
})}
>
{initial}
@@ -1,10 +1,13 @@
import { BackendLanguage } from '@/utils/languages'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export type ApiUser = {
id: string
email: string
full_name: string
full_name: string | null
short_name: string | null
last_name: string
language: BackendLanguage
timezone: string
default_encryption_mode: ApiEncryptionMode
}
@@ -1,15 +1,18 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
export type ApiUserPreferences = Partial<
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption_mode'>
> & { id: string }
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise<ApiUser> => {
return await fetchApi(`/users/${user.id}/`, {
method: 'PUT',
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
const { id, ...payload } = user
return await fetchApi(`/users/${id}/`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
@@ -0,0 +1,113 @@
/**
* Tile overlay shown when LiveKit raises an EncryptionError for a remote
* participant (a passphrase/key mismatch "you and they don't share the
* same encryption key"). Renders the participant's avatar placeholder
* over the broken video, plus a black banner at the bottom of the tile
* explaining the issue.
*
* Cleared automatically once frames decrypt again
* (ParticipantEncryptionStatusChanged with encrypted=true).
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Participant, RoomEvent } from 'livekit-client'
import { useRoomContext } from '@livekit/components-react'
import { RiLockFill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
interface Props {
participant: Participant
}
export function DecryptionFailedTileOverlay({ participant }: Props) {
const { t } = useTranslation('rooms', {
keyPrefix: 'encryption.decryptionFailed',
})
const room = useRoomContext()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!isEncrypted) return
if (participant.isLocal) return
const identity = participant.identity
const onError = (_err: Error, p?: Participant) => {
if (p?.identity === identity) setFailed(true)
}
const onStatus = (encrypted: boolean, p?: Participant) => {
if (p?.identity === identity && encrypted) setFailed(false)
}
room.on(RoomEvent.EncryptionError, onError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
return () => {
room.off(RoomEvent.EncryptionError, onError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
}
}, [room, isEncrypted, participant])
if (!failed) return null
return (
<output
aria-label={t('title')}
className={css({
position: 'absolute',
inset: 0,
zIndex: 3,
pointerEvents: 'none',
})}
>
<ParticipantPlaceholder participant={participant} />
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>{t('title')}</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
maxWidth: '22rem',
}}
>
{t('body')}
</div>
</div>
</output>
)
}
@@ -0,0 +1,79 @@
/**
* Shown when the URL hash and the room's encryption_mode disagree.
*
* - missingPassphrase: room is encrypted on the server, but the URL has no
* (or an invalid) passphrase. The user opened the wrong link.
* - unexpectedPassphrase: the URL has a passphrase, but the server says the
* room is not encrypted. Either the room was created differently or the
* link looks tampered with either way, joining as "encrypted" would
* leave the user alone in an encrypted bubble. Better to bail.
*/
import { css } from '@/styled-system/css'
import { Center } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react'
import { Button, Text } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { navigateTo } from '@/navigation/navigateTo'
interface Props {
reason: 'missingPassphrase' | 'unexpectedPassphrase'
}
export function EncryptionMismatchScreen({ reason }: Props) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
return (
<Screen layout="centered">
<CenteredContent>
<Center>
<div
className={css({
maxWidth: '420px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fffbeb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
{reason === 'missingPassphrase' ? (
<RiLockUnlockLine size={28} color="#b45309" />
) : (
<RiAlertLine size={28} color="#b45309" />
)}
</div>
<Text
as="h2"
className={css({ fontWeight: 700, fontSize: '1.15rem' })}
>
{t(`${reason}.title`)}
</Text>
<Text
as="p"
className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}
>
{t(`${reason}.body`)}
</Text>
<Button variant="primary" onPress={() => navigateTo('home')}>
{t('backHome')}
</Button>
</div>
</Center>
</CenteredContent>
</Screen>
)
}
@@ -0,0 +1,37 @@
/**
* Small pill used to surface a feature name with an icon, e.g. in the
* encrypted-room create dialog, the "Meeting information" panel and the
* floating share dialog the three places that list disabled features.
*/
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
interface Props {
icon: ReactNode
label: string
size?: 'sm' | 'md'
}
export const FeaturePill = ({ icon, label, size = 'md' }: Props) => {
const fontSize = size === 'sm' ? '0.8rem' : '0.85rem'
const padding = size === 'sm' ? '0.3rem 0.6rem' : '0.4rem 0.7rem'
return (
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
color: 'greyscale.700',
backgroundColor: 'white',
whiteSpace: 'nowrap',
})}
style={{ fontSize, padding }}
>
{icon}
{label}
</span>
)
}
@@ -0,0 +1,141 @@
/**
* Top-left status banner shown during a meeting.
*
* Renders a horizontal stack of pills, one per active state:
* - "End-to-end encrypted"
* - "Recording in progress"
* - "Transcription in progress"
*
* Each pill auto-collapses to its icon a few seconds after appearing,
* and expands back on hover.
*/
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import {
RiFileTextFill,
RiRecordCircleFill,
RiShieldCheckLine,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
const COLLAPSE_DELAY_MS = 4000
interface PillProps {
icon: React.ReactNode
label: string
background: string
pulse?: boolean
}
function StatusPill({ icon, label, background, pulse }: PillProps) {
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS)
return () => clearTimeout(t)
}, [])
return (
<output
onMouseEnter={() => setCollapsed(false)}
onMouseLeave={() => setCollapsed(true)}
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'default',
overflow: 'hidden',
transition: 'max-width 300ms ease, padding-right 200ms ease',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: background,
maxWidth: collapsed ? '2.2rem' : '20rem',
paddingRight: collapsed ? '0.3rem' : '0.6rem',
animation: pulse ? 'pulse_background 1.6s infinite' : undefined,
}}
>
<span className={css({ flexShrink: 0, display: 'inline-flex' })}>
{icon}
</span>
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{ opacity: collapsed ? 0 : 1 }}
>
{label}
</span>
</output>
)
}
export function RoomStatusBanner() {
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
const roomData = useRoomData()
// Use the metadata-driven `isStarted` for both pills — it flips to
// false the moment the user clicks stop (recording_status moves to
// Saving), so the pill disappears immediately instead of lingering
// through LK's 1-2s post-stop callback delay.
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const isRecording = screenRec.isStarted
const isTranscribing = transcript.isStarted
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
if (!isEncrypted && !isRecording && !isTranscribing) {
return null
}
return (
<HStack
gap="0.4rem"
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
})}
>
{isEncrypted && (
<StatusPill
key="encrypted"
icon={<RiShieldCheckLine size={14} color="white" />}
label={t('encrypted')}
background="#1e3a5f"
/>
)}
{isTranscribing && (
<StatusPill
key="transcript"
icon={<RiFileTextFill size={13} color="white" />}
label={t('transcribing')}
background="#7c2d12"
/>
)}
{isRecording && (
<StatusPill
key="recording"
icon={<RiRecordCircleFill size={13} color="white" />}
label={t('recording')}
background="#b91c1c"
pulse
/>
)}
</HStack>
)
}
@@ -0,0 +1,10 @@
export {
generatePassphrase,
isValidPassphrase,
getPassphraseFromHash,
PASSPHRASE_LENGTH,
} from './passphrase'
export { RoomStatusBanner } from './RoomStatusBanner'
export { FeaturePill } from './FeaturePill'
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
@@ -0,0 +1,33 @@
/**
* Passphrase utilities for end-to-end encryption.
*
* The passphrase is appended to a room URL as the hash fragment
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
* server never sees it; participants share it by sharing the link.
*
* Encoding is plain hex so that the validator's regex matches exactly
* what the generator produces: 48 lowercase hex characters = 192 bits
* of entropy, no overlap with looser "looks like a passphrase" inputs.
*/
const PASSPHRASE_BYTES = 24
/** Length, in characters, of a generated passphrase. */
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
/** Generate a random passphrase suitable for an encrypted room. */
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/** Whether a string is exactly a generator-shaped passphrase. */
export function isValidPassphrase(value: string): boolean {
return value.length === PASSPHRASE_LENGTH && /^[0-9a-f]+$/.test(value)
}
/** Read the current URL hash (without the leading `#`). */
export function getPassphraseFromHash(): string {
return window.location.hash.replace(/^#/, '')
}
@@ -0,0 +1,147 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Dialog } from '@/primitives'
import { Checkbox } from '@/primitives/Checkbox'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { RiAlertFill, RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
interface Props {
room: ApiRoom | null
hash: string
onOpenChange: (open: boolean) => void
onStart: () => void
}
export const ConnectionDetailsDialog = ({
room,
hash,
onOpenChange,
onStart,
}: Props) => {
const { t } = useTranslation('home', { keyPrefix: 'connectionDetailsDialog' })
const [acknowledged, setAcknowledged] = useState(false)
const [copied, setCopied] = useState(false)
if (!room) return null
const url = `${getRouteUrl('room', room.slug)}#${hash}`
const displayUrl = url.replace(/^https?:\/\//, '')
const copy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('copy failed', err)
}
}
return (
<Dialog
isOpen={!!room}
onOpenChange={onOpenChange}
title={t('title')}
role="dialog"
>
<p
className={css({
fontSize: '0.9rem',
color: 'greyscale.700',
marginBottom: '1rem',
})}
>
{t('description')}
</p>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.6rem 0.9rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
backgroundColor: 'white',
marginBottom: '1rem',
})}
>
<span
className={css({
flexGrow: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{displayUrl}
</span>
<Button
variant={copied ? 'success' : 'tertiaryText'}
square
size="sm"
onPress={copy}
aria-label={t('copy')}
tooltip={t('copy')}
>
{copied ? <RiCheckLine size={16} /> : <RiFileCopyLine size={16} />}
</Button>
</div>
<div
className={css({
display: 'flex',
gap: '0.5rem',
padding: '0.75rem 0.9rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
marginBottom: '1rem',
alignItems: 'flex-start',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<div className={css({ flex: 1 })}>
<p
className={css({
fontSize: '0.85rem',
color: '#7c2d12',
lineHeight: 1.4,
marginBottom: '0.5rem',
})}
>
{t('warning')}
</p>
<Checkbox
isSelected={acknowledged}
onChange={setAcknowledged}
className={css({
fontSize: '0.9rem',
color: '#7c2d12',
})}
>
{t('iUnderstand')}
</Checkbox>
</div>
</div>
<HStack gap="0.5rem" justify="flex-end">
<Button
variant="primary"
isDisabled={!acknowledged}
onPress={onStart}
data-attr="encrypted-start"
>
{t('startMeeting')}
</Button>
</HStack>
</Dialog>
)
}
@@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import {
RiPhoneLine,
RiComputerLine,
RiFileTextLine,
RiRecordCircleLine,
} from '@remixicon/react'
import { FeaturePill } from '@/features/encryption'
interface Props {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export const CreateEncryptedMeetingDialog = ({
isOpen,
onOpenChange,
onConfirm,
}: Props) => {
const { t } = useTranslation('home', {
keyPrefix: 'createEncryptedMeetingDialog',
})
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
title={t('title')}
role="dialog"
>
<p
className={css({
fontSize: '0.9rem',
color: 'greyscale.700',
marginBottom: '0.75rem',
})}
>
{t('description')}
</p>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '1rem',
})}
>
<FeaturePill
icon={<RiPhoneLine size={14} />}
label={t('features.dialIn')}
/>
<FeaturePill
icon={<RiComputerLine size={14} />}
label={t('features.meetingRoom')}
/>
<FeaturePill
icon={<RiFileTextLine size={14} />}
label={t('features.transcription')}
/>
<FeaturePill
icon={<RiRecordCircleLine size={14} />}
label={t('features.recording')}
/>
</div>
<p
className={css({
fontSize: '0.85rem',
color: 'greyscale.700',
marginBottom: '1.25rem',
})}
>
{t('warning')}
</p>
<HStack gap="0.5rem" justify="flex-end">
<Button variant="tertiary" onPress={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button
variant="primary"
onPress={onConfirm}
data-attr="create-encrypted-confirm"
>
{t('confirm')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,35 +1,163 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { css } from '@/styled-system/css'
import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { isValidPassphrase } from '@/features/encryption'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
const [step, setStep] = useState<'room' | 'passphrase'>('room')
const [roomId, setRoomId] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
const roomId = (data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
navigateTo('room', roomId)
const parseInput = (input: string): { roomId: string; hash: string } => {
const trimmed = input.trim()
try {
const url = new URL(trimmed)
const id = url.pathname.replace(/^\//, '')
return { roomId: id, hash: url.hash.slice(1) }
} catch {
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
const raw = trimmed.replace(`${window.location.origin}/`, '')
return { roomId: normalizeRoomId(raw), hash: '' }
}
}
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
const input = data.roomId as string
const parsed = parseInput(input)
if (parsed.hash) {
navigateTo('room', parsed.roomId, { hash: parsed.hash })
return
}
setIsLoading(true)
try {
const room = await fetchRoom({ roomId: parsed.roomId })
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
setRoomId(parsed.roomId)
setStep('passphrase')
return
}
navigateTo('room', parsed.roomId)
} catch {
// Room doesn't exist yet or error — navigate anyway
navigateTo('room', parsed.roomId)
} finally {
setIsLoading(false)
}
}
const handlePassphraseSubmit = (data: {
passphrase?: FormDataEntryValue
}) => {
const passphrase = (data.passphrase as string).trim()
navigateTo('room', roomId, { hash: passphrase })
}
const validateRoomId = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return null
return !isRoomValid(trimmed) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
const { roomId: id, hash } = parseInput(trimmed)
if (!isRoomValid(id))
return (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
<li>uioazerjkl</li>
</Ul>
</>
)
// If a hash is pasted in, refuse malformed passphrases now (instead of
// letting Conference render the mismatch screen after navigation).
if (hash && !isValidPassphrase(hash))
return <p>{t('joinPassphraseInvalidFormat')}</p>
return null
}
if (step === 'passphrase') {
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={handlePassphraseSubmit}
submitLabel={t('joinPassphraseSubmit')}
>
<P
dangerouslySetInnerHTML={{
__html: t('joinPassphraseDescription', {
interpolation: { escapeValue: false },
}),
}}
/>
<div
className={css({
backgroundColor: 'greyscale.100',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
fontSize: '0.8rem',
fontFamily: 'monospace',
wordBreak: 'break-all',
lineHeight: '1.5',
border: '1px solid',
borderColor: 'greyscale.200',
'& strong': {
color: '#16a34a',
fontWeight: 700,
},
})}
dangerouslySetInnerHTML={{
__html: t('joinPassphraseExample', {
origin: window.location.origin,
interpolation: { escapeValue: false },
}),
}}
/>
{/* eslint-disable jsx-a11y/no-autofocus */}
<Field
type="text"
autoFocus
isRequired
name="passphrase"
label={t('joinPassphraseLabel')}
validate={(value: string) => {
const v = (value || '').trim()
if (!v) return t('joinPassphraseError')
if (!isValidPassphrase(v)) return t('joinPassphraseInvalidFormat')
return null
}}
/>
<P
className={css({
fontSize: '0.8rem',
color: '#b45309',
marginTop: '0.5rem',
})}
>
{t('joinPassphraseWarning')}
</P>
</Form>
</Dialog>
)
}
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
<Form
onSubmit={handleRoomSubmit}
submitLabel={isLoading ? '...' : t('joinInputSubmit')}
>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Field
type="text"
@@ -17,7 +17,7 @@ export const LaterMeetingDialog = ({
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const roomUrl = room && getRouteUrl('room', room?.slug)
const roomUrl = room ? getRouteUrl('room', room.slug) : null
const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false)
+95 -16
View File
@@ -1,5 +1,10 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import {
DialogTrigger,
MenuItem,
Menu as RACMenu,
Separator as RACSeparator,
} from 'react-aria-components'
import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
@@ -7,8 +12,12 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink } from '@remixicon/react'
import { RiAddLine, RiLink, RiShieldCrossLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { CreateEncryptedMeetingDialog } from '@/features/home/components/CreateEncryptedMeetingDialog'
import { ConnectionDetailsDialog } from '@/features/home/components/ConnectionDetailsDialog'
import { generatePassphrase } from '@/features/encryption'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react'
@@ -148,17 +157,42 @@ const IntroText = styled('div', {
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { isLoggedIn, user } = useUser()
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom }>(null)
const [encryptedRoom, setEncryptedRoom] = useState<null | {
room: ApiRoom
hash: string
}>(null)
const [showEncryptedConfirm, setShowEncryptedConfirm] = useState(false)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
// The encrypted dropdown entry is offered only when:
// - the server has encryption enabled (instance config), AND
// - the user opted into the feature in their preferences.
// "Instant meeting" and "Later date" stay plain regardless — encryption
// is always an explicit, opt-in flow with its own confirmation modal.
const encryptionAvailable =
!!data?.encryption?.enabled &&
user?.default_encryption_mode === ApiEncryptionMode.BASIC
const buildRoomBundle = async (
encryptionMode: ApiEncryptionMode = ApiEncryptionMode.NONE
) => {
const slug = generateRoomId()
const hash =
encryptionMode === ApiEncryptionMode.BASIC
? generatePassphrase()
: undefined
const room = await createRoom({ slug, username, encryptionMode })
return { room, hash }
}
useEffect(() => {
const checkSiteAndRedirect = async () => {
@@ -210,12 +244,10 @@ export const Home = () => {
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
const { room } = await buildRoomBundle()
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
})
}}
data-attr="create-option-instant"
>
@@ -226,17 +258,37 @@ export const Home = () => {
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
)
onAction={async () => {
const { room } = await buildRoomBundle()
setLaterRoom({ room })
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
{encryptionAvailable && (
<>
<RACSeparator
className={css({
border: 'none',
height: '1px',
background: 'greyscale.250',
margin: '0.35rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setShowEncryptedConfirm(true)}
data-attr="create-option-encrypted"
>
<RiShieldCrossLine size={18} />
{t('createMenu.encryptedOption')}
</MenuItem>
</>
)}
</RACMenu>
</Menu>
) : (
@@ -265,9 +317,36 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
room={laterRoom}
room={laterRoom?.room ?? null}
onOpenChange={() => setLaterRoom(null)}
/>
<CreateEncryptedMeetingDialog
isOpen={showEncryptedConfirm}
onOpenChange={setShowEncryptedConfirm}
onConfirm={async () => {
setShowEncryptedConfirm(false)
const { room, hash } = await buildRoomBundle(
ApiEncryptionMode.BASIC
)
if (hash) setEncryptedRoom({ room, hash })
}}
/>
<ConnectionDetailsDialog
room={encryptedRoom?.room ?? null}
hash={encryptedRoom?.hash ?? ''}
onOpenChange={(open) => {
if (!open) setEncryptedRoom(null)
}}
onStart={() => {
if (!encryptedRoom) return
const { room, hash } = encryptedRoom
setEncryptedRoom(null)
navigateTo('room', room.slug, {
state: { create: true, initialRoomData: room },
hash,
})
}}
/>
</Screen>
</UserAware>
)
@@ -1,44 +0,0 @@
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>
)
}
@@ -1,86 +0,0 @@
// 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 { useCallback, useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -10,11 +10,17 @@ 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'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -22,7 +28,8 @@ export const MainNotificationToast = () => {
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
const { appendReaction } = useReactions()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
useEffect(() => {
const handleChatMessage = (
@@ -55,13 +62,21 @@ export const MainNotificationToast = () => {
}
}, [room, triggerNotificationSound, announce, t])
const handleEmoji = useCallback(
(emoji: string, participant: Participant) => {
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
appendReaction(emoji as Emoji, participant)
},
[appendReaction]
)
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)
}
useEffect(() => {
const handleDataReceived = (
@@ -134,7 +149,7 @@ export const MainNotificationToast = () => {
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room, handleEmoji])
}, [room])
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
@@ -237,6 +252,7 @@ export const MainNotificationToast = () => {
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
<ReactionPortals reactions={reactions} />
</Div>
)
}
@@ -3,7 +3,8 @@ import { HStack, VStack } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiInfinityLine } from '@remixicon/react'
import { RiErrorWarningLine, RiInfinityLine } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { usePrevious } from '@/hooks/usePrevious'
@@ -21,6 +22,8 @@ export const WaitingParticipantNotification = () => {
const { t } = useTranslation('notifications', {
keyPrefix: 'waitingParticipants',
})
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'identity' })
const anonymousLabel = tRooms('anonymous.tooltip')
const timerRef = useRef<NodeJS.Timeout | null>(null)
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -34,7 +37,6 @@ export const WaitingParticipantNotification = () => {
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
useEffect(() => {
// Show notification when the first participant enters the waiting room
if (
!isParticipantListEmpty(waitingParticipants) &&
isParticipantListEmpty(prevWaitingParticipant) &&
@@ -49,10 +51,9 @@ export const WaitingParticipantNotification = () => {
}
timerRef.current = setTimeout(() => {
setShowQuickActionsMessage(false)
timerRef.current = null // Clear the ref when timeout completes
timerRef.current = null
}, NOTIFICATION_DISPLAY_DURATION)
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
// Hide notification when the participant count changes
setShowQuickActionsMessage(false)
}
}, [
@@ -63,7 +64,6 @@ export const WaitingParticipantNotification = () => {
])
useEffect(() => {
// This cleanup function will only run when the component unmounts
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
@@ -72,7 +72,6 @@ export const WaitingParticipantNotification = () => {
}, [])
useEffect(() => {
// Hide notification when participants panel is opened
if (isParticipantsOpen) {
setShowQuickActionsMessage(false)
}
@@ -100,7 +99,7 @@ export const WaitingParticipantNotification = () => {
>
{t('one')}
</Text>
<HStack gap="1rem">
<HStack gap="0.5rem" alignItems="center">
<Avatar
name={waitingParticipants[0].username}
bgColor={waitingParticipants[0].color}
@@ -119,6 +118,22 @@ export const WaitingParticipantNotification = () => {
>
{waitingParticipants[0].username}
</Text>
{!waitingParticipants[0].is_authenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={16} color="#f87171" />
</span>
</VisualOnlyTooltip>
)}
</HStack>
<HStack gap="0.25rem" marginLeft="auto">
<Button
@@ -1,33 +0,0 @@
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>
)
}
@@ -1,31 +0,0 @@
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>
)
}
@@ -1,153 +0,0 @@
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>
)
}
@@ -1,5 +0,0 @@
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
@@ -1,26 +0,0 @@
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])
}
@@ -1,56 +0,0 @@
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,
}
}
@@ -1,13 +0,0 @@
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
},
}
}
@@ -1,17 +0,0 @@
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,11 +1,13 @@
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { RecordingStateToast } from './RecordingStateToast'
import { ErrorAlertDialog } from './ErrorAlertDialog'
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
// now shows "Recording in progress" and "Transcription in progress" in the
// same place, so the standalone toast was rendering behind the new pills.
export const RecordingProvider = () => {
return (
<>
<RecordingStateToast />
<LimitReachedAlertDialog />
<ErrorAlertDialog />
</>
@@ -10,6 +10,11 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
}
export type ApiRoom = {
id: string
name: string
@@ -17,6 +22,7 @@ export type ApiRoom = {
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
encryption_mode: ApiEncryptionMode
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean | string[]
@@ -1,24 +1,30 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
import { ApiEncryptionMode, ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string
callbackId?: string
username?: string
encryptionMode?: ApiEncryptionMode
}
const createRoom = ({
slug,
callbackId,
username = '',
encryptionMode = ApiEncryptionMode.NONE,
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
const queryParams = username
? `?username=${encodeURIComponent(username)}`
: ''
return fetchApi(`rooms/${queryParams}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
callback_id: callbackId,
encryption_mode: encryptionMode,
}),
})
}
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
status: string
username: string
color: string
is_authenticated: boolean
}
export type WaitingParticipantsResponse = {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
@@ -7,18 +7,24 @@ import {
} from '@livekit/components-react'
import {
DisconnectReason,
ExternalE2EEKeyProvider,
MediaDeviceFailure,
Room,
RoomOptions,
VideoPresets,
} from 'livekit-client'
import {
getPassphraseFromHash,
isValidPassphrase,
EncryptionMismatchScreen,
} from '@/features/encryption'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { ApiEncryptionMode, ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
@@ -86,12 +92,74 @@ export const Conference = ({
retry: false,
})
// The URL hash is the *source of truth* for whether to encrypt: the
// server is never given the passphrase, so a compromised server can't
// fabricate or suppress encryption — it can only claim a status, and we
// use that claim only as a sanity reference for the mismatch screen.
//
// `hasValidHash` is synchronous (reads window.location.hash), so it's
// either true or false on every render — never "we don't know yet".
const hashPassphrase = getPassphraseFromHash()
const hasValidHash = isValidPassphrase(hashPassphrase)
const dbSaysEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
type EncryptionMismatch =
| 'missingPassphrase'
| 'unexpectedPassphrase'
| null
let encryptionMismatch: EncryptionMismatch = null
if (data !== undefined) {
if (dbSaysEncrypted && !hasValidHash) {
encryptionMismatch = 'missingPassphrase'
} else if (!dbSaysEncrypted && hashPassphrase.length > 0) {
encryptionMismatch = 'unexpectedPassphrase'
}
}
// We treat the room as encrypted purely because we have a valid hash.
// No server condition. If the hash is valid we MUST run E2EE; if not,
// there's nothing to encrypt with.
const isEncrypted = hasValidHash
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
// `roomWithE2EE` is the actual `Room` instance for which we've already
// run `setKey + setE2EEEnabled(true)`. Comparing it by reference with the
// currently-memoised `room` lets us derive the "setup complete" status
// synchronously during render — no separate boolean, no useEffect-driven
// reset, no race window between a new Room appearing and a flag flipping.
//
// A device-pref change rebuilds `roomOptions` → a new `Room` instance is
// memoised; on that same render `roomWithE2EE !== room` so the gate
// below stays closed until the setup effect has stamped the new Room.
const [roomWithE2EE, setRoomWithE2EE] = useState<Room | null>(null)
const [encryptionSetupError, setEncryptionSetupError] =
useState<Error | null>(null)
const getKeyProvider = () => {
if (!keyProviderRef.current && isEncrypted) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && isEncrypted && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
return workerRef.current
}
const roomOptions = useMemo((): RoomOptions => {
return {
const baseOptions: RoomOptions = {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
videoCodec: isEncrypted ? undefined : 'vp9',
red: !isEncrypted,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -106,8 +174,20 @@ export const Conference = ({
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
if (isEncrypted) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
baseOptions.encryption = { keyProvider, worker }
}
}
return baseOptions
// do not rely on the userConfig object directly as its reference may change on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isEncrypted,
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
@@ -116,61 +196,14 @@ export const Conference = ({
const room = useMemo(() => new Room(roomOptions), [roomOptions])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
* This prefetch helps reduce initial connection latency by establishing
* an early HTTP connection to the WebRTC signaling server
*
* It should cache DNS and TLS keys.
*/
const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return
await room.prepareConnection(apiConfig.livekit.url)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
apiConfig.livekit.url
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND:
*
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
*
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
*
* Note: This issue is reproducible on LiveKit's demo app.
* Reference: livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
// 401 unauthorized response is expected
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const encryptionSetupComplete = !isEncrypted || roomWithE2EE === room
// Never let LiveKitRoom connect in an indeterminate state:
// 1. `data` must have arrived from the server so we know whether to
// show the mismatch screen.
// 2. If the URL has a valid hash, the *current* Room must have already
// been armed with setKey + setE2EEEnabled — otherwise the camera
// goes out in clear.
const canConnectMediaWise = data !== undefined && encryptionSetupComplete
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
@@ -186,9 +219,98 @@ export const Conference = ({
return livekit_url
}, [apiConfig?.livekit])
useEffect(() => {
if (!isEncrypted || roomWithE2EE === room) return
const keyProvider = getKeyProvider()
if (!keyProvider) return
// `isEncrypted === hasValidHash`, so by the time we get here the URL
// already carries a valid passphrase. Hash generation happens upstream
// (in `Home.tsx` for new encrypted meetings); we just read it here.
const passphrase = getPassphraseFromHash()
if (!passphrase) return
// Must only stamp the room as "armed" after the chain has actually
// succeeded. If `setE2EEEnabled` rejects we surface the failure to
// the user via `encryptionSetupError` and stay disconnected.
let cancelled = false
keyProvider
.setKey(passphrase)
.then(() => room.setE2EEEnabled(true))
.then(() => {
if (!cancelled) setRoomWithE2EE(room)
})
.catch((err) => {
if (cancelled) return
console.error('[Encryption] setup failed:', err)
setEncryptionSetupError(
err instanceof Error ? err : new Error(String(err))
)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, isEncrypted, roomWithE2EE])
useEffect(() => {
if (!data) return
let currentHash = getPassphraseFromHash()
const onHashChange = () => {
const next = getPassphraseFromHash()
if (next === currentHash) return
currentHash = next
window.location.reload()
}
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [data])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room.
* Use the normalized `serverUrl` (the same value the LiveKitRoom
* will connect to after `force_wss_protocol`) so the warm-up matches
* the actual connection target.
*/
const prepareConnection = async () => {
if (!apiConfig || !serverUrl || isConnectionWarmedUp) return
await room.prepareConnection(serverUrl)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
serverUrl.replace('https://', 'wss://').replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND see livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, serverUrl, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
<ErrorScreen
title={t('error.createRoom.heading')}
@@ -197,11 +319,23 @@ export const Conference = ({
)
}
if (encryptionMismatch) {
return <EncryptionMismatchScreen reason={encryptionMismatch} />
}
if (encryptionSetupError) {
return (
<ErrorScreen
title={t('error.encryptionSetup.heading')}
body={t('error.encryptionSetup.body')}
/>
)
}
// Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions
const connectOptions = {
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
maxRetries: 5,
peerConnectionTimeout: 60000,
}
return (
@@ -211,7 +345,7 @@ export const Conference = ({
room={room}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={isConnectionWarmedUp}
connect={isConnectionWarmedUp && canConnectMediaWise}
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
@@ -5,15 +5,19 @@ import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
import {
RiAlertFill,
RiCheckLine,
RiCloseLine,
RiComputerLine,
RiFileCopyLine,
RiPhoneLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { FeaturePill } from '@/features/encryption'
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
@@ -41,15 +45,30 @@ const StyledRACDialog = styled(Dialog, {
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const { t: tHome } = useTranslation('home', {
keyPrefix: 'connectionDetailsDialog',
})
const { t: tFeatures } = useTranslation('home', {
keyPrefix: 'createEncryptedMeetingDialog',
})
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const isAdminOrOwner = !!roomData?.is_administrable
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony()
// Encrypted rooms never get a working PIN (backend skips both pin_code
// and dispatch_rule allocation), so the phone block must stay hidden
// even if a stale pin_code somehow slipped through.
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
return telephony?.enabled && roomData?.pin_code && !isEncrypted
}, [telephony?.enabled, roomData?.pin_code, isEncrypted])
const {
isCopied,
@@ -68,7 +87,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
{isEncrypted ? t('encryptedHeading') : t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -84,113 +103,235 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<RiCloseLine />
</Button>
</Div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
{isEncrypted && !isAdminOrOwner ? (
<P>{t('encryptedGuestBody')}</P>
) : (
<P>{t('description')}</P>
)}
{(() => {
if (isEncrypted && !isAdminOrOwner) return null
if (isEncrypted) {
return (
<div
className={css({
width: '100%',
marginTop: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{tHome('warning')}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
})}
>
<span
className={css({
flex: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})}
>
{roomUrl?.replace(/^https?:\/\//, '')}
</span>
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size="sm"
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine size={16} />
) : (
<RiFileCopyLine size={16} />
)}
</Button>
</div>
<Text
margin={false}
className={css({
fontSize: '12px',
fontWeight: 400,
color: 'greyscale.500',
})}
>
{t('encryptedDisabledHeading')}
</Text>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.4rem',
})}
>
<FeaturePill
size="sm"
icon={<RiPhoneLine size={13} />}
label={tFeatures('features.dialIn')}
/>
<FeaturePill
size="sm"
icon={<RiComputerLine size={13} />}
label={tFeatures('features.meetingRoom')}
/>
</div>
</div>
)
}
if (isTelephonyReadyForUse) {
return (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={
isRoomUrlCopied ? t('copied') : t('copyUrl')
}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
) : (
<RiFileCopyLine aria-hidden="true" />
)}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
{t('copied')}
</>
) : (
<RiFileCopyLine aria-hidden="true" />
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
</>
)}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
</div>
)
}
return (
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine
size={18}
style={{ marginRight: '8px' }}
aria-hidden="true"
/>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
)}
)
})()}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
@@ -32,8 +32,15 @@ import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/api/queryClient'
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { ApiAccessLevel, ApiEncryptionMode } from '../api/ApiRoom'
import {
isValidPassphrase,
getPassphraseFromHash,
EncryptionMismatchScreen,
} from '@/features/encryption'
import { useLoginHint } from '@/hooks/useLoginHint'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
import { useUser } from '@/features/auth'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
@@ -103,6 +110,39 @@ export const Join = ({
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
// Early fetch to inspect the room (encrypted? requires passphrase?)
const { data: roomInfo } = useQuery({
queryKey: [keys.room, roomId, 'info'],
queryFn: () => fetchRoom({ roomId }),
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
const { user, isLoggedIn } = useUser()
// Authenticated joiners of an encrypted room can't pick an arbitrary
// display name — it must match the OIDC profile, and the backend
// re-enforces this when minting the JWT.
const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || ''
// Keep the passphrase in state and refresh on `hashchange` so the
// mismatch screen recovers immediately when the user pastes the
// correct hash into the address bar.
const [passphrase, setPassphrase] = useState(getPassphraseFromHash)
useEffect(() => {
const onHashChange = () => setPassphrase(getPassphraseFromHash())
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
const hasValidPassphrase = isEncryptedRoom
? isValidPassphrase(passphrase)
: true
// If the URL has a passphrase but the room itself is not encrypted, the
// link looks tampered with — refuse to join and offer a fresh room.
const unexpectedPassphrase =
!!roomInfo && !isEncryptedRoom && passphrase.length > 0
const {
userChoices: {
audioEnabled,
@@ -173,11 +213,6 @@ export const Join = ({
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
@@ -245,8 +280,6 @@ export const Join = ({
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
@@ -286,12 +319,6 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
// 3. State is manually updated via queryClient when a waiting participant is accepted
// 4. No automatic refetching or revalidation occurs during this period
// todo - refactor in a hook
const {
data: roomData,
error,
@@ -333,7 +360,6 @@ export const Join = ({
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
@@ -426,6 +452,12 @@ export const Join = ({
)
default:
if (unexpectedPassphrase) {
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
}
if (isEncryptedRoom && !hasValidPassphrase) {
return <EncryptionMismatchScreen reason="missingPassphrase" />
}
return (
<Form
onSubmit={handleSubmit}
@@ -438,20 +470,75 @@ export const Join = ({
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
{isNameLocked ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('usernameLabel')}
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'greyscale.100',
borderRadius: '0.375rem',
border: '1px solid',
borderColor: 'greyscale.200',
})}
>
<RiLockLine size={14} color="#6b7280" />
<Text variant="sm" margin={false}>
{lockedName}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
})}
>
<RiInformationLine
size={18}
color="#6b7280"
className={css({ flexShrink: 0 })}
/>
<Text
variant="note"
margin={false}
className={css({
fontSize: '0.8rem',
color: 'greyscale.500',
})}
>
{t('encryptedNameLocked')}
</Text>
</div>
</div>
) : (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
@@ -21,7 +21,6 @@ export const useLobby = ({
}) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearWaitingTimeout = useCallback(() => {
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
@@ -40,10 +39,7 @@ export const useLobby = ({
/* eslint-disable @tanstack/query/exhaustive-deps */
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
const response = await requestEntry({
roomId,
username,
})
const response = await requestEntry({ roomId, username })
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
allowEntry: boolean
) => {
await enterRoom({
roomId: roomId,
roomId,
allowEntry,
participantId: participant.id,
})
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
roomId: roomId,
roomId,
allowEntry,
participantId: participant.id,
})
@@ -1,15 +1,15 @@
import { Div, Field, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Separator as RACSeparator } from 'react-aria-components'
import { RiAlertFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { ApiAccessLevel, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -22,12 +22,7 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const { data: readOnlyData } = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }),
retry: false,
enabled: false,
})
const readOnlyData = useRoomData()
const {
toggleMicrophone,
@@ -166,45 +161,99 @@ export const Admin = () => {
>
{t('access.description')}
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
{(() => {
const isEncrypted =
readOnlyData?.encryption_mode === ApiEncryptionMode.BASIC
return (
<>
{isEncrypted && (
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
marginBottom: '0.75rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{t('access.encryptedLocked')}
</Text>
</div>
)}
<div
className={css({
opacity: isEncrypted ? 0.7 : 1,
pointerEvents: isEncrypted ? 'none' : undefined,
transition: 'opacity 200ms ease',
})}
aria-disabled={isEncrypted || undefined}
>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
isDisabled={isEncrypted}
value={
isEncrypted
? ApiAccessLevel.RESTRICTED
: readOnlyData?.access_level
}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
</div>
</>
)
})()}
</div>
</Div>
)
@@ -32,7 +32,6 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
aria-expanded={isAdminOpen}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -2,28 +2,186 @@ import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import {
RiAlertFill,
RiCheckLine,
RiComputerLine,
RiFileCopyLine,
RiPhoneLine,
} from '@remixicon/react'
import { Bold, Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { FeaturePill } from '@/features/encryption'
import { ApiEncryptionMode } from '../../api/ApiRoom'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const { t: tHome } = useTranslation('home', {
keyPrefix: 'connectionDetailsDialog',
})
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const baseRoomUrl = getRouteUrl('room', data?.slug)
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony()
const isEncrypted = data?.encryption_mode === ApiEncryptionMode.BASIC
const isAdminOrOwner = !!data?.is_administrable
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && data?.pin_code
}, [telephony?.enabled, data?.pin_code])
return telephony?.enabled && data?.pin_code && !isEncrypted
}, [telephony?.enabled, data?.pin_code, isEncrypted])
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
if (isEncrypted && !isAdminOrOwner) {
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<Text as="p" variant="note" wrap="pretty">
{t('encrypted.guestBody')}
</Text>
</Div>
)
}
if (isEncrypted) {
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<VStack
alignItems="stretch"
gap="0.75rem"
className={css({ width: '100%' })}
>
<div
role="alert"
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
>
<RiAlertFill
size={18}
color="#b45309"
className={css({ flexShrink: 0 })}
/>
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{tHome('warning')}
</Text>
</div>
<Text as="p" variant="note" margin={false}>
<Bold>{t('encrypted.linkLabel')}</Bold>
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.250',
})}
>
<span
className={css({
flex: 1,
fontFamily: 'monospace',
fontSize: '0.8rem',
wordBreak: 'break-all',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
</span>
<Button
square
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
onPress={copyRoomToClipboard}
aria-label={
isCopied
? t('roomInformation.button.copied')
: t('roomInformation.button.copy')
}
tooltip={
isCopied
? t('roomInformation.button.copied')
: t('roomInformation.button.copy')
}
data-attr="copy-info-sidepannel"
>
{isCopied ? (
<RiCheckLine size={16} />
) : (
<RiFileCopyLine size={16} />
)}
</Button>
</div>
<Text
as="p"
margin={false}
className={css({
fontSize: '12px',
fontWeight: 400,
color: 'greyscale.500',
})}
>
{t('encrypted.disabledHeading')}
</Text>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
<FeaturePill
icon={<RiPhoneLine size={14} />}
label={t('encrypted.features.dialIn')}
/>
<FeaturePill
icon={<RiComputerLine size={14} />}
label={t('encrypted.features.meetingRoom')}
/>
</div>
</VStack>
</Div>
)
}
return (
<Div
display="flex"
@@ -50,7 +208,14 @@ export const Info = () => {
flexDirection: 'column',
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
<Text
as="p"
variant="xsNote"
className={css({
wordBreak: 'break-all',
whiteSpace: 'normal',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
@@ -1,12 +1,10 @@
import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
@@ -24,6 +22,7 @@ import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { DecryptionFailedTileOverlay } from '@/features/encryption'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -78,7 +77,6 @@ export const ParticipantTile: (
onParticipantClick,
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -114,7 +112,6 @@ export const ParticipantTile: (
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
@@ -215,9 +212,6 @@ export const ParticipantTile: (
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
@@ -237,6 +231,11 @@ export const ParticipantTile: (
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
{!isScreenShare && (
<DecryptionFailedTileOverlay
participant={trackReference.participant}
/>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint>
@@ -2,17 +2,19 @@ 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 { 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'
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
interface FloatingReactionProps {
emoji: string
@@ -77,7 +79,7 @@ export function FloatingReaction({
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
alt={''}
className={css({
height: '50px',
})}
@@ -114,7 +116,14 @@ export function FloatingReaction({
)
}
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal(
@@ -129,27 +138,51 @@ const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
})}
>
<FloatingReaction
emoji={reaction.emoji}
emoji={emoji}
speed={speed}
scale={scale}
name={reaction.participantName}
isLocal={reaction.isLocal}
name={participant?.isLocal ? t('you') : participant.name}
isLocal={participant?.isLocal}
/>
</div>,
document.body
)
}
export const ReactionPortals = () => {
const { reactions } = useSnapshot(reactionsStore)
const latestReaction = reactions.at(-1)
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()
useAnnounceReaction(latestReaction)
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])
return (
<>
{reactions.map((instance) => (
<ReactionPortal key={instance.id} reaction={instance} />
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
</>
)
@@ -14,7 +14,6 @@ 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
@@ -26,7 +25,6 @@ type StyledSidePanelProps = {
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
isReactionToolbarOpen?: boolean
}
const StyledSidePanel = ({
@@ -35,7 +33,6 @@ const StyledSidePanel = ({
children,
onClose,
isClosed,
isReactionToolbarOpen,
closeButtonTooltip,
isSubmenu = false,
onBack,
@@ -54,23 +51,17 @@ const StyledSidePanel = ({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: 'var(--sizes-room-side-panel-margin)',
marginLeft: 0,
marginBottom: 0,
margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0,
gap: 0,
right: 0,
top: 0,
width: 'var(--sizes-room-side-panel)',
bottom: '80px',
width: '360px',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
})}
style={{
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)',
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
@@ -160,8 +151,6 @@ export const SidePanel = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`)
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<StyledSidePanel
title={title}
@@ -175,7 +164,6 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
@@ -3,6 +3,7 @@ import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { RiAlertFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
@@ -12,12 +13,15 @@ import {
ScreenRecordingSidePanel,
} from '@/features/recording'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export interface ToolsButtonProps {
icon: ReactNode
title: string
description: string
onPress: () => void
isDisabled?: boolean
}
const ToolButton = ({
@@ -25,9 +29,11 @@ const ToolButton = ({
title,
description,
onPress,
isDisabled,
}: ToolsButtonProps) => {
return (
<RACButton
isDisabled={isDisabled}
className={css({
display: 'flex',
flexDirection: 'row',
@@ -39,10 +45,14 @@ const ToolButton = ({
width: 'full',
backgroundColor: 'gray.50',
textAlign: 'start',
'&[data-hovered]': {
'&[data-hovered]:not([data-disabled])': {
backgroundColor: 'primary.50',
cursor: 'pointer',
},
'&[data-disabled]': {
opacity: 0.5,
cursor: 'not-allowed',
},
})}
onPress={onPress}
>
@@ -98,17 +108,15 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
@@ -166,12 +174,42 @@ export const Tools = () => {
</A>
)}
</Text>
{isEncrypted && (
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
padding: '0.6rem 0.85rem',
margin: '0 0.75rem 0.75rem',
borderRadius: '0.5rem',
backgroundColor: '#fff7ed',
border: '1px solid #fed7aa',
color: '#7c2d12',
})}
role="alert"
>
<RiAlertFill size={18} color="#b45309" />
<Text
variant="sm"
margin={false}
className={css({
color: '#7c2d12',
fontSize: '0.85rem',
lineHeight: 1.4,
})}
>
{t('encryptedBlock')}
</Text>
</div>
)}
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
onPress={openTranscript}
isDisabled={isEncrypted}
/>
)}
{isScreenRecordingEnabled && (
@@ -179,7 +217,8 @@ export const Tools = () => {
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
onPress={openScreenRecording}
isDisabled={isEncrypted}
/>
)}
</Div>
@@ -37,7 +37,6 @@ export const ChatToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
aria-expanded={isChatOpen}
onPress={(e) => {
toggleChat()
onPress?.(e)
@@ -27,7 +27,6 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
aria-expanded={isInfoOpen}
onPress={(e) => {
toggleInfo()
onPress?.(e)
@@ -5,11 +5,15 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const ScreenRecordingMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
useSidePanel()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
@@ -21,9 +25,12 @@ export const ScreenRecordingMenuItem = () => {
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() =>
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
}
isDisabled={isEncrypted}
onAction={() => {
if (isEncrypted) return
if (!isScreenRecordingOpen) openScreenRecording()
else toggleTools()
}}
>
<RiRecordCircleLine size={20} />
{t('screenRecording')}
@@ -5,10 +5,14 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
const roomData = useRoomData()
const isEncrypted = roomData?.encryption_mode === ApiEncryptionMode.BASIC
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -20,7 +24,12 @@ export const TranscriptMenuItem = () => {
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
isDisabled={isEncrypted}
onAction={() => {
if (isEncrypted) return
if (!isTranscriptOpen) openTranscript()
else toggleTools()
}}
>
<RiFileTextLine size={20} />
{t('transcript')}
@@ -13,7 +13,7 @@ import {
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { RiErrorWarningLine, RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives'
import { useState } from 'react'
import { MuteAlertDialog } from '../../MuteAlertDialog'
@@ -21,6 +21,8 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useUser } from '@/features/auth'
type MicIndicatorProps = {
participant: Participant
@@ -97,7 +99,16 @@ export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const { isLoggedIn } = useUser()
const name = participant.name || participant.identity
const isParticipantAuthenticated =
participant.attributes?.is_authenticated === 'true'
const anonymousLabel = t('identity.anonymous.tooltip')
// Email is only displayed to authenticated viewers (defense-in-depth on
// top of the JWT-level guarantee that it's only emitted for authenticated
// participants). The LK signaling channel broadcasts attributes to every
// peer, so the UI is what protects anonymous viewers from seeing it.
const email = isLoggedIn ? participant.attributes?.email : undefined
return (
<HStack
role="listitem"
@@ -118,38 +129,54 @@ export const ParticipantListItem = ({
<PinBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
<Text
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
<HStack gap="0.2rem" alignItems="center">
<Text
variant="sm"
margin={false}
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '150px',
lineHeight: 1.2,
})}
>
{name}
{isLocal(participant) && ` (${t('participants.you')})`}
</Text>
{!isParticipantAuthenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={14} color="#dc2626" />
</span>
</VisualOnlyTooltip>
)}
</HStack>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
{email && (
<Text
variant="xsNote"
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
maxWidth: '180px',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
{email}
</Text>
)}
</VStack>
</HStack>
@@ -48,7 +48,6 @@ export const ParticipantsToggle = ({
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
aria-expanded={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
@@ -77,7 +76,6 @@ export const ParticipantsToggle = ({
zIndex: 1,
userSelect: 'none',
})}
aria-hidden={true}
>
{numParticipants < 100 ? (
numParticipants || 1
@@ -1,10 +1,11 @@
import { Button, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
import { RiCloseLine, RiErrorWarningLine } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export const WaitingParticipantListItem = ({
participant,
@@ -14,6 +15,7 @@ export const WaitingParticipantListItem = ({
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
}) => {
const { t } = useTranslation('rooms')
const anonymousLabel = t('identity.anonymous.tooltip')
return (
<HStack
@@ -30,38 +32,50 @@ export const WaitingParticipantListItem = ({
className={css({
flex: '1',
minWidth: '0',
gap: '0.35rem',
})}
>
<Avatar name={participant.username} bgColor={participant.color} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
<VStack
gap={0}
alignItems="start"
className={css({ flex: 1, minWidth: 0 })}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
<HStack gap="0.2rem" alignItems="center">
<Text
variant="sm"
margin={false}
className={css({
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
padding: '0.1rem 0.25rem',
lineHeight: 1.2,
})}
>
{participant.username}
</Text>
{!participant.is_authenticated && (
<VisualOnlyTooltip
tooltip={anonymousLabel}
ariaLabel={anonymousLabel}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
cursor: 'help',
})}
>
<RiErrorWarningLine size={14} color="#dc2626" />
</span>
</VisualOnlyTooltip>
)}
</HStack>
</VStack>
</HStack>
<HStack
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<HStack gap="0.25rem" className={css({ flexShrink: '0' })}>
<Button
size="sm"
variant="tertiary"
@@ -0,0 +1,161 @@
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,7 +34,6 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
aria-expanded={isToolsOpen}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -7,9 +7,9 @@ import {
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
import { PaginationIndicator } from '../controls/PaginationIndicator'
import { useGridLayout } from '../../hooks/useGridLayout'
import { PaginationControl } from '../controls/PaginationControl'
/** @public */
export interface GridLayoutProps
@@ -2,12 +2,15 @@ import { useTelephony } from './useTelephony'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { ApiRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
export const useCopyRoomToClipboard = (
room: ApiRoom | undefined,
hashOverride?: string
) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
@@ -32,12 +35,23 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
if (!room?.slug) return ''
const base = getRouteUrl('room', room.slug)
// In basic encrypted mode, the passphrase is in the URL hash
const hash = hashOverride ? `#${hashOverride}` : window.location.hash
return hash ? `${base}${hash}` : base
}, [room?.slug, hashOverride])
// Encrypted rooms never get a dispatch rule on the SIP gateway side
// (the backend skips it because no PIN-driven join can ever decrypt),
// so make sure we don't paste a non-functional phone+PIN snippet either.
const hasTelephonyInfo = useMemo(() => {
return telephony.enabled && room?.pin_code
}, [telephony.enabled, room?.pin_code])
return (
telephony.enabled &&
room?.pin_code &&
room?.encryption_mode !== ApiEncryptionMode.BASIC
)
}, [telephony.enabled, room?.pin_code, room?.encryption_mode])
const content = useMemo(() => {
if (!roomUrl || !room) return ''
@@ -4,8 +4,6 @@ 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
@@ -18,32 +16,10 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
export function ControlBar({ onDeviceError }: ControlBarProps) {
const isMobile = useIsMobile()
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>
)
if (isMobile) {
return <MobileControlBar onDeviceError={onDeviceError} />
}
return <DesktopControlBar onDeviceError={onDeviceError} />
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
@@ -3,6 +3,7 @@ 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'
@@ -14,8 +15,6 @@ 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,
@@ -49,7 +48,11 @@ export function DesktopControlBar({
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
@@ -62,7 +65,15 @@ export function DesktopControlBar({
marginLeft: '0.5rem',
})}
/>
<ControlBarRegion>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
@@ -86,7 +97,7 @@ export function DesktopControlBar({
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</ControlBarRegion>
</div>
<MoreOptions parentElement={desktopControlBarEl} />
</div>
)
@@ -25,7 +25,6 @@ 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,
@@ -44,11 +43,21 @@ export function MobileControlBar({
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
justifyContent: 'center',
bottom: 0,
left: 0,
right: 0,
})}
>
<ControlBarRegion mobile>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '330px',
})}
>
<LeaveButton />
<AudioDevicesControl
onDeviceError={(error) =>
@@ -73,7 +82,7 @@ export function MobileControlBar({
>
<RiMore2Line />
</Button>
</ControlBarRegion>
</div>
</div>
<ResponsiveMenu
isOpened={isMenuOpened}
@@ -73,7 +73,7 @@ export const MoreOptions = ({
}) => {
const { width: parentWidth } = useSize(parentElement)
return (
<nav
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
@@ -88,6 +88,6 @@ export const MoreOptions = ({
) : (
<LateralMenu />
)}
</nav>
</div>
)
}
@@ -20,9 +20,13 @@ 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'
@@ -33,13 +37,36 @@ 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 { RoomStatusBanner } from '@/features/encryption'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
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'
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%',
},
},
},
})
)
/**
* @public
@@ -227,6 +254,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
])
/* eslint-enable react-hooks/exhaustive-deps */
const { isSidePanelOpen } = useSidePanel()
const { areSubtitlesOpen } = useSubtitles()
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
return (
@@ -247,35 +277,58 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{!focusTrack ? (
<RoomStatusBanner />
<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
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
)}
</RoomContentArea>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
@@ -295,7 +348,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast />
<RecordingProvider />
<SettingsDialogProvider />
<ReactionPortals />
</div>
)
}
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Emoji } from './types'
export const getEmojiLabel = (
emoji: string | Emoji,
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
@@ -5,10 +5,16 @@ export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
const roomRegex = new RegExp(`^${roomIdPattern}$`)
const roomWithoutHyphensRegex = /^[a-z]{10}$/
export const isRoomValid = (roomIdOrUrl: string) =>
roomRegex.test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const isRoomValid = (roomIdOrUrl: string) => {
const lower = roomIdOrUrl.toLowerCase()
return (
roomRegex.test(lower) ||
roomWithoutHyphensRegex.test(lower) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}`).test(roomIdOrUrl)
)
}
export const normalizeRoomId = (roomId: string) => {
const cleanId = roomId.toLowerCase().replace(/-/g, '')
@@ -39,18 +39,24 @@ export const CreateMeetingButton = () => {
const { data } = useRoomCreationCallback({ callbackId })
const [basicHash, setBasicHash] = useState<string | undefined>(undefined)
const roomUrl = useMemo(() => {
if (room?.slug) return getRouteUrl('room', room.slug)
}, [room])
if (!room?.slug) return undefined
const base = getRouteUrl('room', room.slug)
return basicHash ? `${base}#${basicHash}` : base
}, [room, basicHash])
useEffect(() => {
if (!data?.room?.slug) return
setRoom(data.room)
setCallbackId(undefined)
setIsPending(false)
const url = getRouteUrl('room', data.room.slug)
popupManager.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
url,
...data.room,
},
})
@@ -61,6 +67,7 @@ export const CreateMeetingButton = () => {
(id) => setCallbackId(id),
(data) => {
setRoom(data)
if (data.hash) setBasicHash(data.hash)
setIsPending(false)
}
)
@@ -68,6 +75,25 @@ export const CreateMeetingButton = () => {
return () => popupManager.cleanup()
}, [])
// Communicate iframe height to parent for proper sizing
useEffect(() => {
const targetOrigin = (() => {
try {
return document.referrer ? new URL(document.referrer).origin : '/'
} catch {
return '/'
}
})()
const observer = new ResizeObserver(() => {
window.parent.postMessage(
{ type: 'RESIZE', data: { height: document.body.scrollHeight } },
targetOrigin
)
})
observer.observe(document.body)
return () => observer.disconnect()
}, [])
const resetState = () => {
setRoom(undefined)
setCallbackId(undefined)
@@ -1,64 +1,77 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { generateRoomId, useCreateRoom } from '../../rooms'
import { useUser } from '@/features/auth'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
import { PopupWindow } from '../utils/PopupWindow'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { RiVideoOnLine } from '@remixicon/react'
const callbackIdHandler = new CallbackIdHandler()
const popupWindow = new PopupWindow()
export const CreatePopup = () => {
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
const { isLoggedIn } = useUser({
fetchUserOptions: { attemptSilent: false },
})
const { mutateAsync: createRoom } = useCreateRoom()
const { t } = useTranslation('sdk', { keyPrefix: 'createPopup' })
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
const [isCreating, setIsCreating] = useState(false)
/**
* Handle unauthenticated users by redirecting to login
*
* When redirecting to authentication, the window.location change breaks the connection
* between this popup and its parent window. We need to send the callbackId to the parent
* before redirecting so it can re-establish connection after authentication completes.
* This prevents the popup from becoming orphaned and ensures state consistency.
*/
useEffect(() => {
if (isLoggedIn === false) {
// redirection loses the connection to the manager
// prevent it passing an async callback id
popupWindow.sendCallbackId(callbackId, () => {
popupWindow.navigateToAuthentication()
})
popupWindow.navigateToAuthentication()
}
}, [isLoggedIn, callbackId])
}, [isLoggedIn])
const handleCreate = async () => {
setIsCreating(true)
try {
const slug = generateRoomId()
const roomData = await createRoom({
slug,
encryptionMode: ApiEncryptionMode.NONE,
})
popupWindow.sendRoomData({ slug: roomData.slug }, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
setIsCreating(false)
}
}
/**
* Automatically create meeting room once user is authenticated
* This effect will trigger either immediately if the user is already logged in,
* or after successful authentication and return to this popup
*/
useEffect(() => {
const createMeetingRoom = async () => {
try {
const slug = generateRoomId()
const roomData = await createRoom({
slug,
callbackId,
})
// Send room data back to parent window and clean up resources
popupWindow.sendRoomData(roomData, () => {
callbackIdHandler.clear()
popupWindow.close()
})
} catch (error) {
console.error('Failed to create meeting room:', error)
}
if (isLoggedIn && !isCreating && callbackId) {
void handleCreate()
}
if (isLoggedIn && callbackId) {
createMeetingRoom()
}
}, [isLoggedIn, callbackId, createRoom])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn])
if (!isLoggedIn || isCreating) {
return (
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
width: '100%',
})}
>
<Spinner />
</div>
)
}
return (
<div
@@ -68,9 +81,26 @@ export const CreatePopup = () => {
alignItems: 'center',
height: '100%',
width: '100%',
padding: '2rem',
})}
>
<Spinner />
<VStack gap="0.75rem" alignItems="stretch" maxWidth="22rem" width="100%">
<Text
variant="sm"
bold
className={css({
textAlign: 'center',
fontSize: '1.1rem',
marginBottom: '0.5rem',
})}
>
{t('title')}
</Text>
<Button variant="primary" fullWidth onPress={handleCreate}>
<RiVideoOnLine size={18} />
{t('create')}
</Button>
</VStack>
</div>
)
}
@@ -56,16 +56,21 @@ export class PopupManager {
case PopupMessageType.CALLBACK_ID:
onCallbackId(data.callbackId as string)
return
case PopupMessageType.ROOM_DATA:
case PopupMessageType.ROOM_DATA: {
if (!data?.room) return
onRoomData(data.room)
const baseUrl = getRouteUrl('room', data.room.slug)
const roomUrl = data.room.hash
? `${baseUrl}#${data.room.hash}`
: baseUrl
this.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
url: roomUrl,
...data.room,
},
})
return
}
}
}
window.addEventListener('message', this.messageHandler)
@@ -27,7 +27,7 @@ export class PopupWindow {
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
this.sendMessageToManager(
PopupMessageType.ROOM_DATA,
{ room: { slug: data.slug } },
{ room: { slug: data.slug, hash: data.hash } },
callback
)
}
@@ -1,10 +1,12 @@
export type CallbackCreationRoomData = {
slug: string
hash?: string
}
export enum ClientMessageType {
ROOM_CREATED = 'ROOM_CREATED',
STATE_CLEAR = 'STATE_CLEAR',
RESIZE = 'RESIZE',
}
export interface PopupMessageData {
@@ -0,0 +1,95 @@
/**
* "Encrypt new meetings by default" toggle used both in the in-meeting
* Security tab and the home-page SettingsDialog. Flipping it directly
* patches the user preference the per-meeting CreateEncryptedMeeting
* dialog already surfaces the disabled-features warning when needed.
*/
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { A, Field, Text } from '@/primitives'
import { useUser } from '@/features/auth'
import { updateUserPreferences } from '@/features/auth/api/updateUserPreferences'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const EncryptionDefaultField = () => {
const { t } = useTranslation('settings', { keyPrefix: 'security' })
const { user, isLoggedIn } = useUser()
const { data: config } = useConfig()
const isFeatureEnabled = !!config?.encryption?.enabled
const { mutateAsync, isPending } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
const isOn = user?.default_encryption_mode === ApiEncryptionMode.BASIC
const handleToggle = async (next: boolean) => {
if (!user) return
try {
await mutateAsync({
user: {
id: user.id,
default_encryption_mode: next
? ApiEncryptionMode.BASIC
: ApiEncryptionMode.NONE,
},
})
} catch {
// react-query stores the error on the mutation; the UI shows
// isPending while in flight and reverts the toggle on failure.
}
}
if (!isLoggedIn) {
return (
<>
<Text variant="note" margin={false}>
{t('signInRequired')}
</Text>
<LoginButton />
</>
)
}
if (!isFeatureEnabled) {
return (
<Text variant="note" margin={false}>
{t('featureDisabled')}
</Text>
)
}
return (
<Field
type="switch"
label={t('encryption.label')}
description={
<>
{t('encryption.description')}{' '}
{config?.support?.help_article_encryption && (
<A
href={config.support.help_article_encryption}
target="_blank"
rel="noopener noreferrer"
externalIcon
color="note"
>
{t('encryption.learnMore')}
</A>
)}
</>
}
isSelected={isOn}
isDisabled={isPending}
onChange={handleToggle}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
)
}

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