Compare commits

..

6 Commits

Author SHA1 Message Date
lebaudantoine d59a352181 ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
Copy the MediaPipe WASM modules into the frontend build output and
serve them locally, instead of loading them from the Google CDN at
runtime.

Mediapipe was a transitive dependency, add it explicitly.

Requested by some members of the community.
It closes #1168
2026-07-11 15:51:24 +02:00
lebaudantoine 09055684cb 🎨(frontend) format vite config file
Apply the project's formatter to the Vite config file to align its
style with the rest of the codebase.
2026-07-11 15:51:24 +02:00
lebaudantoine f5c7e0f6ec (frontend) install vite-plugin-static-copy for MediaPipe WASM assets
Add vite-plugin-static-copy as a dependency. It will be used to copy
and serve the WASM modules shipped by the MediaPipe JS dependency
from the frontend build output.
2026-07-11 15:51:24 +02:00
lebaudantoine 08288e1a46 ♻️(frontend) reorganize JS packages in package.json
Sort and regroup the entries in package.json that were
not in the expected order.
2026-07-11 15:51:23 +02:00
lebaudantoine a97d47096e ♻️(frontend) inline model weights to avoid loading them from remote
Inline the model weights instead of fetching them from a Google
remote location at runtime. The weights do not update frequently, so
inlining removes an external dependency.
2026-07-11 15:51:11 +02:00
lebaudantoine fbaade1a08 ♻️(frontend) refactor background processors to use the new API
Update the calls to the background processors imported from
livekit-track-processor so they use the new instantiation method
instead of the previous, now-deprecated one.
2026-07-11 15:45:35 +02:00
14 changed files with 146 additions and 380 deletions
-6
View File
@@ -23,17 +23,11 @@ and this project adheres to
- ♻️(frontend) refactor background processors to use the new API
- ♻️(frontend) inline model weights to avoid loading them from remote
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
- 🐛(backend) fix info panel crash for unregistered rooms
- ♿️(frontend) focus side panel container on open #1452
- 🩹(backend) handle failed and aborted egresses
## [1.23.0] - 2026-07-08
+1 -1
View File
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:22 AS mail-builder
FROM node:20 AS mail-builder
COPY ./src/mail /mail/app
+1 -8
View File
@@ -57,7 +57,6 @@ class RecordingStatusChoices(models.TextChoices):
STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted")
FAILED = "failed", _("Failed")
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -81,7 +80,6 @@ class RecordingStatusChoices(models.TextChoices):
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -89,12 +87,7 @@ class RecordingStatusChoices(models.TextChoices):
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {
cls.ABORTED,
cls.FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class RecordingModeChoices(models.TextChoices):
@@ -26,16 +26,14 @@ class RecordingEventsService:
@staticmethod
def handle_update(recording: Recording, egress_status):
"""Handle egress update and sync recording state to room metadata.
Egress updates are sent for statuses EGRESS_ACTIVE and EGRESS_ENDING.
"""
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
@@ -61,13 +59,6 @@ class RecordingEventsService:
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
logger.warning(
"Could not find notification type for: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
return
try:
@@ -88,18 +79,6 @@ class RecordingEventsService:
f"recording limit reached (recording_id={recording.id})"
) from e
@staticmethod
def handle_failed(recording: Recording):
"""Set recording status to failed, matching egress status."""
recording.status = models.RecordingStatusChoices.FAILED
recording.save()
@staticmethod
def handle_aborted(recording: Recording):
"""Set recording status to aborted, matching egress status."""
recording.status = models.RecordingStatusChoices.ABORTED
recording.save()
@staticmethod
def handle_complete(recording: Recording):
"""Notify external services and save recording."""
+3 -15
View File
@@ -200,21 +200,7 @@ class LiveKitEventsService:
f"Failed to process limit reached event for recording {recording}"
) from e
if (
data.egress_info.status == api.EgressStatus.EGRESS_ABORTED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
return self.recording_events.handle_aborted(recording)
if (
data.egress_info.status == api.EgressStatus.EGRESS_FAILED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
return self.recording_events.handle_failed(recording)
# Fallback for completion when no MinIO/S3 webhooks are configured.
# If RECORDING_STORAGE_EVENT_ENABLE is True, completion is handled
# by ``on_storage_event_received``.
# Fallback for completion when no MinIO/S3 webhooks are configured
if (
not settings.RECORDING_STORAGE_EVENT_ENABLE
) and data.egress_info.status in [
@@ -230,6 +216,8 @@ class LiveKitEventsService:
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -9,7 +9,6 @@ from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.models import RecordingStatusChoices
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -71,22 +70,3 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("handler_name", "expected_status"),
(
("handle_aborted", RecordingStatusChoices.ABORTED),
("handle_failed", RecordingStatusChoices.FAILED),
),
)
def test_handle_unsuccessful_egress(handler_name, expected_status, service):
"""Test unsuccessful egress handlers persist a final, unsuccessful status."""
recording = RecordingFactory(status="active")
getattr(service, handler_name)(recording)
recording.refresh_from_db()
assert recording.status == expected_status
assert RecordingStatusChoices.is_final(recording.status)
assert RecordingStatusChoices.is_unsuccessful(recording.status)
@@ -101,6 +101,7 @@ def test_handle_egress_ended_success(
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
@@ -125,7 +126,6 @@ def test_handle_egress_updated_success(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@@ -420,6 +420,8 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@@ -440,47 +442,6 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
assert recording.status == "active"
@pytest.mark.parametrize(
("egress_status", "expected_status"),
(
(EgressStatus.EGRESS_ABORTED, "aborted"),
(EgressStatus.EGRESS_FAILED, "failed"),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_marks_unsuccessful_egress( # noqa: PLR0913
mock_update_room_metadata,
mock_notify_external_services,
egress_status,
expected_status,
service,
settings,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""An aborted/failed egress moves an active recording to its terminal
unsuccessful status, and never notifies external services."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_ended(mock_data)
mock_notify_external_services.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@@ -160,7 +160,6 @@ def test_models_recording_is_savable_normal():
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
RecordingStatusChoices.FAILED,
],
)
def test_models_recording_is_savable_error(status):
@@ -283,7 +282,6 @@ def test_models_recording_is_saved_false_initiated():
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
RecordingStatusChoices.FAILED,
],
)
def test_models_recording_is_saved_false_error_states(status):
+35 -99
View File
@@ -30,13 +30,13 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.2",
"posthog-js": "1.391.2",
"posthog-js": "1.387.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -112,62 +112,6 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.49.0",
"react-stately": "3.47.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1810,9 +1754,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -9130,19 +9074,19 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.391.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
"version": "1.387.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.387.0.tgz",
"integrity": "sha512-Pv1jUMySMN62zoAxdJBJPV8n62lkHdjuWhpeU7izczc5Dqbx3hhqO2hkrNTI8Yx1ezmWk2qUHZs03FuOBubdFQ==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "^1.35.3",
"@posthog/types": "^1.390.2",
"@posthog/core": "^1.33.0",
"@posthog/types": "^1.387.0",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
"preact": "^10.29.2",
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0"
"web-vitals": "^5.1.0"
}
},
"node_modules/powershell-utils": {
@@ -9159,21 +9103,13 @@
}
},
"node_modules/preact": {
"version": "10.29.7",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
"version": "10.28.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
},
"node_modules/prelude-ls": {
@@ -9380,19 +9316,19 @@
}
},
"node_modules/react-aria": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -9401,17 +9337,17 @@
}
},
"node_modules/react-aria-components": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.50.0",
"react-stately": "3.48.0"
"react-aria": "3.49.0",
"react-stately": "3.47.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -9465,15 +9401,15 @@
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.9",
"@react-types/shared": "^3.36.0",
"@react-types/shared": "^3.35.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
@@ -11649,9 +11585,9 @@
}
},
"node_modules/web-vitals": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
+4 -4
View File
@@ -37,13 +37,13 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.2",
"posthog-js": "1.391.2",
"posthog-js": "1.387.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -6,8 +6,8 @@ import { Button, Div } from '@/primitives'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
import React, { ReactNode, useCallback, useRef } from 'react'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
@@ -15,7 +15,6 @@ import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
type StyledSidePanelProps = {
title: string
@@ -30,115 +29,103 @@ type StyledSidePanelProps = {
isReactionToolbarOpen?: boolean
}
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
(
{
title,
ariaLabel,
children,
onClose,
isClosed,
isReactionToolbarOpen,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
},
ref
) => (
<aside
ref={ref}
tabIndex={-1}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'box.border',
backgroundColor: 'box.bg',
color: 'box.text',
borderRadius: 8,
flex: 1,
position: 'absolute',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: 'var(--sizes-room-side-panel-margin)',
marginLeft: 0,
marginBottom: 0,
padding: 0,
gap: 0,
right: 0,
top: 0,
width: 'var(--sizes-room-side-panel)',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
'&:focus': {
outline: 'none',
},
})}
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)',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
isReactionToolbarOpen,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
className={css({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'box.border',
backgroundColor: 'box.bg',
color: 'box.text',
borderRadius: 8,
flex: 1,
position: 'absolute',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: 'var(--sizes-room-side-panel-margin)',
marginLeft: 0,
marginBottom: 0,
padding: 0,
gap: 0,
right: 0,
top: 0,
width: 'var(--sizes-room-side-panel)',
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)',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
right="5"
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
display: isClosed ? 'none' : undefined,
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
<Button
invisible
variant="tertiaryText"
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</aside>
)
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
right="5"
style={{
display: isClosed ? 'none' : undefined,
}}
>
<Button
invisible
variant="tertiaryText"
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
{children}
</aside>
)
StyledSidePanel.displayName = 'StyledSidePanel'
type PanelProps = {
isOpen: boolean
children: React.ReactNode
@@ -175,28 +162,8 @@ export const SidePanel = () => {
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
const asideRef = useRef<HTMLElement>(null)
const focusAside = useCallback(() => {
requestAnimationFrame(() => {
asideRef.current?.focus({ preventScroll: true })
})
}, [])
const handlePanelOpened = useCallback(() => {
if (activePanelId === PanelId.CHAT) return
focusAside()
}, [activePanelId, focusAside])
useRestoreFocus(isSidePanelOpen, {
onOpened: handlePanelOpened,
preventScroll: true,
activeKey: activePanelId,
})
return (
<StyledSidePanel
ref={asideRef}
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
@@ -95,13 +95,8 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const {
openTranscript,
openScreenRecording,
activeSubPanelId,
isToolsOpen,
isSidePanelOpen,
} = useSidePanel()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
@@ -118,7 +113,6 @@ export const Tools = () => {
},
restoreFocusRaf: true,
preventScroll: true,
shouldRestoreOnClose: () => !isSidePanelOpen,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
@@ -37,7 +37,7 @@ export function Chat({ ...props }: ChatProps) {
const room = useRoomContext()
const { send, chatMessages, isSending } = useChat()
const { isChatOpen, isSidePanelOpen } = useSidePanel()
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus
@@ -51,7 +51,6 @@ export function Chat({ ...props }: ChatProps) {
})
},
preventScroll: true,
shouldRestoreOnClose: () => !isSidePanelOpen,
})
// Use useParticipants hook to trigger a re-render when the participant list changes.
+1 -24
View File
@@ -6,10 +6,6 @@ export type RestoreFocusOptions = {
onClosed?: () => void
restoreFocusRaf?: boolean
preventScroll?: boolean
/** When the panel stays open but its content changes, update the restore target. */
activeKey?: string | null
/** Return false to skip restoring focus on close (e.g. when switching to another panel). */
shouldRestoreOnClose?: () => boolean
}
/**
@@ -26,12 +22,9 @@ export function useRestoreFocus(
onClosed,
restoreFocusRaf = false,
preventScroll = true,
activeKey,
shouldRestoreOnClose,
} = options
const prevIsOpenRef = useRef(false)
const prevActiveKeyRef = useRef(activeKey)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
@@ -44,41 +37,25 @@ export function useRestoreFocus(
onOpened?.()
}
// Panel switched while staying open
if (wasOpen && isOpen && activeKey !== prevActiveKeyRef.current) {
const activeEl = document.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
onOpened?.()
}
// Just closed
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
const shouldRestore =
(shouldRestoreOnClose?.() ?? true) &&
trigger &&
document.contains(trigger)
if (shouldRestore) {
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
prevActiveKeyRef.current = activeKey
}, [
isOpen,
activeKey,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
shouldRestoreOnClose,
])
}