Compare commits

..

65 Commits

Author SHA1 Message Date
lebaudantoine 61f286cc8b 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:39:46 +01:00
lebaudantoine a5254ffd59 🔊(frontend) log participant and segments
Log transcription segments to troubleshoot duplication issue.
2026-01-23 18:53:10 +01:00
lebaudantoine ff82bca9ec 🐛(frontend) ensure transcript segments are sorted by their timestamp
Switching from Deepgram to our custom Kyutai implementation introduced changes
in how segment data is returned by the LiveKit agent, so the segment start time
is now treated as optional.
2026-01-23 18:22:40 +01:00
lebaudantoine 99a18b6e90 🩹(backend) use case-insensitive email matching in the external api
Fix a minor issue in the external API where users were matched using
case-sensitive email comparison, while authentication treats emails as
case-insensitive. This caused inconsistencies that are now resolved.

Spotted by T. Lemeur from Centrale.
2026-01-20 20:50:13 +01:00
Cyril 250e599465 📝(frontend) align close dialog label in rooms locale
keep close label consistent with global wording
2026-01-20 12:39:03 +01:00
Cyril 144a4e1b85 ️(frontend) improve background effect announcements
ensure sr announces clear and virtual background state
2026-01-20 12:34:32 +01:00
Cyril 78ab3cdbdf ️(frontend) improve aria-label with accessible emoji description
replace raw emoji with descriptive label to enhance screen reader support
2026-01-19 23:35:18 +01:00
Cyril a815d6c00d 📝(docs) add changelog file to document project changes
helps track notable changes and improvements over time
2026-01-19 23:35:18 +01:00
Cyril dfbc3a9d17 💄(frontend) add globally available sr-only utility class
provides reusable hidden style for screen reader-only content
2026-01-19 23:35:18 +01:00
Cyril 086db3d089 📝(frontend) update a11y store labels and link for clarity
improves naming and navigation for better user understanding of options
2026-01-19 23:35:18 +01:00
Cyril 014ef3d804 (frontend) create a11y store to manage user option toggles
sets up state handling for enabling or disabling a11y preferences
2026-01-19 23:35:18 +01:00
Cyril de3e1a56a8 (frontend) add placeholder for accessibility menu in settings panel
prepares UI for future accessibility options without implementing logic yet
2026-01-19 23:35:18 +01:00
Cyril 459749b992 (frontend) getEmojiLabel util for accessible emoji labeling across app
centralizes emoji label logic to ensure consistency and reuse in UI components
2026-01-19 23:35:18 +01:00
Cyril e1450329f2 ️(frontend) add screen reader announcements for reactions interactions
ensures users get feedback when adding reactions via assistive tech
2026-01-19 23:35:18 +01:00
Cyril c7e3194331 ️(frontend) announce copy state in invite dialog
improves screen reader feedback after copying the link
2026-01-19 22:55:47 +01:00
Cyril 902b005f32 ️(frontend) improve contrast for selected options
add dark inner border to enhance visibility and accessibility
2026-01-19 22:28:46 +01:00
Cyril 51d22783b2 ️(frontend) make carousel image decorative
avoid screen reader announcing redundant visual content
2026-01-19 18:29:25 +01:00
blipp 76f80a0f2f Fix k8s link in Docker Compose installation guide 2026-01-19 18:29:25 +01:00
Cyril 82eb930200 📝(docs) update changelog
document the latest change in the project history
2026-01-19 18:29:25 +01:00
Cyril eeeb950e08 ️(frontend) improve participants toggle a11y label
avoid screen reader duplication by using visual-only tooltip
2026-01-19 18:29:19 +01:00
Cyril cb77688572 ️(frontend) add accessible back button in side panel
label the back button and separate it from the heading for a11y
2026-01-19 15:14:25 +01:00
lebaudantoine f9524b2f0a 🔒️(backend) prevent automatic upgrade setuptools
The latest `setuptools` version pulls in a `jaraco.context` version that
triggers a Trivy scan failure. `jaraco.context` has a path traversal
vulnerability.

This fix is inspired by suitenumerique/people, specifically Marie’s PR #1010.
2026-01-19 14:16:00 +01:00
lebaudantoine a50aabeaf8 🔖(minor) bump release to 1.3.0 2026-01-13 15:44:23 +01:00
lebaudantoine 594bd5a692 🚸(frontend) hide back button when a user is ejected by an admin
Avoid showing a back button when a user is kicked out of a meeting by an admin,
to prevent them from repeatedly rejoining the room.
2026-01-13 15:28:39 +01:00
lebaudantoine 69d92e6f30 🩹(frontend) icon font loading to avoid text/icon flickering
Icon fonts were loading just in time, which is good for performance, but caused
a visible blink where fallback text appeared before the font loaded. I followed
the documentation introduced in PR 963 of the fontsource repository.

This introduces preloading for critical fonts, slightly increases initial load
time, and defines custom @font-face rules to control font-display and avoid
font swapping. This approach only works with Vite-based frameworks,
as noted in the documentation.

See the advanced installation section for material-symbols-outlined on
fontsource.org, and apply the same approach for Material Icons.

I manually built the preload headers based on a comment from issue #83.
This works well with Vite, which replaces the font URLs at build time.
2026-01-12 12:56:08 +01:00
lebaudantoine c47e830b40 ♻️(frontend) introduce an Icon primitive
Encapsulate icon and symbol rendering in a dedicated component that applies
aria-hidden and disables translation attributes.

This prevents browsers from translating icon names and breaking the UI, and
ensures screen readers do not announce decorative icons.

This is a first draft and can be extended with additional variants later.
2026-01-12 12:56:08 +01:00
lebaudantoine d7f1b7b94c 🚸(frontend) explain to a user her was ejected
Add a clear feedback message explaining to users when they are ejected from a
meeting, explicitly stating that the action was taken by an admin.
2026-01-11 23:07:54 +01:00
lebaudantoine 8072d2c950 ♻️(frontend) refactor disconnection reason handling and state
Refactor the duplicateIdentity boolean URL parameter into an extensible string
reason parameter, making it easier to customize the disconnection message
shown to users.

Avoid passing this value via URL parameters, which are easy to manipulate.
Instead, use Wouter’s built-in navigation state to pass data across pages.

This was initially missed because navigateTo is a wrapper around Wouter’s
official navigation function, and its arguments were easy to overlook. This is
now fixed.

This prepares the ground for supporting additional disconnection reasons in
upcoming commits.
2026-01-11 23:07:54 +01:00
lebaudantoine 726f9097f9 ♻️(frontend) refactor the onDisconnected function to use a switch
This makes the logic more extensible in preparation for introducing
additional disconnect reason handlers.
2026-01-11 23:07:54 +01:00
Cyril bbc7fa8012 ️(frontend) focus first background effect button on panel open
improves keyboard navigation by placing focus on first actionable element
2026-01-09 19:03:34 +01:00
Cyril 41db3e766b ️(frontend) add blur status with sr announcement and sr-only class
improves a11y by exposing blur state to sr users and hiding visual labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 19:03:34 +01:00
Cyril 1ab3ce6d47 ️(frontend) improve background effects a11y and blur labels
Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-09 19:03:34 +01:00
lebaudantoine 3cd5c77f42 ️(frontend) enhance sidepanel accessibility
Use the appropriate HTML <aside> element for the side panel and enhance
it with the correct ARIA attributes to improve accessibility.
2026-01-09 19:03:34 +01:00
lebaudantoine 3ddb075c6b ️(frontend) enhance vocalization of blur options
Hide non-essential icons and refine the labels to emphasize that one option
applies a stronger blur than the other. This should provide clearer cues for
screen reader users.
2026-01-09 19:03:34 +01:00
lebaudantoine 9ed2500565 🚸(frontend) remove the “none” effect button
While this makes it slightly less explicit that clicking an already selected
option will disable the active effect, it improves accessibility by avoiding
automatic focus movement from the previously active option to a separate “none”
option. That focus shift could be misleading or hard to follow
for screen reader users.

Open to feedback on this decision.
2026-01-09 19:03:34 +01:00
lebaudantoine 1001783d3c ️(frontend) enhance vocalized indication of virtual background
Update the virtual background effects tooltip and ARIA label with more
descriptive and concise wording based on Sophie’s feedback. This helps all
users, especially those using assistive technologies, by improving how
each virtual background is vocalized.
2026-01-09 19:03:34 +01:00
lebaudantoine 97b5e8780c 🩹(frontend) fix minor layout issue hidding focus ring
Fix an issue where the focus visual indication was hidden due to an overly tight
layout with no padding on the background and effects toggle buttons.
2026-01-09 19:03:34 +01:00
lebaudantoine 7c7074aa99 🚸(frontend) refine effects wording
Refine the Effects title to clearly indicate it covers both background and
effects, improving clarity. Inspired by Google Meet.
2026-01-09 19:03:34 +01:00
lebaudantoine 35b3bcad63 🔧(agents) make Silero VAD optional
Allow configuring whether a VAD model runs before calling an external ASR API.
Running VAD can save API calls (and costs) when no audible sound is detected,
but comes with the trade-off of additional computational overhead.
2026-01-08 18:03:23 +01:00
lebaudantoine 137a2c7f6f 🩹(frontend) close subtitles on room disconnections
Subtitles were still visible when leaving and rejoining a meeting, even though
the backend API call to start them was not triggered again.

Introduce a hook that closes the subtitles layout on unmount, ensuring users
must explicitly click the button to restart subtitles when they rejoin a room.
2026-01-08 15:13:37 +01:00
lebaudantoine d681e25bcc 💄(frontend) adjust spacing in the recording side panels
Based on @Arnaud’s feedback, adjust the spacing between the title, details
section, and control buttons to make the layout feel more homogeneous.
2026-01-08 13:17:46 +01:00
lebaudantoine 1f1a6371b4 🚸(frontend) remove the default comma delimiter in humanized durations
The comma caused values like 1h30 to be rendered as “1 heure, 30 minutes,”
which feels awkward in most European languages.
2026-01-08 13:17:46 +01:00
Cyril bbfbb23be5 ♻️(frontend) extract tools panel focus logic into reusable hook
prepares logic reuse for consistent focus restoration across the app
2026-01-07 14:50:45 +01:00
Cyril 6e20bc1f43 ️(frontend) restore focus to trigger button when panel closes
improves keyboard navigation and accessibility consistency

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine fed05f2396 ️(frontend) fix jump and animation break on panel open with auto-focus
used requestAnimationFrame and preventScroll to preserve smooth transition

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 865acf2838 (frontend) focus transcript and record buttons on open
move keyboard focus to transcript or recording button when the panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 6ae68013af (frontend) add SR announcements for transcript and recording
announce transcript and record events to sr to provide clear feedback

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
lebaudantoine 394a1be322 (frontend) add sr-only class
add a utility class to hide content visually while keeping it available to sr.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2026-01-07 14:50:45 +01:00
Cyril a71a1fd968 📝(docs) add changelog entry for visio button tooltip a11y fix
documents fix ensuring tooltip appears only on keyboard nav
2026-01-07 12:55:51 +01:00
Cyril 40af264562 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
fixes accidental tooltip trigger unrelated to visio screen interaction
2026-01-07 12:44:52 +01:00
Arnaud Robin 8b2d06976e 📝(terms) update terms of service
Enhance the terms of service by adding detailed sections on
service availability, security, support management,
and monitoring of service use. This update aims to provide clearer
guidelines and responsibilities for users and the DINUM administration.
2026-01-06 22:57:04 +01:00
lebaudantoine 58313666ed 👷(ci) ignore trivy scan output temporary
CVE-2025-13601 has yet no fix. I don't want to migrate the base image
in this pull request, as it could introduce regression.

I'll open an issue to fix this CVE later on. The summary service isn't
exposed on internet, and the agent isn't used in production.
2026-01-06 19:49:23 +01:00
lebaudantoine f3c8aec189 🔧(ci) add trivy scans for summary and agent
Closes #685: add a Trivy scan to the CI build steps for Meet Summary
and Meet Agents to ensure no vulnerabilities are present before pushing images
to the registry.
2026-01-06 19:49:23 +01:00
lebaudantoine 0a0c7ba618 (summary) add dutch and german languages
Based on a request from our European partners, introduce new languages for the
transcription feature. Dutch and German are now supported, which is a great
addition.

It closes #837.

WhisperX is expected to support both languages.
2026-01-06 17:52:04 +01:00
renovate[bot] d7ad5aed05 ⬆️(dependencies) update aiohttp to v3.13.3 [SECURITY] 2026-01-06 17:00:00 +01:00
lebaudantoine 4acc9cf40d 🩹(frontend) render the NoAccessView for unprevileged users
Simplify a broken conditional check that allowed users without
the required permissions to see the control menu. The `NoAccessView`
is now shown to any user who is neither an admin nor the meeting owner.
2026-01-06 16:43:15 +01:00
lebaudantoine 13d0d3d801 📈(frontend) track metadata on recording-started events
I introduced transcript + screen recording modes but forgot
to properly track them in PostHog. Fix this issue.
2026-01-06 16:43:15 +01:00
lebaudantoine 47cd3eff74 🔖(minor) bump release to 1.2.0 2026-01-05 18:10:05 +01:00
lebaudantoine 5769203705 💄(frontend) add minor layout adjustments
Propose minor layout adjustments to ensure the DINUM version with French
copywriting does not look visually awkward due to line breaks.
2026-01-05 17:47:26 +01:00
lebaudantoine cadc186c62 🐛(backend) fix certificates volume mount path for Python 3.13
After upgrading Python to 3.13, not all development environments were
updated accordingly. This fixes the incorrect volume mount path
introduced by that upgrade.
2026-01-05 17:47:26 +01:00
lebaudantoine 5be7595533 🐛(summary) fix MinIO endpoint handling in constructor
Fix MinIO client configuration: I was incorrectly passing a full URL instead of
an endpoint, which caused errors in staging. Local development values did not
reflect the staging setup and were also out of sync with the backend.
2026-01-05 15:40:11 +01:00
lebaudantoine 0fe8d9b681 🐛(backend) fix ignore recording webhook events
Fix an unexpected behavior where filtering LiveKit webhook events sometimes
failed because the room name was not reliably extracted from the webhook data,
causing notifications to be ignored.

Configure the same filtering logic locally to avoid missing this kind of issue
in the future.
2026-01-05 13:34:55 +01:00
lebaudantoine 83654cf7c0 📌(egress) pin egress version to v1.11.0
Pin egress to the production version, which uses a more recent release than the
default chart value (1.9.0).

Using the default could have led to issues; hopefully this change avoids them.
2026-01-05 11:00:12 +01:00
lebaudantoine f6cdb1125b ♻️(backend) refactor backend recording state management
Instead of relying on the egress_started event—which fires when egress is
starting, not actually started—I now rely on egress_updated for more accurate
status updates. This is especially important for the active status, which
triggers after egress has truly joined the room. Using this avoids prematurely
stopping client-side listening to room.isRecording updates. A further
refactoring may remove reliance on room updates entirely.

The goal is to minimize handling metadata in the mediator class. egress_starting
is still used for simplicity, but egress_started could be considered in the
future.

Note: if the API to start egress hasn’t responded yet, the webhook may fail to
find the recording because it currently matches by worker ID. This is unstable.
A better approach would be to pass the database ID in the egress metadata and
recover the recording from it in the webhook.
2026-01-05 00:14:00 +01:00
lebaudantoine 2863aa832d 🔥(frontend) remove useless font block on icons
Myabd, this css property is useful only on font face, it has
no effect on materials-related classes.
2026-01-05 00:14:00 +01:00
91 changed files with 1642 additions and 595 deletions
+16
View File
@@ -147,6 +147,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -178,6 +186,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+48 -3
View File
@@ -8,19 +8,64 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(frontend) remove the beta badge
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22
### Added
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
@@ -32,6 +77,6 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
+1 -1
View File
@@ -235,7 +235,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress
image: livekit/egress:v1.11.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
## Requirements
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
+5 -4
View File
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.1.0"
version = "1.3.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+6 -2
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email=email)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
+4 -2
View File
@@ -118,8 +118,10 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
if self._filter_regex and not self._filter_regex.search(data.room.name):
logger.info("Filtered webhook event for room '%s'", data.room.name)
room_name = data.room.name or data.egress_info.room_name
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
try:
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
"scope": "user.family@example.com",
},
format="json",
)
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.1.0"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
"aiohttp==3.13.3",
]
[project.urls]
+16
View File
@@ -6,6 +6,22 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.1.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.1.0",
"version": "1.3.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.1.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
))}
{/* Article 7 */}
<H lvl={2} margin={false}>
{t('articles.article7.title')}
</H>
<H lvl={2}>{t('articles.article7.title')}</H>
<P>{t('articles.article7.content')}</P>
{/* Section 7.1 */}
<H lvl={3} bold>
{t('articles.article7.sections.section1.title')}
</H>
<P>{t('articles.article7.sections.section1.content')}</P>
{ensureArray(
t('articles.article7.sections.section1.paragraphs', {
returnObjects: true,
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
<H lvl={3} bold>
{t('articles.article7.sections.section2.title')}
</H>
{ensureArray(
t('articles.article7.sections.section2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.3 */}
<H lvl={3} bold>
{t('articles.article7.sections.section3.title')}
</H>
{ensureArray(
t('articles.article7.sections.section3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.4 */}
<H lvl={3} bold>
{t('articles.article7.sections.section4.title')}
</H>
{ensureArray(
t('articles.article7.sections.section4.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.5 */}
<H lvl={3} bold>
{t('articles.article7.sections.section5.title')}
</H>
<P>
{t('articles.article7.sections.section2.content')
{t('articles.article7.sections.section5.content')
.split('https://github.com/suitenumerique/meet')[0]
.replace('https://github.com/suitenumerique/meet', '')}{' '}
<A href="https://github.com/suitenumerique/meet" color="primary">
https://github.com/suitenumerique/meet
</A>
{'. '}
{
t('articles.article7.sections.section2.content').split(
t('articles.article7.sections.section5.content').split(
'https://github.com/suitenumerique/meet'
)[1]
}
@@ -66,16 +66,22 @@ export function ToastRecordingRequest({
name: participant?.name,
})}
{!options.isMenuOpen && (
<Button
size="sm"
variant="text"
<div
className={css({
color: 'primary.300',
marginLeft: '0.5rem',
})}
onPress={options.openMenu}
>
{t('openMenu')}
</Button>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={options.openMenu}
>
{t('openMenu')}
</Button>
</div>
)}
</HStack>
</StyledToastContainer>
@@ -1,7 +1,7 @@
import { css, cx } from '@/styled-system/css'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { Button, Icon, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -42,6 +42,17 @@ export const ControlsButton = ({
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
@@ -97,6 +108,7 @@ export const ControlsButton = ({
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
@@ -129,31 +141,23 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
</RACButton>
)}
<Button
@@ -161,6 +165,8 @@ export const ControlsButton = ({
fullWidth
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
@@ -1,4 +1,4 @@
import { H, Text } from '@/primitives'
import { H, Text, Icon } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols" aria-hidden={true}>
login
</span>
<Icon type="symbols" name="login" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useMemo, useRef, useState, useEffect } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -21,6 +21,9 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -67,6 +70,23 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const hasScreenRecordingAccessAndActive =
@@ -74,61 +94,74 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
)
}
@@ -1,4 +1,5 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <span className="material-symbols">speech_to_text</span>
return <Icon type="symbols" name="speech_to_text" />
}
return <span className="material-symbols">screen_record</span>
return <Icon type="symbols" name="screen_record" />
}
@@ -1,4 +1,4 @@
import { Button, H, Text } from '@/primitives'
import { Button, Icon, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols">person_raised_hand</span>
<Icon type="symbols" name="person_raised_hand" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,5 +1,6 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -29,9 +30,10 @@ export const RowWrapper = ({
className={css({
width: '100%',
background: 'gray.100',
padding: '8px',
paddingBlock: '0.5rem',
paddingInline: '0',
display: 'flex',
marginTop: '4px',
marginTop: '0.25rem',
})}
>
<div
@@ -40,17 +42,19 @@ export const RowWrapper = ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingInline: '0.25rem',
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<span className="material-icons">{iconName}</span>
<Icon name={iconName} />
</div>
<div
className={css({
flex: 5,
flex: 6,
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
paddingInlineEnd: '8px',
})}
>
{children}
@@ -5,7 +5,6 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '@/features/recording'
@@ -19,7 +18,6 @@ import {
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
@@ -28,6 +26,7 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -38,10 +37,7 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isAdminOrOwner = useIsAdminOrOwner()
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -98,14 +94,17 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {})
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle recording:', error)
}
}
if (hasFeatureWithoutAdminRights) {
if (!isAdminOrOwner) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -146,7 +145,7 @@ export const ScreenRecordingSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={30}>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
@@ -163,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={40}>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
@@ -117,7 +117,10 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {})
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -178,7 +181,7 @@ export const TranscriptSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={30}>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
@@ -195,7 +198,7 @@ export const TranscriptSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={40}>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="article" position="first">
<Text variant="sm">
{data?.transcription_destination ? (
@@ -11,6 +11,7 @@ export const useHumanizeRecordingMaxDuration = () => {
return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
delimiter: ' ',
})
}, [data])
}
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({
roomId,
@@ -228,10 +228,20 @@ export const Conference = ({
posthog.captureException(e)
}}
onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo('feedback')
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
}
}}
onMediaDeviceFailure={(e, kind) => {
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('copy')}
aria-label={isCopied ? t('copied') : t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
@@ -117,7 +117,9 @@ export const ParticipantTile: (
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
setHasKeyboardFocus(true)
const target = event.target as HTMLElement | null
const isFocusVisible = !!target?.matches?.(':focus-visible')
setHasKeyboardFocus(isFocusVisible)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -140,11 +143,53 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -13,27 +13,32 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
}
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<div
<aside
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -58,32 +63,37 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size={'sm'}
size="sm"
square
className={css({ marginRight: '0.5rem' })}
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} />
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
{title}
</Heading>
<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',
}}
>
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
@@ -104,7 +114,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</div>
</aside>
)
type PanelProps = {
@@ -125,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -144,6 +153,7 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -153,6 +163,7 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
@@ -164,7 +175,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen}>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -1,9 +1,10 @@
import { A, Div, Text } from '@/primitives'
import { A, Div, Icon, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -86,7 +87,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<span className="material-symbols">chevron_forward</span>
<Icon type="symbols" name="chevron_forward" />
</div>
</RACButton>
)
@@ -94,10 +95,26 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId } =
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -146,7 +163,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<span className="material-symbols">speech_to_text</span>}
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -154,7 +171,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<span className="material-symbols">mode_standby</span>}
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
@@ -19,6 +20,8 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -31,21 +34,24 @@ export const ParticipantsToggle = ({
display: 'inline-block',
})}
>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<div
className={css({
position: 'absolute',
@@ -10,6 +10,7 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -145,7 +146,7 @@ export const ReactionsToggle = () => {
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji })}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
@@ -9,13 +9,13 @@ import {
} from '../blur'
import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,11 +50,17 @@ export const EffectsConfiguration = ({
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -69,16 +75,95 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
@@ -104,7 +189,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (isSelected(type, options)) {
if (wasSelectedBeforeToggle) {
// Stop processor.
await clearEffect()
} else if (
@@ -131,6 +216,8 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -153,8 +240,28 @@ export const EffectsConfiguration = ({
)
}
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
}
return (
@@ -265,65 +372,60 @@ export const EffectsConfiguration = ({
>
{t('blur.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
<div>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
<ToggleButton
ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div>
<div
className={css({
@@ -343,39 +445,37 @@ export const EffectsConfiguration = ({
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
<ToggleButton
variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
)
})}
</div>
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React, { useEffect } from 'react'
import React from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) {
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
},
preventScroll: true,
})
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -73,6 +73,7 @@ export function MobileControlBar({
/>
<HandToggle />
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
id="controlbar-more-options-trigger"
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
export const getEmojiLabel = (
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
'clapping-hands': t('emojis.clapping-hands', {
defaultValue: 'clapping hands',
}),
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
defaultValue: 'face with tears of joy',
}),
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
defaultValue: 'surprised face',
}),
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
}
return emojiLabels[emoji] ?? emoji
}
@@ -3,7 +3,9 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation, useSearchParams } from 'wouter'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -20,25 +22,40 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const [searchParams] = useSearchParams()
const reasonKey = useMemo(() => {
const state = window.history.state
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
)}
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,6 +2,7 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -10,6 +11,7 @@ import {
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -21,6 +23,7 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -106,11 +109,16 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<span className="material-symbols">speech_to_text</span>
<Icon type="symbols" name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -124,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
</Dialog>
@@ -0,0 +1,40 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -18,6 +18,14 @@ export const useTranscriptionLanguage = () => {
key: RecordingLanguage.ENGLISH,
label: t('language.options.english'),
},
{
key: RecordingLanguage.GERMAN,
label: t('language.options.german'),
},
{
key: RecordingLanguage.DUTCH,
label: t('language.options.dutch'),
},
{
key: RecordingLanguage.AUTOMATIC,
label: t('language.options.auto'),
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime: number
startTime?: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime: number
startTime?: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptions = (
const updateTranscriptionSegments = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
const segment = segments[0]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
})
}
return {
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
updateTranscriptionSegments,
transcriptionSegments,
}
}
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionRows, updateTranscriptions, updateParticipant } =
const { transcriptionSegments, updateTranscriptionSegments } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
}
}, [room, updateTranscriptions])
}, [room, updateTranscriptionSegments])
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
}
}, [room, updateParticipant])
return rows
}, [transcriptionSegments])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -2,10 +2,14 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -20,6 +24,18 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles
}
useEffect(() => {
if (!room) return
const closeSubtitles = () => {
layoutStore.showSubtitles = false
}
room.on(RoomEvent.Disconnected, closeSubtitles)
return () => {
room.off(RoomEvent.Disconnected, closeSubtitles)
}
}, [room])
return {
areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles,
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
export type RestoreFocusOptions = {
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
onOpened?: () => void
onClosed?: () => void
restoreFocusRaf?: boolean
preventScroll?: boolean
}
/**
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
* when the panel/menu closes.
*/
export function useRestoreFocus(
isOpen: boolean,
options: RestoreFocusOptions = {}
) {
const {
resolveTrigger,
onOpened,
onClosed,
restoreFocusRaf = false,
preventScroll = true,
} = options
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const wasOpen = prevIsOpenRef.current
// Just opened
if (!wasOpen && isOpen) {
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 focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
}, [
isOpen,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
])
}
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
},
"slide2": {
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße."
},
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
}
}
}
+45 -9
View File
@@ -2,7 +2,8 @@
"feedback": {
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -85,6 +86,7 @@
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
@@ -191,7 +193,9 @@
"leave": "Verlassen",
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen"
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -205,7 +209,18 @@
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"you": "Sie"
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
}
},
"options": {
@@ -233,15 +248,34 @@
"clear": "Effekt deaktivieren",
"blur": {
"title": "Hintergrundunschärfe",
"light": "Leichte Unschärfe",
"normal": "Unschärfe",
"apply": "Unschärfe aktivieren",
"clear": "Unschärfe deaktivieren"
"status": {
"none": "Hintergrund scharf.",
"light": "Hintergrund leicht verschwommen.",
"strong": "Hintergrund stark verschwommen."
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
},
"virtual": {
"title": "Virtueller Hintergrund",
"apply": "Virtuellen Hintergrund aktivieren",
"clear": "Virtuellen Hintergrund deaktivieren"
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
@@ -256,6 +290,8 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
@@ -75,6 +75,8 @@
"options": {
"french": "Französisch (fr)",
"english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch"
}
}
@@ -70,19 +70,42 @@
},
"article7": {
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
"sections": {
"section1": {
"title": "7.1. Sicherheit und Plattformzugang",
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
"title": "7.1. Verfügbarkeitsniveau",
"paragraphs": [
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
"DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
"DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
]
},
"section2": {
"title": "7.2. Open Source und Lizenzen",
"content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
"title": "7.2. Sicherheit des Dienstes",
"paragraphs": [
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
]
},
"section3": {
"title": "7.3. Support-Management",
"paragraphs": [
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
]
},
"section4": {
"title": "7.4. Kontrolle der Dienstenutzung",
"paragraphs": [
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
]
},
"section5": {
"title": "7.5. Open Source und Lizenzen",
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
}
}
},
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Make the switch to simplicity. Try us now!",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
}
}
}
+51 -15
View File
@@ -2,7 +2,8 @@
"feedback": {
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device"
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by an administrator"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -15,7 +16,7 @@
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Effects",
"effects": "Backgrounds and Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
@@ -38,7 +39,7 @@
},
"join": {
"effects": {
"description": "Apply effects",
"description": "Apply backgrounds and effects",
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
@@ -85,6 +86,7 @@
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": {
"call": "Call:",
"pinCode": "Code:"
@@ -191,7 +193,9 @@
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone"
"closed": "See everyone",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
},
"tools": {
"open": "Hide more tools",
@@ -205,7 +209,18 @@
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"you": "you"
"announce": "{{name}} : {{emoji}}",
"you": "you",
"emojis": {
"thumbs-up": "thumbs up",
"thumbs-down": "thumbs down",
"clapping-hands": "clapping hands",
"red-heart": "red heart",
"face-with-tears-of-joy": "face with tears of joy",
"face-with-open-mouth": "surprised face",
"party-popper": "party popper",
"folded-hands": "folded hands"
}
}
},
"options": {
@@ -216,7 +231,7 @@
"screenRecording": "Screen recording",
"transcript": "Transcription",
"username": "Update Your Name",
"effects": "Apply effects",
"effects": "Backgrounds and Effects",
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
@@ -233,15 +248,34 @@
"clear": "Disable effect",
"blur": {
"title": "Background blur",
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
"status": {
"none": "Background clear.",
"light": "Background slightly blurred.",
"strong": "Background strongly blurred."
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
},
"virtual": {
"title": "Virtual background",
"apply": "Enable virtual background",
"clear": "Disable virtual background"
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
},
"faceLandmarks": {
"title": "Visual Effects",
@@ -256,9 +290,11 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
"effects": "Effects",
"effects": "Backgrounds and Effects",
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
@@ -268,7 +304,7 @@
},
"content": {
"participants": "participants",
"effects": "effects",
"effects": "Backgrounds and Effects",
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
@@ -543,7 +579,7 @@
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply visual effects",
"effects": "Apply backgrounds and effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
@@ -75,6 +75,8 @@
"options": {
"french": "French (fr)",
"english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic"
}
}
@@ -106,12 +108,18 @@
"label": "Language"
},
"settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription"
}
}
@@ -70,19 +70,42 @@
},
"article7": {
"title": "7. DINUM Commitments and Responsibilities",
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
"sections": {
"section1": {
"title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"title": "7.1. Service Availability",
"paragraphs": [
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
"The service is available 24/7, except during maintenance windows.",
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.",
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.",
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
"Service unavailability does not give rise to any compensation of any kind."
]
},
"section2": {
"title": "7.2 Open Source and Licenses",
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
"title": "7.2. Service Security",
"paragraphs": [
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
]
},
"section3": {
"title": "7.3. Support Management",
"paragraphs": [
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
"This support can be reached via our online contact form.",
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
]
},
"section4": {
"title": "7.4. Monitoring Service Use",
"paragraphs": [
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
]
},
"section5": {
"title": "7.5. Open Source and Licenses",
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
}
}
},
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
}
}
}
+52 -16
View File
@@ -2,7 +2,8 @@
"feedback": {
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -15,7 +16,7 @@
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Effets d'arrière-plan",
"effects": "Arrière-plans et effets",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -39,8 +40,8 @@
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière-plan",
"title": "Effets",
"description": "Arrière-plans et effets",
"title": "Arrière-plans et effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
@@ -85,6 +86,7 @@
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
@@ -191,7 +193,9 @@
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants"
"closed": "Afficher les participants",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
},
"tools": {
"open": "Masquer les outils de réunion",
@@ -205,7 +209,18 @@
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"you": "vous"
"announce": "{{name}} : {{emoji}}",
"you": "vous",
"emojis": {
"thumbs-up": "pouce levé",
"thumbs-down": "pouce baissé",
"clapping-hands": "applaudissements",
"red-heart": "cœur",
"face-with-tears-of-joy": "visage qui rit",
"face-with-open-mouth": "visage étonné",
"party-popper": "confettis",
"folded-hands": "mains jointes"
}
}
},
"options": {
@@ -216,7 +231,7 @@
"screenRecording": "Enregistrement",
"transcript": "Transcription",
"username": "Choisir votre nom",
"effects": "Effets d'arrière-plan",
"effects": "Arrière-plans et effets",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -230,18 +245,37 @@
"cameraDisabled": "Votre caméra est désactivée.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effect",
"clear": "Désactiver l'effet",
"blur": {
"title": "Flou d'arrière-plan",
"light": "Flou léger",
"normal": "Flou",
"apply": "Activer le flou",
"clear": "Désactiver le flou"
"status": {
"none": "Arrière-plan net.",
"light": "Arrière-plan légèrement flouté.",
"strong": "Arrière-plan fortement flouté."
},
"light": {
"apply": "Flouter l'arrière-plan",
"clear": "Désactiver le flou"
},
"normal": {
"apply": "Flouter encore plus l'arrière-plan",
"clear": "Désactiver le flou"
}
},
"virtual": {
"title": "Arrière-plan virtuel",
"apply": "Activer l'arrière-plan virtuel",
"clear": "Désactiver l'arrière-plan virtuel"
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
},
"faceLandmarks": {
"title": "Effets visuels",
@@ -256,9 +290,11 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
"effects": "Effets",
"effects": "Arrière-plans et effets",
"chat": "Messages dans l'appel",
"transcript": "Transcrire",
"screenRecording": "Enregistrer",
@@ -543,7 +579,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Effets d'arrière-plan",
"effects": "Arrière-plans et effets",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
@@ -75,6 +75,8 @@
"options": {
"french": "Français (fr)",
"english": "Anglais (en)",
"dutch": "Hollandais (nl)",
"german": "Allemand (de)",
"auto": "Automatique"
}
}
@@ -106,12 +108,18 @@
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres",
"accessibility": {
"announceReactions": {
"label": "Vocaliser les réactions"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription"
}
}
@@ -16,7 +16,7 @@
},
"article4": {
"title": "4. Utilisation",
"content": "L'utilisation du service est libre et gratuite, pour administration un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
"content": "L'utilisation du service est libre et gratuite, pour administrer un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
},
"article5": {
"title": "5. Fonctionnalités",
@@ -70,19 +70,42 @@
},
"article7": {
"title": "7. Engagements et responsabilités de la DINUM",
"content": "La DINUM est responsable de ladministration générale du service. Elle transmet aux agents toutes les informations nécessaires pour faciliter son utilisation. Elle propose une assistance technique et fonctionnelle en vue dassurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
"sections": {
"section1": {
"title": "7.1. Sécurité et accès à la plateforme",
"content": "La DINUM s'engage à la sécurisation de Visio, notamment en prenant toutes les mesures nécessaires permettant de garantir la sécurité et la confidentialité des informations fournies.",
"title": "7.1. Niveau de disponibilité",
"paragraphs": [
"La DINUM s'engage à fournir les moyens nécessaires et raisonnables pour assurer un accès continu à Visio.",
"La DINUM se réserve le droit de faire évoluer, de modifier ou de suspendre, sans préavis, le service pour des raisons de maintenance ou pour tout autre motif jugé nécessaire.",
"La DINUM se réserve le droit de suspendre ou supprimer un compte utilisateur ou utilisatrice du service qui aurait méconnu les présentes modalités d'utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l'encontre de l'utilisateur ou l'utilisatrice."
"La plage douverture du Service est 24h/24 7j/7, hors période dindisponibilité pour maintenance.",
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas dincident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
"Dans le cadre dune maintenance de lenvironnement dexécution du service, La DINUM se réserve le droit de suspendre temporairement le fonctionnement du service. Elle tient informée les utilisateurs au minimum 48h à lavance. En cas durgence, cette suspension peut intervenir sans préavis.",
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données en back office, des opérations de mise en production ou des changements darchitecture.",
"Lindisponibilité du service nouvre droit à aucune compensation de quelque nature que ce soit."
]
},
"section2": {
"title": "7.2 Open Source et Licences",
"content": "Le code source de Visio est libre et disponible ici : https://github.com/suitenumerique/meet\nLes contenus proposés par la DINUM sont sous Licence Ouverte, à l'exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
"title": "7.2. Sécurité du service",
"paragraphs": [
"La DINUM prend toutes les précautions utiles pour préserver la sécurité de la plateforme et des outils mis en œuvre dans le cadre du service, notamment sagissant de laccès au service, de la gestion des comptes utilisateurs et du traitement des données collectées."
]
},
"section3": {
"title": "7.3. Gestion du support",
"paragraphs": [
"La DINUM assure le support de premier niveau auprès des utilisateurs, exclusivement sur les parties techniques du service.",
"Ce support est joignable sur notre formulaire de contact en ligne.",
"Lobjectif de réponse aux sollicitations est de deux jours ouvrés à compter de lenvoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
]
},
"section4": {
"title": "7.4. Contrôle de lutilisation du service",
"paragraphs": [
"La DINUM se réserve le droit de suspendre ou supprimer un compte agent qui aurait méconnu les présentes modalités dutilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à lencontre de lagent concerné.",
"La suspension ou la révocation dun ou plusieurs comptes ne donne lieu à aucune compensation daucune sorte."
]
},
"section5": {
"title": "7.5. Open Source et Licences",
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte, à lexception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
}
}
},
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Stap over op eenvoud. Probeer ons nu!",
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
},
"slide2": {
"title": "Houdt groepsgesprekken zonder limieten",
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
},
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
}
}
}
+45 -9
View File
@@ -2,7 +2,8 @@
"feedback": {
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -85,6 +86,7 @@
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
@@ -191,7 +193,9 @@
"leave": "Vertrekken",
"participants": {
"open": "Verberg iedereen",
"closed": "Toon iedereen"
"closed": "Toon iedereen",
"count_one": "{{count}} deelnemer",
"count_other": "{{count}} deelnemers"
},
"tools": {
"open": "Meer tools verbergen",
@@ -205,7 +209,18 @@
"reactions": {
"button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}",
"you": "U"
"announce": "{{name}} : {{emoji}}",
"you": "U",
"emojis": {
"thumbs-up": "duim omhoog",
"thumbs-down": "duim omlaag",
"clapping-hands": "applaudisserende handen",
"red-heart": "rood hart",
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
"face-with-open-mouth": "verrast gezicht",
"party-popper": "feestknaller",
"folded-hands": "gevouwen handen"
}
}
},
"options": {
@@ -233,15 +248,34 @@
"clear": "Effect uitschakelen",
"blur": {
"title": "Achtergrondvervaging",
"light": "Lichte vervaging",
"normal": "Vervaging",
"apply": "Vervaging inschakelen",
"clear": "Vervaging uitschakelen"
"status": {
"none": "Achtergrond scherp.",
"light": "Achtergrond licht vervaagd.",
"strong": "Achtergrond sterk vervaagd."
},
"light": {
"apply": "Vervaag je achtergrond lichtjes",
"clear": "Vervaging uitschakelen"
},
"normal": {
"apply": "Vervaag je achtergrond",
"clear": "Vervaging uitschakelen"
}
},
"virtual": {
"title": "Virtuele achtergrond",
"apply": "Virtuele achtergrond inschakelen",
"clear": "Virtuele achtergrond uitschakelen"
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
},
"faceLandmarks": {
"title": "Visuele effecten",
@@ -256,6 +290,8 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
@@ -75,6 +75,8 @@
"options": {
"french": "Frans (fr)",
"english": "Engels (en)",
"dutch": "Nederlands (nl)",
"german": "Duits (de)",
"auto": "Automatisch"
}
}
@@ -70,19 +70,42 @@
},
"article7": {
"title": "7. DINUM Commitments and Responsibilities",
"content": "De DINUM is verantwoordelijk voor de algemene administratie van de dienst. Zij verstrekt de medewerkers alle nodige informatie om het gebruik te vergemakkelijken. Zij biedt technische en functionele ondersteuning om de goede werking van de dienst te verzekeren. Zij informeert de gebruikers met elk redelijk middel over moeilijkheden die deze goede werking kunnen beïnvloeden.",
"sections": {
"section1": {
"title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"title": "7.1. Beschikbaarheidsniveau",
"paragraphs": [
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.",
"DINUM streeft naar een jaarlijkse beschikbaarheid van 99,5% van de dienst, buiten geplande onbeschikbaarheden. Bij een incident of onderhoud mikt zij op een hersteltermijn van 72 uur buiten kantooruren.",
"In het kader van onderhoud van de uitvoeringsomgeving van de dienst behoudt DINUM zich het recht voor de werking tijdelijk op te schorten. Zij informeert de gebruikers minstens 48 uur vooraf. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.",
"Deze uitzonderlijke stilstanden kunnen bijvoorbeeld nodig zijn voor backoffice-gegevensbeheer, inproductiestellingen of architectuurwijzigingen.",
"Onbeschikbaarheid van de dienst geeft geen recht op enige vorm van compensatie."
]
},
"section2": {
"title": "7.2 Open Source and Licenses",
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
"title": "7.2. Beveiliging van de dienst",
"paragraphs": [
"DINUM neemt alle nodige voorzorgsmaatregelen om de veiligheid van het platform en de in het kader van de dienst gebruikte hulpmiddelen te bewaren, met name wat betreft de toegang tot de dienst, het beheer van gebruikersaccounts en de verwerking van verzamelde gegevens."
]
},
"section3": {
"title": "7.3. Supportbeheer",
"paragraphs": [
"DINUM verzorgt eerstelijns support voor de gebruikers, uitsluitend over de technische onderdelen van de dienst.",
"Deze support is bereikbaar via ons online contactformulier.",
"De streefantwoordtermijn op verzoeken is twee werkdagen vanaf de verzending; deze termijn kan variëren naargelang de complexiteit en het aantal te behandelen aanvragen."
]
},
"section4": {
"title": "7.4. Controle van het gebruik van de dienst",
"paragraphs": [
"DINUM behoudt zich het recht voor om een agentaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden heeft geschonden, onverminderd eventuele strafrechtelijke en civielrechtelijke stappen tegen de betrokken agent.",
"De schorsing of intrekking van één of meer accounts geeft geen recht op enige vorm van compensatie."
]
},
"section5": {
"title": "7.5. Open Source en licenties",
"content": "De broncode van de dienst is vrij beschikbaar op: https://github.com/suitenumerique/meet. De inhoud die door DINUM wordt aangeboden valt onder een Open License, met uitzondering van logo's en iconografische en fotografische voorstellingen die onder hun eigen licenties kunnen vallen."
}
}
},
+21 -22
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
import { ReactNode, forwardRef } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,25 +17,24 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode
}
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
)
+71
View File
@@ -0,0 +1,71 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { ComponentPropsWithoutRef } from 'react'
const iconRecipe = cva({
base: {
fontWeight: 'normal',
fontStyle: 'normal',
display: 'inline-block',
lineHeight: 1,
textTransform: 'none',
letterSpacing: 'normal',
wordWrap: 'normal',
whiteSpace: 'nowrap',
direction: 'ltr',
},
variants: {
type: {
icons: {
fontFamily: 'Material Icons Outlined',
webkitFontSmoothing: 'antialiased',
mozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontFeatureSettings: '"liga"',
},
symbols: {
fontFamily: 'Material Symbols Outlined Variable',
},
},
size: {
sm: {
fontSize: '18px',
},
md: {
fontSize: '24px',
},
lg: {
fontSize: '32px',
},
xl: {
fontSize: '40px',
},
},
},
defaultVariants: {
type: 'icons',
size: 'md',
},
})
export type IconRecipeProps = RecipeVariantProps<typeof iconRecipe>
export type IconProps = IconRecipeProps &
ComponentPropsWithoutRef<'span'> & {
name: string
}
export const Icon = ({ name, ...props }: IconProps) => {
const [variantProps, componentProps] = iconRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<span
translate="no"
aria-hidden="true"
className={[iconRecipe(variantProps), className].join(' ')}
{...remainingComponentProps}
>
{name}
</span>
)
}
+22 -21
View File
@@ -1,10 +1,10 @@
import { forwardRef, ReactNode } from 'react'
import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
@@ -16,24 +16,25 @@ export type ToggleButtonProps = RACToggleButtonProps &
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
({ tooltip, tooltipType, ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
)
ToggleButton.displayName = 'ToggleButton'
@@ -0,0 +1,88 @@
import { type ReactNode, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
tooltip: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
tooltip,
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const getPosition = () => {
if (!wrapperRef.current) return null
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
left: rect.left + rect.width / 2,
}
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</div>
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
className={css({
position: 'fixed',
padding: '2px 8px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
borderRadius: '4px',
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
document.body
)}
</>
)
}
+8 -1
View File
@@ -31,6 +31,12 @@ export const buttonRecipe = cva({
borderRadius: 4,
'--square-padding': '0',
},
compact: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
},
square: {
true: {
@@ -106,7 +112,8 @@ export const buttonRecipe = cva({
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
boxShadow:
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
},
'&[data-disabled]': {
backgroundColor: 'greyscale.100',
+1
View File
@@ -30,3 +30,4 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
export { Icon } from './Icon'
-5
View File
@@ -43,11 +43,6 @@ export const routes: Record<
feedback: {
name: 'feedback',
path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute,
},
legalTerms: {
+76
View File
@@ -0,0 +1,76 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
type AccessibilityState = {
announceReactions: boolean
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
}
function getAccessibilityState(): AccessibilityState {
try {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
announceReactions:
typeof parsed.announceReactions === 'boolean'
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
}
}
// Legacy migration: if the setting was previously stored in notifications
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (legacy) {
try {
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
if (typeof parsedLegacy?.announceReactions === 'boolean') {
const migratedState: AccessibilityState = {
...DEFAULT_STATE,
...parsedLegacy,
announceReactions: parsedLegacy.announceReactions,
}
try {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(migratedState)
)
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
} catch {
// ignore persistence issues during migration
}
return migratedState
}
} catch {
// ignore legacy parsing issues
}
}
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const accessibilityStore = proxy<AccessibilityState>(
getAccessibilityState()
)
subscribe(accessibilityStore, () => {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(accessibilityStore)
)
})
+2
View File
@@ -3,6 +3,8 @@ import { proxy } from 'valtio'
export enum RecordingLanguage {
ENGLISH = 'en',
FRENCH = 'fr',
DUTCH = 'nl',
GERMAN = 'de',
AUTOMATIC = 'auto',
}
+14
View File
@@ -0,0 +1,14 @@
import { css } from '@/styled-system/css'
// Reusable visually hidden style for screen-reader-only content
export const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
})
-27
View File
@@ -1,27 +0,0 @@
@import '@fontsource/material-icons-outlined';
.material-icons,
.material-symbols {
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.material-icons {
font-family: 'Material Icons Outlined';
}
.material-symbols {
font-family: 'Material Symbols Outlined Variable';
}
+45 -1
View File
@@ -1,5 +1,4 @@
@import './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities;
html,
body,
@@ -7,6 +6,18 @@ body,
height: 100%;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
* {
outline: 2px solid transparent;
}
@@ -42,3 +53,36 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
transform: translateY(0);
}
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-icons-outlined/install
*/
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-display: block;
font-weight: 400;
src: url(@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2)
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-symbols-outlined/install
*/
/* material-symbols-outlined-latin-wght-normal */
@font-face {
font-family: 'Material Symbols Outlined Variable';
font-style: normal;
font-display: block;
font-weight: 100 700;
src: url(@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
+1
View File
@@ -4,4 +4,5 @@
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
ACCESSIBILITY: 'app_accessibility_settings',
} as const
@@ -49,6 +49,7 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
ALLOW_UNREGISTERED_ROOMS: 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'}"
@@ -98,7 +99,7 @@ backend:
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
@@ -1,6 +1,9 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
image:
tag: v1.11.0
egress:
log_level: debug
ws_url: ws://livekit-livekit-server:80
@@ -51,6 +51,7 @@ backend:
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
ALLOW_UNREGISTERED_ROOMS: 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'}"
@@ -149,7 +150,7 @@ summary:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -186,7 +187,7 @@ celeryTranscribe:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -224,7 +225,7 @@ celerySummarize:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -266,6 +267,7 @@ agents:
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
image:
repository: localhost:5001/meet-agents
@@ -71,6 +71,7 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.1.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.1.0",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.1.0",
"version": "1.3.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.1.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.1.0",
"version": "1.3.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.1.0",
"version": "1.3.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.1.0"
version = "1.3.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+1 -1
View File
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
whisperx_max_retries: int = 0
# ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None
whisperx_allowed_languages: Set[str] = {"en", "fr"}
whisperx_allowed_languages: Set[str] = {"en", "fr", "de", "nl"}
llm_base_url: str
llm_api_key: SecretStr
llm_model: str
+7 -1
View File
@@ -21,8 +21,14 @@ class FileService:
"""Initialize FileService with MinIO client and configuration."""
self._logger = logger
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio(
settings.aws_s3_endpoint_url,
endpoint,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,