mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fed231b8 | |||
| 72af826402 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 |
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
|
||||
@@ -10,6 +10,32 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||
- ♻️(frontend) refactor background processors to use the new API
|
||||
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||
- ♿️(frontend) close side panel with Escape key #1507
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) identify externally provisioned users to PostHog
|
||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||
- ♿️(frontend) focus side panel container on open #1452
|
||||
|
||||
## [1.23.0] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) extend analytics module to support feature flags
|
||||
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||
@@ -23,6 +49,12 @@ and this project adheres to
|
||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||
- 🔥(summary) remove call to summary enabled feature flag
|
||||
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||
- ⬆️(frontend) update the frontend build image to Node 22
|
||||
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||
- ✨(summary) more precise analytics events
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -42,6 +74,7 @@ and this project adheres to
|
||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||
- 🩹(frontend) enable screen share button in PiP #1458
|
||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.23.0
|
||||
|
||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||
|
||||
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||
|
||||
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||
|
||||
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
||||
# Update agents pyproject.toml
|
||||
update_python_version "agents"
|
||||
|
||||
# Run uv lock in agents
|
||||
print_info "Running uv lock in agents..."
|
||||
cd "src/agents"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update CHANGELOG
|
||||
print_info "Updating CHANGELOG..."
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---- Front-end image ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.4",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
search_fields = [
|
||||
"status",
|
||||
"=id",
|
||||
"worker_id",
|
||||
"room__slug",
|
||||
"=room__id",
|
||||
"accesses__user__email",
|
||||
]
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
|
||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -92,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..models import RoomAccessLevel
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -267,6 +268,9 @@ class RoomViewSet(
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
|
||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
|
||||
@@ -2,13 +2,109 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
from core.factories import UserFactory
|
||||
from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode a LiveKit JWT access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_uses_full_name():
|
||||
"""The token's display name should default to the user's full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
assert claims["sub"] == str(user.sub)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_fallback_user_representation():
|
||||
"""
|
||||
When the user has no full name, the token's display name should fall back
|
||||
to the user's string representation.
|
||||
"""
|
||||
user = UserFactory(full_name=None)
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||
"""Disabling editing doesn't disturb the default full-name path."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_anonymous_uses_username_when_provided():
|
||||
"""An anonymous user's provided username is used as the display name."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_falls_back_to_anonymous_label():
|
||||
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser())
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
|
||||
@@ -109,11 +109,16 @@ def generate_token(
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
default_username = user.full_name or str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
can_edit = (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||
)
|
||||
display_name = (username or default_username) if can_edit else default_username
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -121,7 +126,7 @@ def generate_token(
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
@@ -670,6 +671,11 @@ class Base(Configuration):
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||
True,
|
||||
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
@@ -1128,6 +1134,20 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
):
|
||||
warnings.warn(
|
||||
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||
"The legacy v1 API has been removed from the experimental "
|
||||
"Summary service. Please update your Summary service deployment to "
|
||||
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
USER node
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
Generated
+151
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,10 +15,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -28,8 +29,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.386.5",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-aria-components": "1.18.0",
|
||||
@@ -63,6 +64,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
},
|
||||
@@ -2357,9 +2359,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2378,12 +2380,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.14"
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3192,6 +3194,19 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||
@@ -7094,6 +7109,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-boolean-object": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||
@@ -8035,9 +8063,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
@@ -8675,6 +8703,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||
@@ -9033,19 +9074,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.386.5",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
|
||||
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
|
||||
"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==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.32.3",
|
||||
"@posthog/types": "^1.386.3",
|
||||
"@posthog/core": "^1.35.3",
|
||||
"@posthog/types": "^1.390.2",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.28.2",
|
||||
"preact": "^10.29.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
"web-vitals": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
@@ -9062,13 +9103,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||
"version": "10.29.7",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||
"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": {
|
||||
@@ -11137,6 +11186,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sapphi-red"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-svgr": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||
@@ -11470,9 +11593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -17,15 +17,16 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -35,8 +36,8 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.386.5",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-aria-components": "1.18.0",
|
||||
@@ -70,6 +71,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,7 @@ export interface ApiConfig {
|
||||
transcription_destination?: string
|
||||
max_participants_for_sound: number
|
||||
auto_mute_on_join_threshold: number
|
||||
authenticated_users_can_edit_display_name: boolean
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { loadUserChoices } from '@livekit/components-core'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = loadUserChoices()
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1rem',
|
||||
bottom: '1rem',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
||||
<GridContainer ref={containerRef} style={gridStyle}>
|
||||
{tracks.map((track, index) => (
|
||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||
<ParticipantTile trackRef={track} />
|
||||
<ParticipantTile trackRef={track} disableTileControls />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
gap: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
screenShareTrack: TrackReferenceOrPlaceholder
|
||||
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout when a screen share is active.
|
||||
* Camera tiles are shown as a compact row at the top; the screen share occupies
|
||||
* the remaining space below, much larger than the camera tiles.
|
||||
*/
|
||||
export const PipScreenShareLayout = memo(
|
||||
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
|
||||
return (
|
||||
<LayoutContainer>
|
||||
{cameraTracks.length > 0 && (
|
||||
<CameraTilesRow>
|
||||
{cameraTracks.map((track) => (
|
||||
<CameraTile key={getTrackKey(track)}>
|
||||
<ParticipantTile trackRef={track} disableTileControls />
|
||||
</CameraTile>
|
||||
))}
|
||||
</CameraTilesRow>
|
||||
)}
|
||||
<ScreenShareSlot>
|
||||
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
|
||||
</ScreenShareSlot>
|
||||
</LayoutContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
|
||||
|
||||
const LayoutContainer = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const CameraTilesRow = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
flexShrink: 0,
|
||||
height: '22%',
|
||||
minHeight: '60px',
|
||||
maxHeight: '120px',
|
||||
},
|
||||
})
|
||||
|
||||
const CameraTile = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: '0 1 auto',
|
||||
height: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
minWidth: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ScreenShareSlot = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { usePagination, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
import { PipPagination } from './PipPagination'
|
||||
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||
import { StageFrame } from './StageFrame'
|
||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||
import {
|
||||
@@ -13,9 +14,12 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
|
||||
/**
|
||||
* PipStage picks between two layouts based on track count:
|
||||
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
||||
* - Grid mode (3+ tracks): adaptive tiling.
|
||||
* PipStage picks between three layouts:
|
||||
* - Screen share mode (any screen share active):
|
||||
* small camera tiles in a row at the top, large screen share below.
|
||||
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
|
||||
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
|
||||
* + one thumbnail overlay.
|
||||
*/
|
||||
export const PipStage = () => {
|
||||
const tracks = useTracks(
|
||||
@@ -41,51 +45,55 @@ export const PipStage = () => {
|
||||
[tracks]
|
||||
)
|
||||
|
||||
// Grid mode order: screen share leads, then cameras (already ordered by
|
||||
// active speaker via the `ActiveSpeakersChanged` update above).
|
||||
const gridTracks = useMemo(
|
||||
() =>
|
||||
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
||||
[screenShareTrack, cameraTracks]
|
||||
)
|
||||
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||
|
||||
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
||||
// page visually stable (active/recent speakers stay put) via its internal
|
||||
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
|
||||
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
|
||||
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
|
||||
/**
|
||||
* The focus layout shows one main track with one thumbnail overlay,
|
||||
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
||||
*/
|
||||
if (gridTracks.length > 2) {
|
||||
return (
|
||||
<StageWrapper>
|
||||
// Screen share active
|
||||
if (screenShareTrack) {
|
||||
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||
if (cameraTracks.length <= 1) {
|
||||
return (
|
||||
<StageFrame>
|
||||
<PipGridLayout tracks={pagination.tracks} />
|
||||
<PipFocusLayout
|
||||
mainTrack={screenShareTrack}
|
||||
thumbnailTrack={cameraTracks[0]}
|
||||
/>
|
||||
</StageFrame>
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
)
|
||||
}
|
||||
// Multiple cameras: camera row at top, screen share below
|
||||
return (
|
||||
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||
<PipScreenShareLayout
|
||||
screenShareTrack={screenShareTrack}
|
||||
cameraTracks={paginatedCameraTracks.tracks}
|
||||
/>
|
||||
</StageWrapper>
|
||||
</PaginatedStage>
|
||||
)
|
||||
}
|
||||
|
||||
// 3+ camera tracks → adaptive grid
|
||||
if (cameraTracks.length > 2) {
|
||||
return (
|
||||
<PaginatedStage pagination={pagination}>
|
||||
<PipGridLayout tracks={pagination.tracks} />
|
||||
</PaginatedStage>
|
||||
)
|
||||
}
|
||||
|
||||
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
|
||||
const localCameraTrack = cameraTracks.find(
|
||||
(track) => track.participant?.isLocal
|
||||
)
|
||||
|
||||
const remoteCameraTrack = cameraTracks.find(
|
||||
(track) => !track.participant?.isLocal
|
||||
)
|
||||
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
||||
|
||||
const mainTrack = remoteCameraTrack ?? localCameraTrack
|
||||
const thumbnailTrack =
|
||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||
|
||||
@@ -96,6 +104,31 @@ export const PipStage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
type PaginationResult = {
|
||||
totalPageCount: number
|
||||
currentPage: number
|
||||
nextPage: () => void
|
||||
prevPage: () => void
|
||||
}
|
||||
|
||||
const PaginatedStage = ({
|
||||
pagination,
|
||||
children,
|
||||
}: {
|
||||
pagination: PaginationResult
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<StageWrapper>
|
||||
<StageFrame>{children}</StageFrame>
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
/>
|
||||
</StageWrapper>
|
||||
)
|
||||
|
||||
const StageWrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -25,8 +25,8 @@ const Container = styled('div', {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
marginLeft: '0.5rem',
|
||||
marginRight: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
pin_code: string
|
||||
pin_code?: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
|
||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId, posthog])
|
||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
roomId: roomId as string,
|
||||
username: userConfig.username,
|
||||
username: username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username: userConfig.username })
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
}),
|
||||
retry: false,
|
||||
|
||||
@@ -42,14 +42,17 @@ import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
saveUsername,
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
userChoicesStore,
|
||||
} from '@/stores/userChoices'
|
||||
|
||||
import { saveUsername, userStore } from '@/stores/user'
|
||||
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -114,6 +117,9 @@ export const Join = ({
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { data: configData } = useConfig()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
|
||||
const {
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
@@ -121,9 +127,10 @@ export const Join = ({
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
username,
|
||||
} = useSnapshot(userChoicesStore)
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
|
||||
if (initialUserChoices.current === null) {
|
||||
@@ -134,7 +141,6 @@ export const Join = ({
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,20 +454,23 @@ 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}
|
||||
/>
|
||||
{(!isLoggedIn ||
|
||||
configData?.authenticated_users_can_edit_display_name) && (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useIsSpeaking } from '@livekit/components-react'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useMemo, useRef } from 'react'
|
||||
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
|
||||
}: ParticipantPlaceholderProps) => {
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const participantColor = getParticipantColor(participant)
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(participantColor),
|
||||
[participantColor]
|
||||
)
|
||||
|
||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = useSize(placeholderEl)
|
||||
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
|
||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
||||
<StyledParticipantPlaceHolder
|
||||
ref={placeholderEl}
|
||||
style={{
|
||||
backgroundColor: participantColor,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
|
||||
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
|
||||
|
||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableMetadata?: boolean
|
||||
disableTileControls?: boolean
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
@@ -66,6 +67,7 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
disableMetadata,
|
||||
disableTileControls,
|
||||
...htmlProps
|
||||
}: ParticipantTileExtendedProps,
|
||||
ref
|
||||
@@ -231,7 +233,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
|
||||
@@ -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 { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
@@ -15,6 +15,9 @@ 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'
|
||||
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -23,109 +26,127 @@ type StyledSidePanelProps = {
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
escapeHint: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
isReactionToolbarOpen?: boolean
|
||||
}
|
||||
|
||||
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}
|
||||
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
isReactionToolbarOpen,
|
||||
closeButtonTooltip,
|
||||
escapeHint,
|
||||
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}
|
||||
aria-describedby="side-panel-escape-hint"
|
||||
>
|
||||
<span id="side-panel-escape-hint" className={srOnly}>
|
||||
{escapeHint}
|
||||
</span>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<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
|
||||
@@ -162,17 +183,42 @@ 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])
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}, [])
|
||||
|
||||
useRestoreFocus(isSidePanelOpen, {
|
||||
onOpened: handlePanelOpened,
|
||||
preventScroll: true,
|
||||
activeKey: activePanelId,
|
||||
})
|
||||
|
||||
useEscapeToClose(isSidePanelOpen, asideRef, closePanel)
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
ref={asideRef}
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
escapeHint={t('escapeHint')}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
|
||||
@@ -95,8 +95,13 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const {
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
activeSubPanelId,
|
||||
isToolsOpen,
|
||||
isSidePanelOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
@@ -113,6 +118,7 @@ export const Tools = () => {
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||
})
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
|
||||
+4
-5
@@ -13,8 +13,10 @@ import {
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
SELFIE_SEGMENTER_MODEL_PATH,
|
||||
type ProcessorConfig,
|
||||
type ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
@@ -153,13 +155,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
|
||||
async initSegmenter() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
delegate: 'CPU', // Use CPU for Firefox.
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { ProcessorType } from '.'
|
||||
import {
|
||||
FACE_LANDMARKS_MODEL_PATH,
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
@@ -128,13 +132,10 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
}
|
||||
|
||||
async initFaceLandmarker() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
||||
modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
|
||||
delegate: 'GPU',
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
|
||||
+19
-4
@@ -1,13 +1,14 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
BackgroundProcessor,
|
||||
} from '@livekit/track-processors'
|
||||
import {
|
||||
type ProcessorConfig,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
SELFIE_SEGMENTER_MODEL_PATH,
|
||||
} from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
@@ -20,10 +21,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
|
||||
if (opts.type === 'virtual') {
|
||||
this.processorType = ProcessorType.VIRTUAL
|
||||
this.processor = VirtualBackground(opts.imagePath)
|
||||
this.processor = BackgroundProcessor({
|
||||
mode: 'virtual-background',
|
||||
imagePath: opts.imagePath,
|
||||
assetPaths: {
|
||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
},
|
||||
})
|
||||
} else if (opts.type === 'blur') {
|
||||
this.processorType = ProcessorType.BLUR
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
this.processor = BackgroundProcessor({
|
||||
mode: 'background-blur',
|
||||
blurRadius: opts.blurRadius,
|
||||
assetPaths: {
|
||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
throw new Error(
|
||||
'Must provide either imagePath for virtual background or blurRadius for blur'
|
||||
|
||||
@@ -4,6 +4,14 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||
|
||||
export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
|
||||
|
||||
export const FACE_LANDMARKS_MODEL_PATH =
|
||||
'/assets/mediapipe/models/face_landmarker.task'
|
||||
|
||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
VIRTUAL = 'virtual',
|
||||
|
||||
@@ -35,6 +35,11 @@ export const ChatInput = ({
|
||||
if (!isDisabled) handleSubmit()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Escape') e.stopPropagation()
|
||||
submitOnEnter(e)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const resize = () => {
|
||||
if (!inputRef.current) return
|
||||
@@ -79,10 +84,7 @@ export const ChatInput = ({
|
||||
>
|
||||
<TextArea
|
||||
ref={inputRef}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
submitOnEnter(e)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
placeholder={t('textArea.placeholder')}
|
||||
value={text}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Chat({ ...props }: ChatProps) {
|
||||
const room = useRoomContext()
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
|
||||
const { isChatOpen } = useSidePanel()
|
||||
const { isChatOpen, isSidePanelOpen } = useSidePanel()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
@@ -51,6 +51,7 @@ export function Chat({ ...props }: ChatProps) {
|
||||
})
|
||||
},
|
||||
preventScroll: true,
|
||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||
})
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Builds a radial gradient from the participant's avatar color:
|
||||
* brighter in the center (where the avatar sits), darker at the edges.
|
||||
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
|
||||
*/
|
||||
export const getParticipantBackgroundGradient = (color: string): string =>
|
||||
`radial-gradient(circle at 50% 45%,
|
||||
color-mix(in oklch, ${color} 82%, white) 0%,
|
||||
color-mix(in oklch, ${color} 90%, white) 8%,
|
||||
${color} 35%,
|
||||
color-mix(in oklch, ${color} 85%, black) 65%,
|
||||
color-mix(in oklch, ${color} 65%, black) 100%)`
|
||||
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { useState } from 'react'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
||||
import { saveUsername } from '@/stores/userChoices'
|
||||
import { saveUsername } from '@/stores/user'
|
||||
import { logout } from '@/features/auth/utils/logout'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const room = useRoomContext()
|
||||
const { data } = useConfig()
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
const { renameParticipant } = useRenameParticipant()
|
||||
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
{(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<H lvl={2}>{t('account.authentication')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
export const useEscapeToClose = (
|
||||
isActive: boolean,
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
onClose: () => void
|
||||
) => {
|
||||
const onCloseRef = useRef(onClose)
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
if (!containerRef.current?.contains(document.activeElement)) return
|
||||
e.stopPropagation()
|
||||
onCloseRef.current()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isActive, containerRef])
|
||||
}
|
||||
@@ -6,6 +6,10 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,9 +26,12 @@ 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(() => {
|
||||
@@ -37,25 +44,41 @@ 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
|
||||
if (trigger && document.contains(trigger)) {
|
||||
const shouldRestore =
|
||||
(shouldRestoreOnClose?.() ?? true) &&
|
||||
trigger &&
|
||||
document.contains(trigger)
|
||||
|
||||
if (shouldRestore) {
|
||||
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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -361,7 +361,8 @@
|
||||
"tools": "Weitere Tools",
|
||||
"info": "Meeting-Informationen"
|
||||
},
|
||||
"closeButton": "{{content}} ausblenden"
|
||||
"closeButton": "{{content}} ausblenden (Escape)",
|
||||
"escapeHint": "Escape drücken zum Schließen"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
||||
|
||||
@@ -360,7 +360,8 @@
|
||||
"tools": "more tools",
|
||||
"info": "meeting information"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
"closeButton": "Hide {{content}} (Escape)",
|
||||
"escapeHint": "Press Escape to close"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
|
||||
|
||||
@@ -360,7 +360,8 @@
|
||||
"tools": "outils de réunion",
|
||||
"info": "informations sur la réunion"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
"closeButton": "Masquer {{content}} (Échap)",
|
||||
"escapeHint": "Appuyez sur Échap pour fermer"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel.",
|
||||
|
||||
@@ -360,7 +360,8 @@
|
||||
"tools": "meer tools",
|
||||
"info": "vergaderinformatie"
|
||||
},
|
||||
"closeButton": "Verberg {{content}}"
|
||||
"closeButton": "Verberg {{content}} (Escape)",
|
||||
"escapeHint": "Druk op Escape om te sluiten"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek.",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
|
||||
type State = {
|
||||
username: string
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
username: '',
|
||||
}
|
||||
|
||||
function getUserState(): State {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.USER)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored)
|
||||
return {
|
||||
...parsed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[UserPreferencesStore] Failed to parse stored settings:',
|
||||
error
|
||||
)
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const userStore = proxy<State>(getUserState())
|
||||
|
||||
subscribe(userStore, () => {
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
|
||||
})
|
||||
|
||||
export const saveUsername = (username: string) => {
|
||||
userStore.username = username
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
|
||||
|
||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||
processorConfig?: ProcessorConfig
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
|
||||
|
||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||
subscribe(userChoicesStore, () => {
|
||||
saveUserChoices(userChoicesStore, false)
|
||||
// TEMPORARY: cast needed because our store omits `username`, which we no
|
||||
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
|
||||
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
|
||||
// object, which `saveUserChoices` tolerates at runtime.
|
||||
// We are migrating away from LiveKit's persistence logic to our own store
|
||||
// handling for more control — this cast can be removed once that lands.
|
||||
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
|
||||
})
|
||||
|
||||
// we run some logic on store loading to check if the processor config is still valid
|
||||
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
|
||||
userChoicesStore.videoSubscribeQuality = quality
|
||||
}
|
||||
|
||||
export const saveUsername = (username: string) => {
|
||||
userChoicesStore.username = username
|
||||
}
|
||||
|
||||
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
||||
userChoicesStore.noiseReductionEnabled = enabled
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
export const STORAGE_KEYS = {
|
||||
NOTIFICATIONS: 'app_notification_settings',
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
USER: 'app_user',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
} as const
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -11,18 +12,32 @@ export default defineConfig(({ mode }) => {
|
||||
react(),
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
replaceAttrValues: { '#000': 'currentColor', '#000000': 'currentColor', '#1f1f1f': 'currentColor' },
|
||||
replaceAttrValues: {
|
||||
'#000': 'currentColor',
|
||||
'#000000': 'currentColor',
|
||||
'#1f1f1f': 'currentColor',
|
||||
},
|
||||
},
|
||||
}),
|
||||
env.VITE_ANALYZE === 'true' && visualizer({
|
||||
open: true,
|
||||
filename: 'rollup-plugin-visualizer/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
||||
dest: 'assets/mediapipe/wasm',
|
||||
rename: { stripBase: 4 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
env.VITE_ANALYZE === 'true' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'rollup-plugin-visualizer/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
tsconfigPaths: true
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||
|
||||
@@ -191,6 +191,8 @@ backend:
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
|
||||
OIDC_USERINFO_SHORTNAME_FIELD: first_name
|
||||
|
||||
migrate:
|
||||
command:
|
||||
|
||||
@@ -6,4 +6,4 @@ DIR_MAILS="../backend/core/templates/mail/html/"
|
||||
if [ ! -d "${DIR_MAILS}" ]; then
|
||||
mkdir -p "${DIR_MAILS}";
|
||||
fi
|
||||
mjml mjml/*.mjml -o "${DIR_MAILS}";
|
||||
mjml mjml/*.mjml -o "${DIR_MAILS}" --config.allowIncludes true;
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<!--
|
||||
We load django tags here so they appear in the body of the HTML output.
|
||||
This ensures html-to-text also includes them in the plain text template.
|
||||
-->
|
||||
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
@@ -59,7 +64,7 @@
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
|
||||
<mj-include path="./partial/footer.mjml" />
|
||||
<mj-include path="./partial/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
<mj-head>
|
||||
<mj-title>{{ title }}</mj-title>
|
||||
<mj-preview>
|
||||
<!--
|
||||
We load django tags here, in this way there are put within the body in html output
|
||||
so the html-to-text command includes it within its output
|
||||
-->
|
||||
{% load i18n static extra_tags %}
|
||||
{{ title }}
|
||||
</mj-preview>
|
||||
<mj-preview>{{ title }}</mj-preview>
|
||||
<mj-font name="Roboto" href="https://fonts.bunny.net/css?family=roboto:400,700,900" />
|
||||
<mj-attributes>
|
||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
|
||||
<mj-all
|
||||
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
|
||||
font-size="16px"
|
||||
line-height="1.5em"
|
||||
color="#3A3A3A"
|
||||
/>
|
||||
<mj-text font-family="Roboto, sans-serif" />
|
||||
<mj-button font-family="Roboto, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style>
|
||||
/* Reset */
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<!--
|
||||
We load django tags here so they appear in the body of the HTML output.
|
||||
This ensures html-to-text also includes them in the plain text template.
|
||||
-->
|
||||
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
|
||||
Generated
+1904
-942
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
"mjml": "4.18.0"
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"mjml": "5.4.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -59,17 +59,16 @@ async def create_transcribe_task_v2(
|
||||
]
|
||||
)
|
||||
|
||||
properties = {}
|
||||
if request.user_email:
|
||||
properties["$set"] = {"email": request.user_email}
|
||||
|
||||
# We track the request, this also properly initializes the user in the
|
||||
# analytics system, so that later feature flags work properly
|
||||
analytics.capture(
|
||||
settings.posthog_event_request,
|
||||
settings.posthog_transcript_request,
|
||||
request.user_sub,
|
||||
properties={
|
||||
"kind": "transcribe",
|
||||
"$set": {
|
||||
"email": request.user_email,
|
||||
},
|
||||
},
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||
@@ -93,15 +92,15 @@ async def create_summarize_task_v2(
|
||||
|
||||
# We track the request, this also properly initializes the user in the
|
||||
# analytics system, so that later feature flags work properly
|
||||
|
||||
properties = {}
|
||||
if request.user_email:
|
||||
properties["$set"] = {"email": request.user_email}
|
||||
|
||||
analytics.capture(
|
||||
settings.posthog_event_request,
|
||||
settings.posthog_summary_request,
|
||||
request.user_sub,
|
||||
properties={
|
||||
"kind": "summarize",
|
||||
"$set": {
|
||||
"email": request.user_email,
|
||||
},
|
||||
},
|
||||
properties=properties,
|
||||
)
|
||||
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from celery.utils.log import get_task_logger
|
||||
from posthog import Posthog
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.models import TranscribeTaskJob
|
||||
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -109,19 +109,15 @@ class MetadataManager:
|
||||
"""Check if task_id exists in tasks metadata cache."""
|
||||
return self._redis.exists(self._get_redis_key(task_id))
|
||||
|
||||
def create(self, task_id: str, task_payload: TranscribeTaskJob):
|
||||
def create(self, task_id: str, task_payload: TranscribeTaskJob | SummarizeTaskJob):
|
||||
"""Create initial metadata entry for a new task."""
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": clean_url,
|
||||
"sub": task_payload.user_sub,
|
||||
# avoid None in redis, it shouldn't happen anyway in prod
|
||||
"email": task_payload.user_email or "",
|
||||
@@ -129,6 +125,15 @@ class MetadataManager:
|
||||
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
|
||||
}
|
||||
|
||||
if isinstance(task_payload, TranscribeTaskJob):
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
initial_metadata["source_url"] = clean_url
|
||||
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||
elif isinstance(task_payload, SummarizeTaskJob):
|
||||
initial_metadata["content_length"] = len(task_payload.content)
|
||||
initial_metadata["llm_model"] = settings.llm_model
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
def retry(self, task_id):
|
||||
|
||||
@@ -542,28 +542,28 @@ def process_audio_transcribe_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
def task_retry_handler_transcript(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
def task_failure_handler_transcript(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||
@@ -648,9 +648,30 @@ def summarize_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||
def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||
def task_failure_handler_summary(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_summary_failure)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||
def handle_summarize_v2_failed(
|
||||
sender,
|
||||
|
||||
@@ -130,9 +130,12 @@ class Settings(BaseSettings):
|
||||
posthog_enabled: bool = False
|
||||
posthog_api_key: Optional[str] = None
|
||||
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
||||
posthog_event_failure: str = "transcript-failure"
|
||||
posthog_event_success: str = "transcript-success"
|
||||
posthog_event_request: str = "transcript-request"
|
||||
posthog_transcript_request: str = "transcript-request"
|
||||
posthog_transcript_failure: str = "transcript-failure"
|
||||
posthog_transcript_success: str = "transcript-success"
|
||||
posthog_summary_request: str = "summary-request"
|
||||
posthog_summary_failure: str = "summary-failure"
|
||||
posthog_summary_success: str = "summary-success"
|
||||
|
||||
# Langfuse (LLM Observability)
|
||||
langfuse_enabled: bool = False
|
||||
|
||||
Reference in New Issue
Block a user