Compare commits

...

15 Commits

Author SHA1 Message Date
lebaudantoine 0e7e5d1a4c 🔧(devx) pull the MinIO image from quay.io
The quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables.
2026-09-14 19:07:01 +02:00
snyk-bot d85123d0c5 ⬆(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
Snyk has created this PR to upgrade humanize-duration from 3.33.2 to 3.34.1.

See this package in npm:
humanize-duration

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 15:40:56 +02:00
snyk-bot b2abf814fa ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
Snyk has created this PR to upgrade i18next from 26.3.6 to 26.4.0.

See this package in npm:
i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 09:10:04 +02:00
Miguel Victoria cfdf1c2f92 ✨(backend) add default video codec on apiConfig struct
Co-authored-by: David <60177543+davd-gzl@users.noreply.github.com>
2026-09-14 09:03:05 +02:00
Michel-Marie Maudet 4139f542d3 🔧(ci) pull the MinIO image from quay.io
Docker Hub now denies anonymous pulls of minio/minio (pull access
denied), which fails the test-back job for every pull request. The
quay.io/minio/minio mirror remains publicly available, accepts the
same environment variables, and the container lookup in the Configure
MinIO step still matches the image name.

thx @mmaudet
2026-09-14 07:43:02 +02:00
snyk-bot eda66640df ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
Snyk has created this PR to upgrade posthog-js from 1.414.0 to 1.418.10.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:38:10 +02:00
snyk-bot 3fa05ea785 ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
Snyk has created this PR to upgrade react-i18next from 17.0.10 to 17.0.12.

See this package in npm:
react-i18next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-09-14 07:03:25 +02:00
lebaudantoine 30b68052e1 ⚡️(frontend) defer loading the Crisp script until idle
Load the Crisp JavaScript module only once the frontend is idle,
instead of during the initial page load.

Keeps the critical path lighter and prevents Crisp from competing
with the app's own bootstrap for network and CPU on slow devices.
2026-09-13 00:03:55 +02:00
lebaudantoine 04fd79b56b 🔒️(backend) reject inactive users in resource server backend
The resource server backend returned any user matching the token's
`sub` claim without checking `User.is_active`. The upstream lasuite
backend only validates the token's introspection `active` claim, so a
deactivated Django account kept API access until its token expired.

Raise `SuspiciousOperation` in `get_or_create_user` when the user is
inactive, which the authentication class turns into a 401, consistent
with `BaseJWTAuthentication`. Add unit and end-to-end tests.
2026-09-10 11:10:53 +02:00
leo e336122cfa 💬(frontend) clarify video recording wording
Video recording from transcription panel did not explicitly
mention video, leading to confusion from some users. Make
wording more explicit.
2026-09-09 20:03:36 +02:00
lebaudantoine 172dc70649 ✨(backend) allow configuring trace sampling
Add a configuration knob for the trace sampling rate, so we can
enable tracing on middleware and cache spans when debugging slow
requests in production.

Sampling is set to 0 by default, so tracing stays fully off unless
explicitly enabled.
2026-09-09 20:02:57 +02:00
lebaudantoine e0ab7f191f 📈(frontend) include LiveKit SIDs in the connection analytics event
Attach the LiveKit SIDs (room and participant) to the connection
analytics event.

Makes it easier to debug problematic sessions and to correlate a
room session with the corresponding LiveKit logs.
2026-09-09 13:38:44 +02:00
lebaudantoine 455b315dbb 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
Around 0.76% of incoming LiveKit webhooks were being flagged as
unprocessable and returned a 422, even though LiveKit was sending
legitimate data — just with event types we do not handle. This
inflated error metrics and made real webhook issues harder to spot.

Return a 200 for these webhooks instead. When a new, unhandled
event type shows up, log a warning so we can decide whether it is
worth adding explicit handling.
2026-09-09 12:09:13 +02:00
lebaudantoine 3089b03062 🔇(backend) silence noisy request summary info logs
The request summary info logs were spamming the log stream, making
around 46% of the total volume, without carrying any exploitable
information.

Silence them so the remaining logs are easier to explore and cheaper
to store; roughly halves the overall log volume.
2026-09-09 11:17:49 +02:00
lebaudantoine 60febb3b57 🔇(backend) silence expected 401 warnings on /me
On a busy morning, `/me` alone produced 72k warning logs — 97% of
all warnings. They all come from anonymous requests to `/me`
without credentials, which is normal: `/me` is how the app
determines the current auth status.

These warnings carry no diagnostic value on this endpoint, so
silence them there to cut down on log volume.
2026-09-09 11:17:49 +02:00
27 changed files with 405 additions and 113 deletions
+2 -2
View File
@@ -252,13 +252,13 @@ jobs:
- name: Start MinIO - name: Start MinIO
run: | run: |
docker pull minio/minio docker pull quay.io/minio/minio
docker run -d --name minio \ docker run -d --name minio \
-p 9000:9000 \ -p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \ -e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \ -e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \ -v /data/media:/data \
minio/minio server --console-address :9001 /data quay.io/minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready # Tool to wait for a service to be ready
- name: Install Dockerize - name: Install Dockerize
+17
View File
@@ -8,9 +8,26 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(backend) make the LiveKit default video codec configurable
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
### Fixed ### Fixed
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API - 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
## [1.31.0] - 2026-09-08 ## [1.31.0] - 2026-09-08
+2 -2
View File
@@ -17,7 +17,7 @@ services:
minio: minio:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
image: minio/minio image: quay.io/minio/minio
environment: environment:
- MINIO_ROOT_USER=meet - MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password - MINIO_ROOT_PASSWORD=password
@@ -35,7 +35,7 @@ services:
- ./data/media:/data - ./data/media:/data
createbuckets: createbuckets:
image: minio/mc image: quay.io/minio/mc
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.50.0",
"i18next": "26.3.6", "i18next": "26.4.0",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
@@ -9367,9 +9367,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.6", "version": "26.4.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", "integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.50.0",
"i18next": "26.3.6", "i18next": "26.4.0",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
+1
View File
@@ -71,6 +71,7 @@ def get_frontend_configuration(request):
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL, "force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND, "enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
}, },
"authenticated_users_can_edit_display_name": ( "authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER: if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub) user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user return user
def create_user(self, sub): def create_user(self, sub):
+25
View File
@@ -0,0 +1,25 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+8 -10
View File
@@ -52,12 +52,6 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400 status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError): class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete.""" """Webhook action fails to process or complete."""
@@ -74,6 +68,7 @@ class LiveKitWebhookEventType(Enum):
# Participant events # Participant events
PARTICIPANT_JOINED = "participant_joined" PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left" PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events # Track events
TRACK_PUBLISHED = "track_published" TRACK_PUBLISHED = "track_published"
@@ -153,10 +148,13 @@ class LiveKitEventsService:
try: try:
webhook_type = LiveKitWebhookEventType(data.event) webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e: except ValueError:
raise UnsupportedEventTypeError( logger.warning(
f"Unknown webhook type: {data.event}" "Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
) from e data.event,
room_name,
)
return
# Handle according to received webhook type # Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value) handler = self._webhook_handlers.get(webhook_type.value)
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config): def test_unknown_event_type(client, mock_livekit_config):
"""Should return 422 for unknown event type.""" """Should acknowledge (200) an unknown event type rather than reject it."""
event_data = json.dumps({"event": "unknown_event_type"}) event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload # Generate auth token for this specific payload
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token, HTTP_AUTHORIZATION=auth_token,
) )
assert response.status_code == 422 assert response.status_code == 200
assert response.json() == { assert response.json() == {"status": "success"}
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished") @mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
AuthenticationError, AuthenticationError,
InvalidPayloadError, InvalidPayloadError,
LiveKitEventsService, LiveKitEventsService,
UnsupportedEventTypeError,
api, api,
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service): def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Should raise LiveKitWebhookError for unsupported events.""" """Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
mock_request = mock.MagicMock() mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"} mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}" mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4()) mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event" mock_data.event = "some_future_event"
mock_receive.return_value = mock_data mock_receive.return_value = mock_data
with pytest.raises( with caplog.at_level("WARNING", logger="core.services.livekit_events"):
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event" service.receive(mock_request) # must not raise
):
service.receive(mock_request) assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
@@ -0,0 +1,96 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -0,0 +1,22 @@
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
import pytest
from meet.settings import VideoCodecValue
@pytest.mark.parametrize(
"raw,expected",
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
)
def test_video_codec_value_normalizes(raw, expected):
"""Whitespace is trimmed and the name is lowercased before it is checked."""
# environ=False keeps __new__ from resolving the value, so the instance survives.
assert VideoCodecValue(environ=False).to_python(raw) == expected
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
def test_video_codec_value_rejects_unsupported(raw):
"""A name outside the accepted list stops the settings module loading."""
with pytest.raises(ValueError, match="Unsupported video codec"):
VideoCodecValue(environ=False).to_python(raw)
+53 -1
View File
@@ -53,6 +53,26 @@ def get_release():
return "NA" # Default: not available return "NA" # Default: not available
class VideoCodecValue(values.Value):
"""
A video codec name, normalized to lowercase and validated against the codecs
supported by the LiveKit client, so that a typo fails at startup instead of
silently downgrading every publisher to another codec.
"""
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
def to_python(self, value):
"""Normalize the codec name and ensure it is a supported one."""
codec = super().to_python(value).strip().lower()
if codec not in self.codecs:
raise ValueError(
f"Unsupported video codec {value!r}, "
f"expected one of: {', '.join(sorted(self.codecs))}."
)
return codec
class Base(Configuration): class Base(Configuration):
""" """
This is the base configuration every configuration (aka environment) should inherit from. It This is the base configuration every configuration (aka environment) should inherit from. It
@@ -469,6 +489,9 @@ class Base(Configuration):
# Sentry # Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN") SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails # Easy thumbnails
THUMBNAIL_EXTENSION = "webp" THUMBNAIL_EXTENSION = "webp"
@@ -675,6 +698,9 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
default=False, default=False,
) )
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
)
CONNECTION_TEST_ENABLED = values.BooleanValue( CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED", environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None, environ_prefix=None,
@@ -1110,6 +1136,12 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging # Logging
# We want to make it easy to log to console but by default we log production # We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console. # to Sentry and don't want to log to console.
@@ -1122,10 +1154,16 @@ class Base(Configuration):
"style": "{", "style": "{",
}, },
}, },
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": { "handlers": {
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "simple", "formatter": "simple",
"filters": ["silence_expected_401"],
}, },
}, },
# Override root logger to send it to console # Override root logger to send it to console
@@ -1136,6 +1174,13 @@ class Base(Configuration):
), ),
}, },
"loggers": { "loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"core": { "core": {
"handlers": ["console"], "handlers": ["console"],
"level": values.Value( "level": values.Value(
@@ -1211,7 +1256,14 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN, dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production environment=cls.__name__.lower(), # build, test, development, production
release=get_release(), release=get_release(),
integrations=[DjangoIntegration()], traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
) )
sentry_sdk.set_tag("application", "backend") sentry_sdk.set_tag("application", "backend")
+36 -43
View File
@@ -23,18 +23,18 @@
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.34.1",
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.414.0", "posthog-js": "1.418.10",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.10", "react-i18next": "17.0.12",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -1720,28 +1720,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/browser-common": { "node_modules/@posthog/browser-common": {
"version": "0.4.0", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==", "integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/core": "^1.46.8", "@posthog/core": "^1.48.11",
"@posthog/types": "^1.402.0" "@posthog/types": "^1.405.3"
} }
}, },
"node_modules/@posthog/core": { "node_modules/@posthog/core": {
"version": "1.49.1", "version": "1.53.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==", "integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/types": "^1.407.0" "@posthog/types": "^1.411.1"
} }
}, },
"node_modules/@posthog/types": { "node_modules/@posthog/types": {
"version": "1.407.1", "version": "1.411.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==", "integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/overlays": { "node_modules/@react-aria/overlays": {
@@ -6354,11 +6354,12 @@
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
}, },
"node_modules/html-parse-stringify": { "node_modules/html-parse-stringify": {
"version": "3.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"dependencies": { "license": "MIT",
"void-elements": "3.1.0" "funding": {
"url": "https://locize.com"
} }
}, },
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
@@ -6401,9 +6402,9 @@
} }
}, },
"node_modules/humanize-duration": { "node_modules/humanize-duration": {
"version": "3.33.2", "version": "3.34.1",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz", "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz",
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==", "integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==",
"license": "Unlicense", "license": "Unlicense",
"funding": { "funding": {
"url": "https://github.com/sponsors/EvanHahn" "url": "https://github.com/sponsors/EvanHahn"
@@ -9131,16 +9132,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.414.0", "version": "1.418.10",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==", "integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
"license": "(Apache-2.0 AND MIT)", "license": "(Apache-2.0 AND MIT)",
"dependencies": { "dependencies": {
"@posthog/browser-common": "^0.4.0", "@posthog/browser-common": "^0.5.0",
"@posthog/core": "^1.46.9", "@posthog/core": "^1.48.8",
"@posthog/types": "^1.402.2", "@posthog/types": "^1.405.1",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"dompurify": "^3.4.12", "dompurify": "^3.4.13",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.29.3", "preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
@@ -9435,13 +9436,13 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.10", "version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==", "integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.7",
"html-parse-stringify": "^3.0.1", "html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -11608,14 +11609,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walk-sync": { "node_modules/walk-sync": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
+3 -3
View File
@@ -30,18 +30,18 @@
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.34.1",
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.414.0", "posthog-js": "1.418.10",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.10", "react-i18next": "17.0.12",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
+2 -1
View File
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording' import { RecordingMode } from '@/features/recording'
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import type { Track } from 'livekit-client' import type { Track, VideoCodec } from 'livekit-client'
type Source = Track.Source type Source = Track.Source
export interface ApiConfig { export interface ApiConfig {
@@ -62,6 +62,7 @@ export interface ApiConfig {
force_wss_protocol: boolean force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean enable_firefox_proxy_workaround: boolean
default_sources: Source[] default_sources: Source[]
default_video_codec: VideoCodec
} }
transcription_destination?: string transcription_destination?: string
max_participants_for_sound: number max_participants_for_sound: number
@@ -107,7 +107,7 @@ export const Conference = ({
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
publishDefaults: { publishDefaults: {
videoCodec: 'vp9', videoCodec: apiConfig?.livekit.default_video_codec ?? 'vp9',
}, },
videoCaptureDefaults: { videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined, deviceId: userConfig.videoDeviceId ?? undefined,
@@ -129,6 +129,7 @@ export const Conference = ({
userConfig.videoPublishResolution, userConfig.videoPublishResolution,
userConfig.audioDeviceId, userConfig.audioDeviceId,
userConfig.audioOutputDeviceId, userConfig.audioOutputDeviceId,
apiConfig?.livekit.default_video_codec,
]) ])
const room = useMemo(() => new Room(roomOptions), [roomOptions]) const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
useEffect(() => { useEffect(() => {
if (!isAnalyticsEnabled) return if (!isAnalyticsEnabled) return
const handleConnection = () => { const handleConnection = async () => {
// Preserve original connection timestamp across reconnections to measure // Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect. // total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now() connectionStartTimeRef.current = Date.now()
void captureMediaEvent('connection-event', {}) const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
} }
const handleReconnect = () => { const handleReconnect = () => {
captureEvent('reconnect-event') captureEvent('reconnect-event')
} }
const handleReconnected = () => { const handleReconnected = async () => {
captureEvent('reconnected-event') const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
} }
const handleSignalingConnect = () => { const handleSignalingConnect = () => {
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components' import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe' import { menuRecipe } from '@/primitives/menuRecipe'
import { Crisp } from 'crisp-sdk-web' import {
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport' useIsSupportEnabled,
openSupportChat,
} from '@/features/support/hooks/useSupport'
export const SupportMenuItem = () => { export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled() const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled || !Crisp) { if (!isSupportEnabled) {
return return
} }
return ( return (
<MenuItem <MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item} className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => { onAction={openSupportChat}
Crisp?.chat.open()
}}
> >
<RiQuestionLine size={20} /> <RiQuestionLine size={20} />
{t('support')} {t('support')}
@@ -1,20 +1,45 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { type ApiUser } from '@/features/auth/api/ApiUser' import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser' import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
let crisp: CrispSdk | undefined
let crispPromise: Promise<CrispSdk> | undefined
const loadCrisp = (): Promise<CrispSdk> => {
crispPromise ??= import('crisp-sdk-web')
.then((module) => {
crisp = module.Crisp
return module.Crisp
})
.catch((error) => {
crispPromise = undefined
throw error
})
return crispPromise
}
export const openSupportChat = () => {
if (!crisp?.isCrispInjected()) return
crisp.chat.open()
}
export const initializeSupportSession = (user: ApiUser) => { export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return if (!crisp?.isCrispInjected()) return
const { id, email } = user const { id, email } = user
Crisp.setTokenId(`meet-${id}`) crisp.setTokenId(`meet-${id}`)
if (email) Crisp.user.setEmail(email) if (email) crisp.user.setEmail(email)
} }
export const terminateSupportSession = () => { export const terminateSupportSession = () => {
if (!Crisp.isCrispInjected()) return if (!crisp?.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset() crisp.setTokenId()
crisp.session.reset()
} }
export type useSupportProps = { export type useSupportProps = {
@@ -22,26 +47,70 @@ export type useSupportProps = {
isDisabled?: boolean isDisabled?: boolean
} }
// Configure Crisp chat for real-time support across all pages. const IDLE_TIMEOUT_MS = 10_000
const scheduleWhenIdle = (callback: () => void): (() => void) => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, {
timeout: IDLE_TIMEOUT_MS,
})
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(callback, 1)
return () => window.clearTimeout(handle)
}
export const useSupport = ({ id, isDisabled }: useSupportProps) => { export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser() const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => { useEffect(() => {
if (!id || Crisp.isCrispInjected() || isDisabled) return if (!id || isDisabled) return
Crisp.configure(id)
Crisp.setHideOnMobile(true) if (crisp?.isCrispInjected()) {
setIsInjected(true)
return
}
let cancelled = false
const cancelIdle = scheduleWhenIdle(() => {
void loadCrisp()
.then((sdk) => {
if (cancelled) return
if (!sdk.isCrispInjected()) {
sdk.configure(id)
sdk.setHideOnMobile(true)
}
setIsInjected(true)
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to initialize support chat', error)
}
})
})
return () => {
cancelled = true
cancelIdle()
}
}, [id, isDisabled]) }, [id, isDisabled])
useEffect(() => { useEffect(() => {
if (!user) return if (!user || !isInjected || isDisabled) return
initializeSupportSession(user) initializeSupportSession(user)
}, [user]) }, [user, isInjected, isDisabled])
return null return null
} }
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins // Some users block the chat widget, so check its availability safely.
// So we need to safely check if Crisp is available and not blocked
const isCrispAvailable = () => { const isCrispAvailable = () => {
try { try {
return !!window?.$crisp?.is return !!window?.$crisp?.is
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf", "destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt", "destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:", "language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten" "recording": "Auch eine Videoaufzeichnung starten"
}, },
"button": { "button": {
"start": "Meeting-Transkription starten", "start": "Meeting-Transkription starten",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "A new document will be created on", "destination": "A new document will be created on",
"destinationUnknown": "A new document will be created", "destinationUnknown": "A new document will be created",
"language": "Meeting language:", "language": "Meeting language:",
"recording": "Also start a recording" "recording": "Also start a video recording"
}, },
"button": { "button": {
"start": "Start transcribing the meeting", "start": "Start transcribing the meeting",
+1 -1
View File
@@ -479,7 +479,7 @@
"destination": "Se creará un nuevo documento en", "destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento", "destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:", "language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación" "recording": "Iniciar también una grabación de vídeo"
}, },
"button": { "button": {
"start": "Empezar a transcribir la reunión", "start": "Empezar a transcribir la reunión",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Un nouveau document sera créé sur", "destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé", "destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :", "language": "Langue de la réunion :",
"recording": "Démarrer aussi un enregistrement" "recording": "Démarrer aussi un enregistrement vidéo"
}, },
"button": { "button": {
"start": "Commencer à transcrire la réunion", "start": "Commencer à transcrire la réunion",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op", "destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt", "destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:", "language": "Vergadertalen:",
"recording": "Start ook een opname" "recording": "Start ook een video-opname"
}, },
"button": { "button": {
"start": "Begin met het transcriberen van de vergadering", "start": "Begin met het transcriberen van de vergadering",
+1
View File
@@ -150,6 +150,7 @@ backend:
# Frontend # Frontend
LIVEKIT_FORCE_WSS_PROTOCOL: True LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000 FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
FRONTEND_SILENCE_LIVEKIT_DEBUG: False FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}" FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"