Compare commits

...

103 Commits

Author SHA1 Message Date
Cyril ed39758ab4 wip 2026-02-19 11:04:33 +01:00
Cyril cb2af9da96 🎨(frontend) fix prettier formatting for room ui components
align formatting with prettier checks to keep ci passing consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:52:04 +01:00
Cyril ed97f09aad 🐛(frontend) fix focus restore for chevron menus
Ensure focus returns to camera chevron after effects panel closes.
2026-02-05 10:31:24 +01:00
Cyril c803bd9170 fixup! ♻️(frontend) replace side panel context with valtio store 2026-02-05 10:05:53 +01:00
Cyril daa3d6bcc6 ️(frontend) centralize popover focus and reuse side panel close
move popover focus handling into primitive and reuse close helper
2026-02-05 10:05:52 +01:00
Cyril 096968c222 ♻️(frontend) replace side panel context with valtio store
centralize refs/triggers in layout store to keep focus behavior
2026-02-05 10:05:52 +01:00
Cyril 71637dc199 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:52 +01:00
Cyril 8c60cfeccd 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:51 +01:00
Cyril f711346073 ️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
2026-02-05 10:03:28 +01:00
Cyril 5fa287c2b0 ️(frontend) add escape key hook for overlays
Use shared hook to close side panel and reactions
2026-02-05 10:03:27 +01:00
Cyril f5cc76861d 📝(docs) add side panel focus pattern
Document trigger ref and panel ref focus workflow
2026-02-05 10:03:27 +01:00
Cyril 87bc5ff6ed ️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
2026-02-05 10:03:26 +01:00
Cyril 17602ab548 ♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
2026-02-05 10:03:26 +01:00
Cyril 4fbcf8d64e (frontend) add auto refocus for effects menu
Restore focus to options button on close, focus first element on open
2026-02-05 10:03:25 +01:00
Cyril d4d484b9b1 🔧(git) ignore personal rules.md file
Allow developers to keep personal rules.md files without committing them
2026-02-05 10:03:25 +01:00
Cyril cfc6b90bd7 ️(frontend) add visible focus ring on restore focus
Show focus ring when restoring focus, keyboard navigation only
2026-02-05 10:03:25 +01:00
Cyril 716e11b5b3 ️(frontend) fix form labels and autocomplete wiring
Ensure labels map to inputs and avoid empty describedby output
2026-02-04 09:28:15 +01:00
lebaudantoine 88a1136dfd ♻️(backend) refactor ApplicationViewSet to use a basic ViewSet
This endpoint only exposes a custom action for token generation and does not
rely on serializers or querysets. Using ViewSet is more appropriate here, as
it provides routing without enforcing standard CRUD patterns or requiring a
serializer_class.

This removes unnecessary constraints and avoids warnings related to missing
serializer configuration, while better reflecting the actual responsibility of
this view.

I noticed this bug from Sentry issue 241308
2026-02-03 16:22:06 +01:00
lebaudantoine 90633928a8 💚(backend) reactivate trivy scan on backend image
Protobuff has been patched, rebuilding the backend image should be
enough with pip to pull its latest version, which fixes the CVE.
2026-02-03 11:57:02 +01:00
lebaudantoine fd894eb61f 🔧(compose) configure LiveKit webhooks in the local Docker Compose stack
Without this configuration, LiveKit does not notify the backend when a recording
starts, leaving it stuck in a “starting recording” state.

Thanks to @leobouloc for spotting the issue.
2026-01-29 18:22:00 +01:00
lebaudantoine bb64532cff 🔖(minor) bump release to 1.5.0 2026-01-28 21:28:55 +01:00
Cyril 692c55ed1b Merge branch 'refactor/issue-921-generic-sr-announcer' 2026-01-28 17:07:43 +01:00
lebaudantoine df616ae711 🩹(doc) fix github rendering of docker compose doc
The docker compose rendering was broken because of a recent merge.
Fix it. I've also fixed other minor issues.
2026-01-28 16:17:53 +01:00
Cyril 021d7a7e06 ️(frontend) centralize aria-live announcements in store
avoid per-feature live regions and reduce a11y duplication.
2026-01-28 14:01:35 +01:00
Andrew Hunter f2a3e7c8de 📝(doc) Fix typo 2026-01-28 12:13:19 +01:00
Andrew Hunter cf07ceb67e 🔧(docker) Fix incorrect env variable
Incorrect capitalization prevents correct MEET_HOST variable
subsitution.
2026-01-28 12:13:19 +01:00
Andrew Hunter ea7fb5fc27 📝(doc) Use an empty directory for postgres
Use an empty directory for postgres data, otherwise it will complain the
directory is not empty and fail to start.
2026-01-28 12:13:19 +01:00
Andrew Hunter 6e8a6ce82a 📝(doc) Add -p swich to mkdir
Add the -p switch to create the parent directory before we try to cd
into it.
2026-01-28 12:13:19 +01:00
Andrew Hunter ce960ae330 📝 (doc) Add key gen example
Add a API key generation example using OpenSSL.
2026-01-28 12:13:19 +01:00
Cyril f9dd2e1909 ️(frontend) add global screen reader announcer
centralize live region rendering with a shared announce hook.
2026-01-28 11:44:39 +01:00
Cyril 9023e54352 ️(frontend) add screen reader announcer store
create shared state for screen reader announcements.
2026-01-28 11:40:54 +01:00
Cyril 8295574616 (frontend) sr pin/unpin announcements with dedicated messages
improves accessibility by announcing pin/unpin on state change
2026-01-28 11:13:09 +01:00
Cyril db15c8b6cc ️(frontend) adjust visual-only tooltip a11y labels
Ensure tooltips stay visual while exposing correct aria-labels.
2026-01-28 10:08:01 +01:00
Cyril e1aeec6053 ️(frontend) adjust sr announcements for idle disconnect timer
reduces screen reader noise while keeping key countdown cues
2026-01-27 22:12:55 +01:00
lebaudantoine c5aa762e11 📝(doc) update mosacloud link in the list of saas instances
Link has changed. Update it.
2026-01-27 18:38:34 +01:00
lebaudantoine 8f710a4626 🔒️(frontend) fix an XSS vulnerability on the recording page
An XSS vulnerability was identified by an open-source contributor. While the
impact was limited, only a room owner could inject the content and then view the
recording page, it is important to address, especially before introducing
multi-owner support.
2026-01-27 14:12:45 +01:00
virgile-deville 60d1338eff 📝(readme) mention french state wide deployment
To indicate product maturity to reusers

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-01-26 12:04:16 +01:00
lebaudantoine f8436d9ae2 🔖(minor) bump release to 1.4.0 2026-01-25 20:02:37 +01:00
lebaudantoine 39fb273201 💩(ci) disable temporarily Trivy scan step for backend image
A new vulnerability (CVE-2026-0994) was reported and is not yet fixed.
It affects protobuf libraries used by the livekit-api Python package.

A fix is in progress upstream, but the related PR has not yet been merged or
released. Since a release is required tonight, the Trivy scan step is
temporarily disabled to allow the build to proceed. This should be re-enabled
once a patched version is available.

https://github.com/protocolbuffers/protobuf/pull/25239
2026-01-25 18:01:13 +01:00
lebaudantoine d101459115 (frontend) add configurable external redirect for unauthenticated users
Offer a way to redirect unauthenticated users to an external home page when they
visit the app, allowing a more marketing-focused entry point with a clearer
value proposition.

In many self-hosted deployments, the default unauthenticated home page is not
accessible or already redirects elsewhere. To ensure resilience, the client
briefly checks that the target page is reachable and falls back to the default
page if not.
2026-01-25 16:49:56 +01:00
aleb_the_flash 88696a23fd 🩹(doc) update link to the environment variables
Link was invalid. Update it to point to the chart's README file.
Please note this file might be removed.
2026-01-25 00:17:50 +01:00
Cyril 13d26a76b3 (frontend) scope scrollbar gutter override to video rooms
limit scrollbar gutter override to video conference context
2026-01-25 00:07:51 +01:00
lebaudantoine b675517a60 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:43:29 +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
136 changed files with 2749 additions and 669 deletions
+16
View File
@@ -147,6 +147,14 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -178,6 +186,14 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
+3
View File
@@ -77,6 +77,9 @@ db.sqlite3
*.iml *.iml
.devcontainer .devcontainer
# Personal rules/config files
rules.md
# Egress output # Egress output
docker/livekit/out docker/livekit/out
+73 -3
View File
@@ -8,19 +8,89 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ♿️(frontend) fix form labels and autocomplete wiring #932
- ♿(frontend) improve menu focus management #869
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿(frontend) improve menu focus management #869
- ♿️(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
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
## [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 - ✨(agent) support Kyutai client for subtitle
- ✨(frontend) remove the beta badge
- ✨(all) support starting transcription and recording simultaneously - ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording - ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language - ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file - ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording - ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service - ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side - ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22 ## [1.1.0] - 2025-12-22
### Added ### Added
- ✨(backend) enable user creation via email for external integrations - ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls - ✨(summary) add Langfuse observability for LLM API calls
@@ -32,6 +102,6 @@ and this project adheres to
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803 - ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813 - ♿️(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) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807 - ♿️(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 FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation # 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 # Upgrade system packages to install security updates
RUN apk update && \ RUN apk update && \
+4 -1
View File
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were continuously adding new features to enhance your experience, with the latest updates coming soon! Were continuously adding new features to enhance your experience, with the latest updates coming soon!
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents ## Table of Contents
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up| | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up| | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing ## Contributing
@@ -3,3 +3,8 @@ redis:
address: redis:6379 address: redis:6379
keys: keys:
devkey: secret devkey: secret
webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- env.d/postgresql - env.d/postgresql
- env.d/common - env.d/common
volumes: volumes:
- ./data/databases/backend:/var/lib/postgresql/data/pgdata - ./data/databases/backend:/var/lib/postgresql/data
redis: redis:
image: redis:5 image: redis:5
+2 -2
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment: ### Step 1: Prepare your working environment:
```bash ```bash
mkdir keycloak/env.d && cd keycloak mkdir -p keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
#### Step 3: Get Client Credentials #### Step 3: Get Client Credentials
1. Go to the "Credentials" tab. 1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret. 2. Copy the client ID (`meet` in this example) and the client secret.
+13 -4
View File
@@ -1,6 +1,6 @@
# Installation with docker compose # 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 ## Requirements
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
## Step 2: Configuration ## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md). Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
In this example, we assume the following services: In this example, we assume the following services:
@@ -129,7 +129,7 @@ The following ports will need to be opened:
- 7881/tcp - WebRTC ICE over TCP - 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP - 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the follwoing: If you are using ufw, enter the following:
``` ```
ufw allow 80/tcp ufw allow 80/tcp
ufw allow 443/tcp ufw allow 443/tcp
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
# external: true # external: true
``` ```
#### Caddy Reverse Proxy
Expose the Frontend port to the host
```yaml
frontend:
ports:
- "8086:8086"
```
## Step 5: Start Meet ## Step 5: Start Meet
You are ready to start your Meet application ! You are ready to start your Meet application !
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld. Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created. The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application ## How to upgrade your Meet application
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev" APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password" 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_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false AWS_S3_SECURE_ACCESS=false
+1 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending. #DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique" DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png" DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
# Backend url # Backend url
MEET_BASE_URL="https://${MEET_HOST}" MEET_BASE_URL="https://${MEET_HOST}"
+5 -4
View File
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber") TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram") STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider(): def create_stt_provider():
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
if participant.identity in self._sessions: if participant.identity in self._sessions:
return self._sessions[participant.identity] return self._sessions[participant.identity]
session = AgentSession( vad = self.ctx.proc.userdata.get("vad", None)
vad=self.ctx.proc.userdata["vad"], session = AgentSession(vad=vad)
)
room_io = RoomIO( room_io = RoomIO(
agent_session=session, agent_session=session,
room=self.ctx.room, room=self.ctx.room,
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload voice activity detection model.""" """Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load() if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.1.0" version = "1.5.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.3.10", "livekit-agents==1.3.10",
+7 -3
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password 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 from django.core.validators import validate_email
import jwt import jwt
@@ -28,7 +28,7 @@ from . import authentication, permissions, serializers
logger = getLogger(__name__) logger = getLogger(__name__)
class ApplicationViewSet(viewsets.GenericViewSet): class ApplicationViewSet(viewsets.ViewSet):
"""API endpoints for application authentication and token generation.""" """API endpoints for application authentication and token generation."""
@decorators.action( @decorators.action(
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
) )
try: try:
user = models.User.objects.get(email=email) user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e: except models.User.DoesNotExist as e:
if ( if (
settings.APPLICATION_ALLOW_USER_CREATION settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
) )
else: else:
raise drf_exceptions.NotFound("User not found.") from e 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) now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or []) scope = " ".join(application.scopes or [])
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings): def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token.""" """Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey" settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com") UserFactory(email="User.Family@example.com")
application = ApplicationFactory( application = ApplicationFactory(
active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], 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_id": application.client_id,
"client_secret": plain_secret, "client_secret": plain_secret,
"grant_type": "client_credentials", "grant_type": "client_credentials",
"scope": user.email, "scope": "user.family@example.com",
}, },
format="json", format="json",
) )
+3
View File
@@ -336,6 +336,9 @@ class Base(Configuration):
"feedback": values.DictValue( "feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None {}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
), ),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue( "use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
), ),
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "1.1.0" version = "1.5.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0", "whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1", "mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7", "livekit-api==1.0.7",
"aiohttp==3.13.2", "aiohttp==3.13.3",
] ]
[project.urls] [project.urls]
+34
View File
@@ -28,3 +28,37 @@ export default {
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
## Side Panel Focus Pattern
We use a consistent focus management pattern for side panels:
- **Open**: focus the first actionable element inside the panel.
- **Close**: restore focus to the button that opened the panel.
Implementation summary:
1. A provider stores a `panelRef` and a registry of trigger refs (`setTrigger/getTrigger`).
2. Each trigger button registers itself with `setTrigger("key", el)`.
3. Panel content uses `useRestoreFocus` with:
- `resolveTrigger` → returns `getTrigger("key")`.
- `onOpened` → finds the first actionable element inside `panelRef`.
Example:
```tsx
// Trigger button
;<ToggleButton ref={(el) => setTrigger('tools', el)} />
// Panel content
useRestoreFocus(isOpen, {
resolveTrigger: (activeEl) => getTrigger('tools') ?? activeEl,
onOpened: () => {
const first = panelRef.current?.querySelector(
'[data-attr="tools-list"] button'
)
// Leading semicolon avoids ASI issues when a line starts with '('
;(first as HTMLElement | null)?.focus({ preventScroll: true })
},
})
```
+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="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest"> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
</head> </head>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.1.0", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.1.0", "version": "1.5.0",
"dependencies": { "dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30", "@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6", "@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.1.0", "version": "1.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
+1
View File
@@ -17,6 +17,7 @@ export interface ApiConfig {
feedback: { feedback: {
url: string url: string
} }
external_home_url?: string
silence_livekit_debug_logs?: boolean silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean is_silent_login_enabled?: boolean
custom_css_url?: string custom_css_url?: string
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer> <SlideContainer>
{SLIDES.map((slide, index) => ( {SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={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}> <TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading> <Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body> <Body>{t(`${slide.key}.body`)}</Body>
+27 -1
View File
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider' import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink' import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts' import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom' import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { LoadingScreen } from '@/components/LoadingScreen'
const Columns = ({ children }: { children?: ReactNode }) => { const Columns = ({ children }: { children?: ReactNode }) => {
return ( return (
@@ -155,9 +156,34 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom() const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null) const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig() const { data } = useConfig()
useEffect(() => {
const checkSiteAndRedirect = async () => {
if (!data?.external_home_url) return
if (isLoggedIn === false) {
try {
await fetch(data.external_home_url, {
method: 'HEAD', // Use HEAD to avoid downloading the full page
mode: 'no-cors', // Needed for cross-origin requests
})
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
console.error('Site is not reachable:', error)
}
}
}
checkSiteAndRedirect()
}, [isLoggedIn, data])
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
return <LoadingScreen header={false} footer={false} delay={0} />
}
return ( return (
<UserAware> <UserAware>
<Screen> <Screen>
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
))} ))}
{/* Article 7 */} {/* Article 7 */}
<H lvl={2} margin={false}> <H lvl={2}>{t('articles.article7.title')}</H>
{t('articles.article7.title')} <P>{t('articles.article7.content')}</P>
</H>
{/* Section 7.1 */} {/* Section 7.1 */}
<H lvl={3} bold> <H lvl={3} bold>
{t('articles.article7.sections.section1.title')} {t('articles.article7.sections.section1.title')}
</H> </H>
<P>{t('articles.article7.sections.section1.content')}</P>
{ensureArray( {ensureArray(
t('articles.article7.sections.section1.paragraphs', { t('articles.article7.sections.section1.paragraphs', {
returnObjects: true, returnObjects: true,
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
<H lvl={3} bold> <H lvl={3} bold>
{t('articles.article7.sections.section2.title')} {t('articles.article7.sections.section2.title')}
</H> </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> <P>
{t('articles.article7.sections.section2.content') {t('articles.article7.sections.section5.content')
.split('https://github.com/suitenumerique/meet')[0] .split('https://github.com/suitenumerique/meet')[0]
.replace('https://github.com/suitenumerique/meet', '')}{' '} .replace('https://github.com/suitenumerique/meet', '')}{' '}
<A href="https://github.com/suitenumerique/meet" color="primary"> <A href="https://github.com/suitenumerique/meet" color="primary">
https://github.com/suitenumerique/meet https://github.com/suitenumerique/meet
</A> </A>
{'. '}
{ {
t('articles.article7.sections.section2.content').split( t('articles.article7.sections.section5.content').split(
'https://github.com/suitenumerique/meet' 'https://github.com/suitenumerique/meet'
)[1] )[1]
} }
@@ -66,16 +66,22 @@ export function ToastRecordingRequest({
name: participant?.name, name: participant?.name,
})} })}
{!options.isMenuOpen && ( {!options.isMenuOpen && (
<Button <div
size="sm"
variant="text"
className={css({ 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> </HStack>
</StyledToastContainer> </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 { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives' import { Button, Icon, Text } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses' import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -42,6 +42,17 @@ export const ControlsButton = ({
}: ControlsButtonProps) => { }: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix }) 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 room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected const isRoomConnected = room.state == ConnectionState.Connected
@@ -97,6 +108,7 @@ export const ControlsButton = ({
fullWidth fullWidth
onPress={handle} onPress={handle}
isDisabled={isDisabled} isDisabled={isDisabled}
ref={primaryActionRef}
> >
{t('button.stop')} {t('button.stop')}
</Button> </Button>
@@ -129,31 +141,23 @@ export const ControlsButton = ({
})} })}
onPress={() => openSidePanel()} onPress={() => openSidePanel()}
> >
<span <Icon
className={cx( className={css({
'material-icons', color: 'primary.500',
css({ marginRight: '1rem',
color: 'primary.500', })}
marginRight: '1rem', name="info"
}) />
)}
>
info
</span>
<Text variant={'smNote'}> <Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))} {parseLineBreaks(t('button.anotherModeStarted'))}
</Text> </Text>
<span <Icon
className={cx( className={css({
'material-icons', color: 'primary.500',
css({ marginLeft: 'auto',
color: 'primary.500', })}
marginLeft: 'auto', name="chevron_right"
}) />
)}
>
chevron_right
</span>
</RACButton> </RACButton>
)} )}
<Button <Button
@@ -161,6 +165,8 @@ export const ControlsButton = ({
fullWidth fullWidth
onPress={handle} onPress={handle}
isDisabled={isDisabled} isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
> >
{t('button.start')} {t('button.start')}
</Button> </Button>
@@ -1,4 +1,4 @@
import { H, Text } from '@/primitives' import { H, Text, Icon } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})} })}
> >
<HStack justify="start" alignItems="center" marginBottom="0.5rem"> <HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols" aria-hidden={true}> <Icon type="symbols" name="login" />
login
</span>
<H lvl={3} margin={false} padding={false}> <H lvl={3} margin={false} padding={false}>
{heading} {heading}
</H> </H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMemo } from 'react' import { useMemo, useRef, useEffect } from 'react'
import { Text } from '@/primitives' import { Text } from '@/primitives'
import { import {
RecordingMode, RecordingMode,
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useRoomMetadata } from '../hooks/useRoomMetadata' import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon' import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react' import { useIsRecording } from '@livekit/components-react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const RecordingStateToast = () => { export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', { const { t } = useTranslation('rooms', {
@@ -21,6 +22,9 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel() const { openTranscript, openScreenRecording } = useSidePanel()
const lastKeyRef = useRef('')
const announce = useScreenReaderAnnounce()
const hasTranscriptAccess = useHasRecordingAccess( const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript, RecordingMode.Transcript,
FeatureFlags.Transcript FeatureFlags.Transcript
@@ -67,6 +71,16 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}` return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording]) }, [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)
announce(message)
}
}, [announce, key, t])
if (!key) return null if (!key) return null
const hasScreenRecordingAccessAndActive = const hasScreenRecordingAccessAndActive =
@@ -74,61 +88,65 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return ( return (
<div <>
className={css({ {/* Visual banner (without aria-live to avoid duplicate announcements) */}
display: 'flex', <div
position: 'fixed', className={css({
top: '10px', display: 'flex',
left: '10px', position: 'fixed',
paddingY: '0.25rem', top: '10px',
paddingX: '0.75rem 0.75rem', left: '10px',
backgroundColor: 'danger.700', paddingY: '0.25rem',
borderColor: 'white', paddingX: '0.75rem 0.75rem',
border: '1px solid', backgroundColor: 'danger.700',
color: 'white', borderColor: 'white',
borderRadius: '4px', border: '1px solid',
gap: '0.5rem', color: 'white',
})} borderRadius: '4px',
> gap: '0.5rem',
<RecordingStatusIcon })}
isStarted={isStarted} >
isTranscriptActive={isTranscriptActive} <RecordingStatusIcon
/> isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && ( {!hasScreenRecordingAccessAndActive &&
<Text !hasTranscriptAccessAndActive && (
variant={'sm'} <Text
className={css({ variant={'sm'}
fontWeight: '500 !important', className={css({
})} fontWeight: '500 !important',
> })}
{t(key)} >
</Text> {t(key)}
)} </Text>
{hasScreenRecordingAccessAndActive && ( )}
<RACButton {hasScreenRecordingAccessAndActive && (
onPress={openScreenRecording} <RACButton
className={css({ onPress={openScreenRecording}
textStyle: 'sm !important', className={css({
fontWeight: '500 !important', textStyle: 'sm !important',
cursor: 'pointer', fontWeight: '500 !important',
})} cursor: 'pointer',
> })}
{t(key)} >
</RACButton> {t(key)}
)} </RACButton>
{hasTranscriptAccessAndActive && ( )}
<RACButton {hasTranscriptAccessAndActive && (
onPress={openTranscript} <RACButton
className={css({ onPress={openTranscript}
textStyle: 'sm !important', className={css({
fontWeight: '500 !important', textStyle: 'sm !important',
cursor: 'pointer', fontWeight: '500 !important',
})} cursor: 'pointer',
> })}
{t(key)} >
</RACButton> {t(key)}
)} </RACButton>
</div> )}
</div>
</>
) )
} }
@@ -1,4 +1,5 @@
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps { interface RecordingStatusIconProps {
isStarted: boolean isStarted: boolean
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
} }
if (isTranscriptActive) { 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 { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})} })}
> >
<HStack justify="start" alignItems="center" marginBottom="0.5rem"> <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}> <H lvl={3} margin={false} padding={false}>
{heading} {heading}
</H> </H>
@@ -1,5 +1,6 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single' type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -29,9 +30,10 @@ export const RowWrapper = ({
className={css({ className={css({
width: '100%', width: '100%',
background: 'gray.100', background: 'gray.100',
padding: '8px', paddingBlock: '0.5rem',
paddingInline: '0',
display: 'flex', display: 'flex',
marginTop: '4px', marginTop: '0.25rem',
})} })}
> >
<div <div
@@ -40,17 +42,19 @@ export const RowWrapper = ({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingInline: '0.25rem',
})} })}
> >
{/* fixme - doesn't handle properly material-symbols */} {/* fixme - doesn't handle properly material-symbols */}
<span className="material-icons">{iconName}</span> <Icon name={iconName} />
</div> </div>
<div <div
className={css({ className={css({
flex: 5, flex: 6,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.25rem', gap: '0.25rem',
paddingInlineEnd: '8px',
})} })}
> >
{children} {children}
@@ -5,7 +5,6 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { import {
RecordingMode, RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration, useHumanizeRecordingMaxDuration,
useRecordingStatuses, useRecordingStatuses,
} from '@/features/recording' } from '@/features/recording'
@@ -19,7 +18,6 @@ import {
} from '@/features/notifications' } from '@/features/notifications'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView' import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton' import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper' import { RowWrapper } from './RowWrapper'
@@ -28,6 +26,7 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings' import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording' import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => { export const ScreenRecordingSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -38,10 +37,7 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false) const [includeTranscript, setIncludeTranscript] = useState(false)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights( const isAdminOrOwner = useIsAdminOrOwner()
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const { notifyParticipants } = useNotifyParticipants() const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } = const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -98,14 +94,17 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.ScreenRecordingStarted, type: NotificationType.ScreenRecordingStarted,
}) })
posthog.capture('screen-recording-started', {}) posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
} }
} catch (error) { } catch (error) {
console.error('Failed to handle recording:', error) console.error('Failed to handle recording:', error)
} }
} }
if (hasFeatureWithoutAdminRights) { if (!isAdminOrOwner) {
return ( return (
<NoAccessView <NoAccessView
i18nKeyPrefix={keyPrefix} 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> <H lvl={1} margin={'sm'} fullWidth>
{t('heading')} {t('heading')}
</H> </H>
@@ -163,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
)} )}
</Text> </Text>
</VStack> </VStack>
<VStack gap={0} marginBottom={40}> <VStack gap={0} marginBottom={25}>
<RowWrapper iconName="cloud_download" position="first"> <RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text> <Text variant="sm">{t('details.destination')}</Text>
</RowWrapper> </RowWrapper>
@@ -117,7 +117,10 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.TranscriptionStarted, type: NotificationType.TranscriptionStarted,
}) })
posthog.capture('transcript-started', {}) posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
} }
} catch (error) { } catch (error) {
console.error('Failed to handle transcript:', 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'}> <H lvl={1} margin={'sm'}>
{t('heading')} {t('heading')}
</H> </H>
@@ -195,7 +198,7 @@ export const TranscriptSidePanel = () => {
)} )}
</Text> </Text>
</VStack> </VStack>
<VStack gap={0} marginBottom={40}> <VStack gap={0} marginBottom={25}>
<RowWrapper iconName="article" position="first"> <RowWrapper iconName="article" position="first">
<Text variant="sm"> <Text variant="sm">
{data?.transcription_destination ? ( {data?.transcription_destination ? (
@@ -11,6 +11,7 @@ export const useHumanizeRecordingMaxDuration = () => {
return humanizeDuration(data?.recording?.max_duration, { return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language, language: i18n.language,
delimiter: ' ',
}) })
}, [data]) }, [data])
} }
@@ -104,14 +104,12 @@ export const RecordingDownload = () => {
{t('success.title')} {t('success.title')}
</H> </H>
<Text centered margin="md" wrap={'balance'}> <Text centered margin="md" wrap={'balance'}>
<span <span>
dangerouslySetInnerHTML={{ {t('success.body', {
__html: t('success.body', { room: data.room.name,
room: data.room.name, created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'), })}
}), </span>
}}
/>
<span> <span>
{configData?.recording?.expiration_days && ( {configData?.recording?.expiration_days && (
<> <>
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react' import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -228,10 +228,20 @@ export const Conference = ({
posthog.captureException(e) posthog.captureException(e)
}} }}
onDisconnected={(e) => { onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) { switch (e) {
navigateTo('feedback', { duplicateIdentity: false }) case DisconnectReason.CLIENT_INITIATED:
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) { navigateTo('feedback')
navigateTo('feedback', { duplicateIdentity: true }) return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
} }
}} }}
onMediaDeviceFailure={(e, kind) => { onMediaDeviceFailure={(e, kind) => {
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square square
size={'sm'} size={'sm'}
onPress={copyRoomUrlToClipboard} onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')} aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={t('copyUrl')} tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
> >
{isRoomUrlCopied ? ( {isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" /> <RiCheckLine aria-hidden="true" />
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)} {formatPinCode(roomData?.pin_code)}
</Text> </Text>
</div> </div>
<Button <Button
variant={isCopied ? 'success' : 'secondaryText'} variant={isCopied ? 'success' : 'secondaryText'}
size="sm" size="sm"
fullWidth fullWidth
aria-label={t('copy')} aria-label={isCopied ? t('copied') : t('copy')}
style={{ style={{
justifyContent: 'start', justifyContent: 'start',
}} }}
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button <Button
variant={isCopied ? 'success' : 'tertiary'} variant={isCopied ? 'success' : 'tertiary'}
fullWidth fullWidth
aria-label={t('copy')} aria-label={isCopied ? t('copied') : t('copy')}
onPress={copyRoomToClipboard} onPress={copyRoomToClipboard}
data-attr="share-dialog-copy" data-attr="share-dialog-copy"
> >
@@ -446,16 +446,14 @@ export const Join = ({
type="text" type="text"
onChange={saveUsername} onChange={saveUsername}
label={t('usernameLabel')} label={t('usernameLabel')}
aria-label={t('usernameLabel')} id="input-name"
defaultValue={username} defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')} validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{ wrapperProps={{
noMargin: true, noMargin: true,
fullWidth: true, fullWidth: true,
}} }}
labelProps={{ autoComplete="name"
center: true,
}}
maxLength={50} maxLength={50}
/> />
</VStack> </VStack>
@@ -10,9 +10,16 @@ import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
const { isAdminOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const { roomId } = useParams() const { roomId } = useParams()
@@ -38,6 +45,29 @@ export const Admin = () => {
isScreenShareEnabled, isScreenShareEnabled,
} = usePublishSourcesManager() } = usePublishSourcesManager()
// Restore focus to the element that opened the Admin panel
useRestoreFocus(isAdminOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('admin') ?? activeEl
},
// Focus the first focusable element when the panel opens (first Field switch)
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first switch in the moderation section
const firstSwitch =
panel.querySelector<HTMLElement>('[role="switch"]')
if (firstSwitch) {
firstSwitch.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return ( return (
<Div <Div
display="flex" display="flex"
@@ -5,6 +5,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef'
export const AdminToggle = ({ export const AdminToggle = ({
variant = 'primaryTextDark', variant = 'primaryTextDark',
@@ -15,9 +16,10 @@ export const AdminToggle = ({
const { isAdminOpen, toggleAdmin } = useSidePanel() const { isAdminOpen, toggleAdmin } = useSidePanel()
const tooltipLabel = isAdminOpen ? 'open' : 'closed' const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useSidePanelTriggerRef('admin')
const hasAdminAccess = useIsAdminOrOwner() const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return if (!hasAdminAccess) return null
return ( return (
<div <div
@@ -32,6 +34,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isAdminOpen} isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => { onPress={(e) => {
toggleAdmin() toggleAdmin()
onPress?.(e) onPress?.(e)
@@ -9,9 +9,16 @@ import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony' import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony' import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard' import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Info = () => { export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' }) const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const { isInfoOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const data = useRoomData() const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug) const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +31,27 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data) const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
// Restore focus to the element that opened the Info panel
useRestoreFocus(isInfoOpen, {
resolveTrigger: (activeEl) => getTrigger('info') ?? activeEl,
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="copy-info-sidepanel"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return ( return (
<Div <Div
display="flex" display="flex"
@@ -71,7 +99,7 @@ export const Info = () => {
variant={isCopied ? 'success' : 'tertiaryText'} variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')} aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard} onPress={copyRoomToClipboard}
data-attr="copy-info-sidepannel" data-attr="copy-info-sidepanel"
style={{ style={{
marginLeft: '-8px', marginLeft: '-8px',
}} }}
@@ -1,21 +1,26 @@
import { Button, Dialog, H, P } from '@/primitives' import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver' import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration' import humanizeDuration from 'humanize-duration'
import i18n from 'i18next' import i18n from 'i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => { export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore) const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS) const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const lastAnnouncementRef = useRef<number | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' }) const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
useEffect(() => { useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) { if (connectionObserverSnap.isIdleDisconnectModalOpen) {
@@ -35,10 +40,42 @@ export const IsIdleDisconnectModal = () => {
} }
}, [connectionObserverSnap.isIdleDisconnectModalOpen]) }, [connectionObserverSnap.isIdleDisconnectModalOpen])
const minutes = Math.floor(timeRemaining / 1000 / 60) useEffect(() => {
const seconds = (timeRemaining / 1000) % 60 if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
lastAnnouncementRef.current = null
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const remainingSeconds = Math.floor(timeRemaining / 1000)
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}` const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
useEffect(() => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
lastAnnouncementRef.current = remainingSeconds
const message = t('countdownAnnouncement', {
duration: humanizeDuration(remainingSeconds * 1000, {
language: i18n.language,
round: false,
largest: 2,
}),
})
announce(message, 'assertive', 'idle')
}
}, [
announce,
connectionObserverSnap.isIdleDisconnectModalOpen,
remainingSeconds,
t,
])
return ( return (
<Dialog <Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen} isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
@@ -52,6 +89,7 @@ export const IsIdleDisconnectModal = () => {
{({ close }) => { {({ close }) => {
return ( return (
<div> <div>
<ScreenReaderAnnouncer channel="idle" />
<div <div
className={css({ className={css({
height: '50px', height: '50px',
@@ -65,6 +103,7 @@ export const IsIdleDisconnectModal = () => {
color: 'blue.800', color: 'blue.800',
margin: 'auto', margin: 'auto',
})} })}
aria-hidden="true"
> >
{formattedTime} {formattedTime}
</div> </div>
@@ -117,7 +117,9 @@ export const ParticipantTile: (
'aria-label': t('containerLabel', { name: participantName }), 'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => { onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event) 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>) => { onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event) elementProps.onBlur?.(event)
@@ -5,6 +5,10 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle' import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const ANIMATION_DURATION = 3000 export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300 export const ANIMATION_DISTANCE = 300
@@ -140,11 +144,41 @@ export function ReactionPortal({
) )
} }
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
reactions.map((instance) => ( const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
<ReactionPortal const { announceReactions } = useSnapshot(accessibilityStore)
key={instance.id} const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
emoji={instance.emoji} const announce = useScreenReaderAnnounce()
participant={instance.participant}
/> const latestReaction =
)) reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
</>
)
}
@@ -13,27 +13,37 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin' import { Admin } from './Admin'
import { Tools } from './Tools' import { Tools } from './Tools'
import { Info } from './Info' import { Info } from './Info'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useEscapeKey } from '@/hooks/useEscapeKey'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
ariaLabel: string
children: ReactNode children: ReactNode
onClose: () => void onClose: () => void
isClosed: boolean isClosed: boolean
closeButtonTooltip: string closeButtonTooltip: string
isSubmenu: boolean isSubmenu: boolean
onBack: () => void onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
} }
const StyledSidePanel = ({ const StyledSidePanel = ({
title, title,
ariaLabel,
children, children,
onClose, onClose,
isClosed, isClosed,
closeButtonTooltip, closeButtonTooltip,
isSubmenu = false, isSubmenu = false,
onBack, onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => ( }: StyledSidePanelProps) => (
<div <aside
ref={panelRef}
className={css({ className={css({
borderWidth: '1px', borderWidth: '1px',
borderStyle: 'solid', borderStyle: 'solid',
@@ -58,32 +68,37 @@ const StyledSidePanel = ({
style={{ style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none', transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}} }}
aria-hidden={isClosed}
aria-label={ariaLabel}
> >
<Heading <HStack alignItems="center">
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && ( {isSubmenu && (
<Button <Button
variant="secondaryText" variant="secondaryText"
size={'sm'} size="sm"
square square
className={css({ marginRight: '0.5rem' })} className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack} onPress={onBack}
> >
<RiArrowLeftLine size={20} /> <RiArrowLeftLine size={20} aria-hidden="true" />
</Button> </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 <Div
position="absolute" position="absolute"
top="5" top="5"
@@ -104,7 +119,7 @@ const StyledSidePanel = ({
</Button> </Button>
</Div> </Div>
{children} {children}
</div> </aside>
) )
type PanelProps = { type PanelProps = {
@@ -125,8 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null} {keepAlive || isOpen ? children : null}
</div> </div>
) )
const SidePanelContent = () => {
export const SidePanel = () => {
const { const {
activePanelId, activePanelId,
isParticipantsOpen, isParticipantsOpen,
@@ -138,23 +152,37 @@ export const SidePanel = () => {
isInfoOpen, isInfoOpen,
isSubPanelOpen, isSubPanelOpen,
activeSubPanelId, activeSubPanelId,
closeSidePanel,
} = useSidePanel() } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
closeSidePanel()
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return ( return (
<StyledSidePanel <StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)} title={t(`heading.${activeSubPanelId || activePanelId}`)}
onClose={() => { ariaLabel={t('ariaLabel')}
layoutStore.activePanelId = null onClose={closeSidePanel}
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', { closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`), content: t(`content.${activeSubPanelId || activePanelId}`),
})} })}
isClosed={!isSidePanelOpen} isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen} isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)} onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
> >
{/* keepAlive stays only for Info to reduce memory footprint */}
<Panel isOpen={isParticipantsOpen}> <Panel isOpen={isParticipantsOpen}>
<ParticipantsList /> <ParticipantsList />
</Panel> </Panel>
@@ -164,7 +192,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}> <Panel isOpen={isChatOpen} keepAlive={true}>
<Chat /> <Chat />
</Panel> </Panel>
<Panel isOpen={isToolsOpen}> <Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools /> <Tools />
</Panel> </Panel>
<Panel isOpen={isAdminOpen}> <Panel isOpen={isAdminOpen}>
@@ -176,3 +204,7 @@ export const SidePanel = () => {
</StyledSidePanel> </StyledSidePanel>
) )
} }
export const SidePanel = () => {
return <SidePanelContent />
}
@@ -1,9 +1,12 @@
import { A, Div, Text } from '@/primitives' import { A, Div, Icon, Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
import { import {
useIsRecordingModeEnabled, useIsRecordingModeEnabled,
RecordingMode, RecordingMode,
@@ -86,7 +89,7 @@ const ToolButton = ({
alignItems: 'center', alignItems: 'center',
})} })}
> >
<span className="material-symbols">chevron_forward</span> <Icon type="symbols" name="chevron_forward" />
</div> </div>
</RACButton> </RACButton>
) )
@@ -94,9 +97,47 @@ const ToolButton = ({
export const Tools = () => { export const Tools = () => {
const { data } = useConfig() const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId } = const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel() useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// 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 getTrigger('options') ?? activeEl
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return getTrigger('tools') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToolButton in the tools list (transcript or screen recording button)
const toolsList = panel.querySelector<HTMLElement>(
'[data-attr="tools-list"]'
)
if (toolsList) {
const firstToolButton = toolsList.querySelector<HTMLElement>(
'button:first-of-type'
)
if (firstToolButton) {
firstToolButton.focus({ preventScroll: true })
}
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled( const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript RecordingMode.Transcript
@@ -124,6 +165,7 @@ export const Tools = () => {
flexDirection="column" flexDirection="column"
alignItems="start" alignItems="start"
gap={0.5} gap={0.5}
data-attr="tools-list"
> >
<Text <Text
variant="note" variant="note"
@@ -146,7 +188,7 @@ export const Tools = () => {
</Text> </Text>
{isTranscriptEnabled && ( {isTranscriptEnabled && (
<ToolButton <ToolButton
icon={<span className="material-symbols">speech_to_text</span>} icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')} title={t('tools.transcript.title')}
description={t('tools.transcript.body')} description={t('tools.transcript.body')}
onPress={() => openTranscript()} onPress={() => openTranscript()}
@@ -154,7 +196,7 @@ export const Tools = () => {
)} )}
{isScreenRecordingEnabled && ( {isScreenRecordingEnabled && (
<ToolButton <ToolButton
icon={<span className="material-symbols">mode_standby</span>} icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')} title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')} description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()} onPress={() => openScreenRecording()}
@@ -1,3 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react' import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives' import { Button, Popover } from '@/primitives'
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice' import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import Source = Track.Source import Source = Track.Source
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,7 @@ export const AudioDevicesControl = ({
...props ...props
}: AudioDevicesControlProps) => { }: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const { const {
userChoices: { audioDeviceId, audioOutputDeviceId }, userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -76,7 +78,16 @@ export const AudioDevicesControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: AUDIO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
@@ -100,6 +111,7 @@ export const AudioDevicesControl = ({
})} })}
> >
<div <div
data-attr="audio-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -10,6 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack' import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
import { BackgroundProcessorFactory } from '../../blur' import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
@@ -17,10 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton' import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
const EffectsButton = ({ onPress }: { onPress: () => void }) => { const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel() const { isEffectsOpen, toggleEffects } = useSidePanel()
const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
return ( return (
<Button <Button
size="sm" size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')} tooltip={t('effects')}
aria-label={t('effects')} aria-label={t('effects')}
variant="primaryDark" variant="primaryDark"
ref={setEffectsTriggerRef}
onPress={() => { onPress={() => {
if (!isEffectsOpen) toggleEffects() if (!isEffectsOpen) toggleEffects()
onPress() onPress()
@@ -50,6 +54,8 @@ export const VideoDeviceControl = ({
...props ...props
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' }) const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } = const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices() usePersistentUserChoices()
@@ -120,12 +126,22 @@ export const VideoDeviceControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Popover variant="dark" withArrow={false}> <Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: VIDEO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button <Button
tooltip={selectLabel} tooltip={selectLabel}
aria-label={selectLabel} aria-label={selectLabel}
groupPosition="right" groupPosition="right"
square square
ref={setCameraMenuTriggerRef}
variant={ variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice !canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2' ? 'error2'
@@ -136,6 +152,7 @@ export const VideoDeviceControl = ({
</Button> </Button>
{({ close }) => ( {({ close }) => (
<div <div
data-attr="camera-menu-popover"
className={css({ className={css({
maxWidth: '36rem', maxWidth: '36rem',
padding: '0.15rem', padding: '0.15rem',
@@ -144,6 +161,7 @@ export const VideoDeviceControl = ({
})} })}
> >
<div <div
data-attr="video-input-select"
style={{ style={{
flex: '1 1 0', flex: '1 1 0',
minWidth: 0, minWidth: 0,
@@ -0,0 +1,5 @@
export const AUDIO_INPUT_FOCUS_SELECTOR =
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]'
export const VIDEO_INPUT_FOCUS_SELECTOR =
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]'
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel' import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const InfoToggle = ({ export const InfoToggle = ({
onPress, onPress,
@@ -13,6 +14,7 @@ export const InfoToggle = ({
const { isInfoOpen, toggleInfo } = useSidePanel() const { isInfoOpen, toggleInfo } = useSidePanel()
const tooltipLabel = isInfoOpen ? 'open' : 'closed' const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const setInfoTriggerRef = useSidePanelTriggerRef('info')
return ( return (
<div <div
@@ -27,6 +29,7 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isInfoOpen} isSelected={isInfoOpen}
ref={setInfoTriggerRef}
onPress={(e) => { onPress={(e) => {
toggleInfo() toggleInfo()
onPress?.(e) onPress?.(e)
@@ -2,17 +2,21 @@ import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react' import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems' import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const OptionsButton = () => { export const OptionsButton = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const setOptionsTriggerRef = useSidePanelTriggerRef('options')
return ( return (
<Menu variant="dark"> <Menu variant="dark">
<Button <Button
id="room-options-trigger"
square square
variant="primaryDark" variant="primaryDark"
aria-label={t('options.buttonLabel')} aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')} tooltip={t('options.buttonLabel')}
ref={setOptionsTriggerRef}
> >
<RiMoreFill /> <RiMoreFill />
</Button> </Button>
@@ -11,7 +11,10 @@ export const SettingsMenuItem = () => {
return ( return (
<MenuItem <MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item} className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => openSettingsDialog()} onAction={() => {
// Let MenuTrigger close first to avoid stacked overlays (menu + dialog).
window.setTimeout(() => openSettingsDialog(), 0)
}}
> >
<RiSettings3Line size={20} /> <RiSettings3Line size={20} />
{t('settings')} {t('settings')}
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
<ToggleHeader <ToggleHeader
isSelected={isOpen} isSelected={isOpen}
aria-label={label} aria-label={label}
data-focus-target="list-header"
onPress={() => setIsOpen(!isOpen)} onPress={() => setIsOpen(!isOpen)}
style={{ style={{
borderRadius: !isOpen ? '7px' : undefined, borderRadius: !isOpen ? '7px' : undefined,
@@ -12,10 +12,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants' import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton' import { MuteEveryoneButton } from './MuteEveryoneButton'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => { export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const { isParticipantsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks, // Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their // because the 'useLocalParticipant' hook does not update the participant's information when their
@@ -48,6 +55,31 @@ export const ParticipantsList = () => {
const { waitingParticipants, handleParticipantEntry } = const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants() useWaitingParticipants()
// Restore focus to the element that opened the Participants panel
useRestoreFocus(isParticipantsOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('participants') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
// Use setTimeout + RAF to ensure DOM is fully rendered and transition completed
setTimeout(() => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToggleHeader (collapsable list header) in the participants panel
const firstListHeader = panel.querySelector<HTMLElement>(
'button[data-focus-target="list-header"]'
)
firstListHeader?.focus({ preventScroll: true })
}
})
}, 100) // Wait for panel slide-in animation to complete
},
restoreFocusRaf: true,
preventScroll: true,
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers // TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return ( return (
<Div overflowY="scroll"> <Div overflowY="scroll">
@@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react' import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const ParticipantsToggle = ({ export const ParticipantsToggle = ({
onPress, onPress,
@@ -19,8 +21,11 @@ export const ParticipantsToggle = ({
*/ */
const participants = useParticipants() const participants = useParticipants()
const numParticipants = participants?.length const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const setParticipantsTriggerRef = useSidePanelTriggerRef('participants')
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed' const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -31,21 +36,25 @@ export const ParticipantsToggle = ({
display: 'inline-block', display: 'inline-block',
})} })}
> >
<ToggleButton <VisualOnlyTooltip tooltip={t(tooltipLabel)}>
square <ToggleButton
variant="primaryTextDark" square
aria-label={t(tooltipLabel)} variant="primaryTextDark"
tooltip={t(tooltipLabel)} aria-label={`${t(tooltipLabel)}. ${t('count', {
isSelected={isParticipantsOpen} count: announcedCount,
onPress={(e) => { })}.`}
toggleParticipants() isSelected={isParticipantsOpen}
onPress?.(e) ref={setParticipantsTriggerRef}
}} onPress={(e) => {
data-attr={`controls-participants-${tooltipLabel}`} toggleParticipants()
{...props} onPress?.(e)
> }}
<RiGroupLine /> data-attr={`controls-participants-${tooltipLabel}`}
</ToggleButton> {...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',
@@ -10,9 +10,11 @@ import {
ANIMATION_DURATION, ANIMATION_DURATION,
ReactionPortals, ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal' } from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components' import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter' import useRateLimiter from '@/hooks/useRateLimiter'
import { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export enum Emoji { export enum Emoji {
@@ -36,6 +38,7 @@ export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([]) const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0) const instanceIdRef = useRef(0)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const room = useRoomContext() const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
@@ -101,6 +104,22 @@ export const ReactionsToggle = () => {
} }
}, [isVisible, isRendered]) }, [isVisible, isRendered])
useEscapeKey(
() => {
// Mirror the trigger button behavior (Enter toggles open/close)
triggerRef.current?.click()
requestAnimationFrame(() => {
triggerRef.current?.focus({ preventScroll: true })
})
},
{
isActive: isVisible,
capture: true,
preventDefault: true,
stopPropagation: true,
}
)
return ( return (
<> <>
<div <div
@@ -113,6 +132,7 @@ export const ReactionsToggle = () => {
variant="primaryDark" variant="primaryDark"
aria-label={t('button')} aria-label={t('button')}
tooltip={t('button')} tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)} onPress={() => setIsVisible(!isVisible)}
> >
<RiEmotionLine /> <RiEmotionLine />
@@ -145,7 +165,7 @@ export const ReactionsToggle = () => {
<Button <Button
key={index} key={index}
onPress={() => debouncedSendReaction(emoji)} onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji })} aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark" variant="primaryTextDark"
size="sm" size="sm"
square square
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel' import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const ToolsToggle = ({ export const ToolsToggle = ({
variant = 'primaryTextDark', variant = 'primaryTextDark',
@@ -14,6 +15,7 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel() const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed' const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useSidePanelTriggerRef('tools')
return ( return (
<div <div
@@ -28,6 +30,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isToolsOpen} isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => { onPress={(e) => {
toggleTools() toggleTools()
onPress?.(e) onPress?.(e)
@@ -5,14 +5,51 @@ import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack' import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const Effects = () => { export const Effects = () => {
const { cameraTrack } = useLocalParticipant() const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices() const { saveProcessorSerialized } = usePersistentUserChoices()
const { isEffectsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA) const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
useRestoreFocus(isEffectsOpen, {
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return getTrigger('options') ?? activeEl
}
if (activeEl?.closest('[data-attr="camera-menu-popover"]')) {
return getTrigger('cameraMenu') ?? activeEl
}
// For direct button clicks, use the active element as is
return activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first toggle button (blur light button)
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="toggle-blur-light"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return ( return (
<div <div
className={css({ className={css({
@@ -9,15 +9,16 @@ import {
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives' import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn' import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong' import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react' import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader' import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay' import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects' import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess' import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
@@ -50,11 +51,17 @@ export const EffectsConfiguration = ({
layout = 'horizontal', layout = 'horizontal',
}: EffectsConfigurationProps) => { }: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' }) const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera }) const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false) const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending) const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess() const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const announce = useScreenReaderAnnounce()
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => { useEffect(() => {
const videoElement = videoRef.current const videoElement = videoRef.current
@@ -69,16 +76,92 @@ export const EffectsConfiguration = ({
} }
}, [videoTrack, videoTrack?.isMuted]) }, [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)
}
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
announce(message)
}, 80)
}
const clearEffect = async () => { const clearEffect = async () => {
await videoTrack.stopProcessor() await videoTrack.stopProcessor()
onSubmit?.(undefined) 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 ( const toggleEffect = async (
type: ProcessorType, type: ProcessorType,
options: BackgroundOptions options: BackgroundOptions
) => { ) => {
setProcessorPending(true) setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) { if (!videoTrack) {
/** /**
* Special case: if no video track is available, then we must pass directly the processor into the * Special case: if no video track is available, then we must pass directly the processor into the
@@ -104,7 +187,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor() const processor = getProcessor()
try { try {
if (isSelected(type, options)) { if (wasSelectedBeforeToggle) {
// Stop processor. // Stop processor.
await clearEffect() await clearEffect()
} else if ( } else if (
@@ -131,6 +214,8 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it. // We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor) onSubmit?.(processor)
} }
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) { } catch (error) {
console.error('Error applying effect:', error) console.error('Error applying effect:', error)
} finally { } finally {
@@ -153,8 +238,28 @@ export const EffectsConfiguration = ({
) )
} }
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => { const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`) 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 ( return (
@@ -265,65 +370,57 @@ export const EffectsConfiguration = ({
> >
{t('blur.title')} {t('blur.title')}
</H> </H>
<div <div>
className={css({ <div
display: 'flex', className={css({
gap: '1.25rem', display: 'flex',
})} gap: '1.25rem',
> })}
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
> >
<RiProhibited2Line /> <ToggleButton
</ToggleButton> ref={blurLightRef}
<ToggleButton variant="bigSquare"
variant="bigSquare" aria-label={tooltipBlur(ProcessorType.BLUR, {
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT, blurRadius: BlurRadius.LIGHT,
}) })}
} tooltip={tooltipBlur(ProcessorType.BLUR, {
isSelected={isSelected(ProcessorType.BLUR, { blurRadius: BlurRadius.LIGHT,
blurRadius: BlurRadius.LIGHT, })}
})} isDisabled={processorPendingReveal || isDisabled}
data-attr="toggle-blur-light" onChange={async () =>
> await toggleEffect(ProcessorType.BLUR, {
<BlurOn /> blurRadius: BlurRadius.LIGHT,
</ToggleButton> })
<ToggleButton }
variant="bigSquare" isSelected={isSelected(ProcessorType.BLUR, {
aria-label={tooltipLabel(ProcessorType.BLUR, { blurRadius: BlurRadius.LIGHT,
blurRadius: BlurRadius.NORMAL, })}
})} data-attr="toggle-blur-light"
tooltip={tooltipLabel(ProcessorType.BLUR, { >
blurRadius: BlurRadius.NORMAL, <BlurOn />
})} </ToggleButton>
isDisabled={processorPendingReveal || isDisabled} <ToggleButton
onChange={async () => variant="bigSquare"
await toggleEffect(ProcessorType.BLUR, { aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL, blurRadius: BlurRadius.NORMAL,
}) })}
} tooltip={tooltipBlur(ProcessorType.BLUR, {
isSelected={isSelected(ProcessorType.BLUR, { blurRadius: BlurRadius.NORMAL,
blurRadius: BlurRadius.NORMAL, })}
})} isDisabled={processorPendingReveal || isDisabled}
data-attr="toggle-blur-normal" onChange={async () =>
> await toggleEffect(ProcessorType.BLUR, {
<BlurOnStrong /> blurRadius: BlurRadius.NORMAL,
</ToggleButton> })
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
</div> </div>
<div <div
className={css({ className={css({
@@ -343,39 +440,37 @@ export const EffectsConfiguration = ({
className={css({ className={css({
display: 'flex', display: 'flex',
gap: '1.25rem', gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap', flexWrap: 'wrap',
})} })}
> >
{[...Array(8).keys()].map((i) => { {[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg` const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return ( return (
<ToggleButton <VisualOnlyTooltip key={i} tooltip={tooltipText}>
key={i} <ToggleButton
variant="bigSquare" variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, { aria-label={ariaLabelVirtualBackground(i, imagePath)}
imagePath, isDisabled={processorPendingReveal || isDisabled}
})} onChange={async () =>
tooltip={tooltipLabel(ProcessorType.VIRTUAL, { await toggleEffect(ProcessorType.VIRTUAL, {
imagePath, imagePath,
})} })
isDisabled={processorPendingReveal || isDisabled} }
onChange={async () => isSelected={isSelected(ProcessorType.VIRTUAL, {
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath, imagePath,
}) })}
} className={css({
isSelected={isSelected(ProcessorType.VIRTUAL, { bgSize: 'cover',
imagePath, })}
})} style={{
className={css({ backgroundImage: `url(${thumbnailPath})`,
bgSize: 'cover', }}
})} data-attr={`toggle-virtual-${i}`}
style={{ />
backgroundImage: `url(${thumbnailPath})`, </VisualOnlyTooltip>
}}
data-attr={`toggle-virtual-${i}`}
/>
) )
})} })}
</div> </div>
@@ -1,5 +1,7 @@
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export enum PanelId { export enum PanelId {
PARTICIPANTS = 'participants', PARTICIPANTS = 'participants',
@@ -19,6 +21,41 @@ export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore) const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId const activeSubPanelId = layoutSnap.activeSubPanelId
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
const prevPanelIdRef = useRef<PanelId | null>(activePanelId)
const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) {
return layoutStore.sidePanelTriggers.options ?? activeEl
}
}
if (
panelId === PanelId.EFFECTS &&
activeEl?.closest('[data-attr="camera-menu-popover"]')
) {
return layoutStore.sidePanelTriggers.cameraMenu ?? activeEl
}
const triggerKeyByPanel: Partial<Record<PanelId, SidePanelTriggerKey>> = {
[PanelId.PARTICIPANTS]: 'participants',
[PanelId.TOOLS]: 'tools',
[PanelId.INFO]: 'info',
[PanelId.ADMIN]: 'admin',
[PanelId.EFFECTS]: 'effects',
}
const triggerKey = triggerKeyByPanel[panelId]
return triggerKey
? (layoutStore.sidePanelTriggers[triggerKey] ?? activeEl)
: activeEl
}
const storeLastTrigger = (panelId: PanelId) => {
const activeEl = document.activeElement as HTMLElement | null
layoutStore.lastSidePanelTriggerRef.current = resolveTrigger(
panelId,
activeEl
)
}
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS const isEffectsOpen = activePanelId == PanelId.EFFECTS
@@ -32,45 +69,101 @@ export const useSidePanel = () => {
const isSubPanelOpen = !!activeSubPanelId const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => { const toggleAdmin = () => {
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleParticipants = () => { const toggleParticipants = () => {
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleChat = () => { const toggleChat = () => {
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleEffects = () => { const toggleEffects = () => {
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleTools = () => { const toggleTools = () => {
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleInfo = () => { const toggleInfo = () => {
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const openTranscript = () => { const openTranscript = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS layoutStore.activePanelId = PanelId.TOOLS
} }
const openScreenRecording = () => { const openScreenRecording = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS layoutStore.activePanelId = PanelId.TOOLS
} }
const closeSidePanel = () => {
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
}
useEffect(() => {
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevPanelIdRef.current
if (wasOpen && !activePanelId) {
const trigger = layoutStore.lastSidePanelTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
}
}
}
prevPanelIdRef.current = activePanelId
}, [activePanelId])
return { return {
activePanelId, activePanelId,
activeSubPanelId, activeSubPanelId,
@@ -82,6 +175,7 @@ export const useSidePanel = () => {
toggleInfo, toggleInfo,
openTranscript, openTranscript,
openScreenRecording, openScreenRecording,
closeSidePanel,
isSubPanelOpen, isSubPanelOpen,
isChatOpen, isChatOpen,
isParticipantsOpen, isParticipantsOpen,
@@ -0,0 +1,5 @@
import { layoutStore } from '@/stores/layout'
export const useSidePanelRef = () => {
return layoutStore.sidePanelRef
}
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
import { useSidePanelTriggers } from './useSidePanelTriggers'
export const useSidePanelTriggerRef = (key: SidePanelTriggerKey) => {
const { setTrigger } = useSidePanelTriggers()
return useCallback(
(el: HTMLElement | null) => {
setTrigger(key, el)
},
[key, setTrigger]
)
}
@@ -0,0 +1,13 @@
import { layoutStore } from '@/stores/layout'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export const useSidePanelTriggers = () => {
return {
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => {
layoutStore.sidePanelTriggers[key] = el
},
getTrigger: (key: SidePanelTriggerKey) => {
return layoutStore.sidePanelTriggers[key] ?? null
},
}
}
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core' import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React, { useEffect } from 'react' import React from 'react'
import { import {
formatChatMessageLinks, formatChatMessageLinks,
useChat, useChat,
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client' import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, 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 // Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed. // when the chat panel is closed.
const prevIsChatOpenRef = React.useRef(false) useRestoreFocus(isChatOpen, {
const chatTriggerRef = React.useRef<HTMLElement | null>(null) // Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
useEffect(() => { onOpened: () => {
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.
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}) })
} },
// Chat just closed preventScroll: true,
if (wasChatOpen && !isChatPanelOpen) { })
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes. // Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants() const participants = useParticipants()
@@ -73,6 +73,7 @@ export function MobileControlBar({
/> />
<HandToggle /> <HandToggle />
<Button <Button
id="room-options-trigger"
square square
variant="primaryDark" variant="primaryDark"
aria-label={t('options.buttonLabel')} aria-label={t('options.buttonLabel')}
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
return ( return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}> <DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button <Button
id="controlbar-more-options-trigger"
square square
variant="secondaryDark" variant="secondaryDark"
aria-label={t('controls.moreOptions')} aria-label={t('controls.moreOptions')}
@@ -5,9 +5,8 @@ import {
isWeb, isWeb,
log, log,
} from '@livekit/components-core' } from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client' import { Participant, RoomEvent, Track } from 'livekit-client'
import * as React from 'react' import React, { useCallback, useRef, useState, useEffect } from 'react'
import { useState } from 'react'
import { import {
ConnectionStateToast, ConnectionStateToast,
FocusLayoutContainer, FocusLayoutContainer,
@@ -16,7 +15,9 @@ import {
usePinnedTracks, usePinnedTracks,
useTracks, useTracks,
useCreateLayoutContext, useCreateLayoutContext,
useRoomContext,
} from '@livekit/components-react' } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { ControlBar } from './ControlBar/ControlBar' import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
@@ -37,6 +38,8 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout' import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout' import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const LayoutWrapper = styled( const LayoutWrapper = styled(
'div', 'div',
@@ -89,7 +92,22 @@ export interface VideoConferenceProps
*/ */
export function VideoConference({ ...props }: VideoConferenceProps) { export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack = const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null) useRef<TrackReferenceOrPlaceholder | null>(null)
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
if (!participant) return tRooms('participants.unknown')
return participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
},
[tRooms]
)
useConnectionObserver() useConnectionObserver()
useVideoResolutionSubscription() useVideoResolutionSubscription()
@@ -115,9 +133,62 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack) (track) => !isEqualTrackRef(track, focusTrack)
) )
// handle pin announcements
useEffect(() => {
const participant = focusTrack?.participant
// 1. unpin
if (!participant) {
if (!lastPinnedParticipantIdentityRef.current) return
const lastIdentity = lastPinnedParticipantIdentityRef.current
const lastParticipant =
room.localParticipant.identity === lastIdentity
? room.localParticipant
: room.remoteParticipants.get(lastIdentity)
const announcementName = getAnnouncementName(lastParticipant)
announce(
lastParticipant?.isLocal
? t('self.unpin')
: t('unpin', {
name: announcementName,
})
)
lastPinnedParticipantIdentityRef.current = null
return
}
// 2. same pin → do nothing
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
return
}
// 3. new pin
const participantName = participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
lastPinnedParticipantIdentityRef.current = participant.identity
announce(
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
)
}, [
announce,
focusTrack,
getAnnouncementName,
room.localParticipant,
room.remoteParticipants,
t,
tRooms,
])
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring. // Code duplicated from LiveKit; this warning will be addressed in the refactoring.
React.useEffect(() => { useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share. // If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if ( if (
screenShareTracks.some((track) => track.publication.isSubscribed) && screenShareTracks.some((track) => track.publication.isSubscribed) &&
@@ -0,0 +1,8 @@
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
@@ -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 { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx' import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx' 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 // fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', { const Heading = styled('h1', {
@@ -20,25 +22,40 @@ const Heading = styled('h1', {
}, },
}) })
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => { export const FeedbackRoute = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const [, setLocation] = useLocation() 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 ( return (
<Screen layout="centered" footer={false}> <Screen layout="centered" footer={false}>
<Center> <Center>
<VStack> <VStack>
<Heading> <Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack> <HStack>
<Button variant="secondary" onPress={() => window.history.back()}> {showBackButton && (
{t('feedback.back')} <Button variant="secondary" onPress={() => window.history.back()}>
</Button> {t('feedback.back')}
</Button>
)}
<Button variant="primary" onPress={() => setLocation('/')}> <Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')} {t('feedback.home')}
</Button> </Button>
@@ -2,6 +2,7 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx' import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx' import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -10,6 +11,7 @@ import {
RiSettings3Line, RiSettings3Line,
RiSpeakerLine, RiSpeakerLine,
RiVideoOnLine, RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react' } from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab' import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab' import { NotificationsTab } from './tabs/NotificationsTab'
@@ -17,10 +19,11 @@ import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab' import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab' import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab' import { TranscriptionTab } from './tabs/TranscriptionTab'
import { useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({ const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -58,8 +61,20 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null) const dialogEl = useRef<HTMLDivElement>(null)
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
useLayoutEffect(() => {
if (!props.isOpen) return
console.warn('[a11y] useLayoutEffect fired, dialogEl:', dialogEl.current)
console.warn('[a11y] tabs found:', dialogEl.current?.querySelectorAll('[role="tab"]').length)
const selected = dialogEl.current?.querySelector<HTMLElement>(
'[role="tab"][aria-selected="true"]',
)
console.warn('[a11y] selected tab:', selected?.textContent)
selected?.focus({ preventScroll: true })
console.warn('[a11y] activeElement after focus:', document.activeElement?.tagName, document.activeElement?.getAttribute('role'))
}, [props.isOpen])
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
const isAdminOrOwner = useIsAdminOrOwner() const isAdminOrOwner = useIsAdminOrOwner()
return ( return (
@@ -106,11 +121,16 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab> </Tab>
{isAdminOrOwner && ( {isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}> <Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<span className="material-symbols">speech_to_text</span> <Icon type="symbols" name="speech_to_text" />
{isWideScreen && {isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)} t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab> </Tab>
)} )}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList> </TabList>
</div> </div>
<div className={tabPanelContainerStyle}> <div className={tabPanelContainerStyle}>
@@ -124,6 +144,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} /> <NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */} {/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} /> <TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div> </div>
</Tabs> </Tabs>
</Dialog> </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, key: RecordingLanguage.ENGLISH,
label: t('language.options.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, key: RecordingLanguage.AUTOMATIC,
label: t('language.options.auto'), label: t('language.options.auto'),
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general', GENERAL = 'general',
NOTIFICATIONS = 'notifications', NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription', 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 { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css' import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
id: string id: string
text: string text: string
language: string language: string
startTime: number startTime?: number
endTime: number endTime: number
final: boolean final: boolean
firstReceivedTime: number firstReceivedTime: number
lastReceivedTime: number lastReceivedTime: number
} }
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow { export interface TranscriptionRow {
id: string id: string
participant: Participant participant: Participant
segments: TranscriptionSegment[] segments: TranscriptionSegment[]
startTime: number startTime?: number
lastUpdateTime: number lastUpdateTime: number
lastReceivedTime: number
} }
const useTranscriptionState = () => { const useTranscriptionState = () => {
const [transcriptionRows, setTranscriptionRows] = useState< const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionRow[] TranscriptionSegmentWithParticipant[]
>([]) >([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptions = ( const updateTranscriptionSegments = (
segments: TranscriptionSegment[], segments: TranscriptionSegment[],
participant?: Participant participant?: Participant
) => { ) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return if (!participant || segments.length === 0) return
setTranscriptionRows((prevRows) => { if (segments.length > 1) {
const updatedRows = [...prevRows] console.warn('Unexpected error more segments')
const now = Date.now() return
}
const shouldAppendToLastRow = const segment = segments[0]
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
if (shouldAppendToLastRow) { setTranscriptionSegments((prevSegments) => {
const lastRowIndex = updatedRows.length - 1 const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
const lastRow = updatedRows[lastRowIndex] if (existingSegmentIds.has(segment.id)) return prevSegments
return [
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id)) ...prevSegments,
const newSegments = segments.filter( {
(segment) => !existingSegmentIds.has(segment.id) participant: participant,
) ...segment,
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
})
}) })
} }
return { return {
transcriptionRows, updateTranscriptionSegments,
updateTranscriptions, transcriptionSegments,
clearTranscriptions,
updateParticipant,
} }
} }
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => { export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles() const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext() const room = useRoomContext()
const { transcriptionRows, updateTranscriptions, updateParticipant } =
const { transcriptionSegments, updateTranscriptionSegments } =
useTranscriptionState() useTranscriptionState()
useEffect(() => { useEffect(() => {
if (!room) return if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions) room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
return () => { return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions) room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
} }
}, [room, updateTranscriptions]) }, [room, updateTranscriptionSegments])
useEffect(() => { const transcriptionRows = useMemo(() => {
if (!room) return if (transcriptionSegments.length === 0) return []
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => { const rows: TranscriptionRow[] = []
room.off(RoomEvent.ParticipantNameChanged, updateParticipant) 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 ( return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}> <SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -2,10 +2,14 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle' import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' 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 = () => { export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore) const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData() const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle() const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -20,6 +24,18 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles 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 { return {
areSubtitlesOpen: layoutSnap.showSubtitles, areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles, toggleSubtitles,
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react'
type UseEscapeKeyOptions = {
isActive: boolean
capture?: boolean
preventDefault?: boolean
stopPropagation?: boolean
}
export const useEscapeKey = (
handler: () => void,
{
isActive,
capture = false,
preventDefault = false,
stopPropagation = false,
}: UseEscapeKeyOptions
) => {
const handleRef = useRef(handler)
handleRef.current = handler
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation()
handleRef.current()
}
document.addEventListener('keydown', onKeyDown, capture)
return () => {
document.removeEventListener('keydown', onKeyDown, capture)
}
}, [capture, isActive, preventDefault, stopPropagation])
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
type UseFocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
export const useFocusOnOpen = (
isOpen: boolean,
containerRef: React.RefObject<HTMLElement>,
{ selector, delayMs = 0, preventScroll = true }: UseFocusOnOpenOptions
) => {
useEffect(() => {
if (!isOpen) return
const timer = setTimeout(() => {
requestAnimationFrame(() => {
const first = containerRef.current?.querySelector<HTMLElement>(selector)
first?.focus({ preventScroll })
})
}, delayMs)
return () => clearTimeout(timer)
}, [containerRef, delayMs, isOpen, preventScroll, selector])
}
+108
View File
@@ -0,0 +1,108 @@
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)
const cleanupRef = useRef<(() => void) | null>(null)
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
useEffect(() => {
// Track last interaction type (like native :focus-visible behavior)
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
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 })
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
// Remove focus ring only when the trigger loses focus
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
// Store cleanup for unmount case
cleanupRef.current?.()
cleanupRef.current = () => {
trigger.removeEventListener('blur', handleBlur)
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
}
}
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
// Cleanup: remove focus ring if component unmounts before focus changes
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [
isOpen,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
])
}
@@ -0,0 +1,19 @@
import { useCallback } from 'react'
import {
announceToScreenReader,
type Politeness,
type ScreenReaderChannel,
} from '@/stores/screenReaderAnnouncer'
export const useScreenReaderAnnounce = () => {
return useCallback(
(
message: string,
politeness: Politeness = 'polite',
channel: ScreenReaderChannel = 'global'
) => {
announceToScreenReader(message, politeness, channel)
},
[]
)
}
+23 -16
View File
@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
import { Menu } from '@/primitives/Menu' import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList' import { MenuList } from '@/primitives/MenuList'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
@@ -90,6 +91,11 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService') const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room') const isRoom = useMatchesRoute('room')
const { user, isLoggedIn, logout } = useUser() const { user, isLoggedIn, logout } = useUser()
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
? `${loggedInTooltip} ${userLabel}`
: loggedInTooltip
return ( return (
<> <>
@@ -153,23 +159,24 @@ export const Header = () => {
)} )}
{!!user && ( {!!user && (
<Menu> <Menu>
<Button <Button size="sm" variant="secondaryText">
size="sm" <VisualOnlyTooltip
variant="secondaryText" tooltip={loggedInTooltip}
tooltip={t('loggedInUserTooltip')} ariaLabel={loggedInAriaLabel}
tooltipType="delayed" tooltipPosition="bottom"
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
> >
{user?.full_name || user?.email} <span
</span> className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
{user?.full_name || user?.email}
</span>
</VisualOnlyTooltip>
</Button> </Button>
<MenuList <MenuList
variant={'light'} variant={'light'}
+2
View File
@@ -4,6 +4,7 @@ import { Header } from './Header'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer' import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
export type Layout = 'fullpage' | 'centered' export type Layout = 'fullpage' | 'centered'
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
flexDirection: 'column', flexDirection: 'column',
})} })}
> >
<ScreenReaderAnnouncer />
{children} {children}
</main> </main>
</div> </div>
+3 -6
View File
@@ -44,18 +44,15 @@
}, },
"slide1": { "slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!", "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.", "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"
}, },
"slide2": { "slide2": {
"title": "Gruppenanrufe ohne Einschränkungen", "title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.", "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"
}, },
"slide3": { "slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI", "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!", "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"
} }
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"success": { "success": {
"title": "Ihre Aufzeichnung ist bereit!", "title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.", "body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.", "expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen", "button": "Herunterladen",
"warning": { "warning": {
+56 -10
View File
@@ -2,7 +2,8 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "Sie haben das Meeting verlassen", "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", "home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten" "back": "Dem Meeting erneut beitreten"
@@ -85,6 +86,7 @@
"heading": "Ihr Meeting ist bereit", "heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.", "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.", "permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": { "phone": {
"call": "Rufen Sie an:", "call": "Rufen Sie an:",
"pinCode": "Code:" "pinCode": "Code:"
@@ -154,7 +156,8 @@
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?", "body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.", "settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen", "stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen" "leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
}, },
"controls": { "controls": {
"microphone": "Mikrofon", "microphone": "Mikrofon",
@@ -191,7 +194,9 @@
"leave": "Verlassen", "leave": "Verlassen",
"participants": { "participants": {
"open": "Alle ausblenden", "open": "Alle ausblenden",
"closed": "Alle anzeigen" "closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
}, },
"tools": { "tools": {
"open": "Weitere Tools ausblenden", "open": "Weitere Tools ausblenden",
@@ -205,7 +210,18 @@
"reactions": { "reactions": {
"button": "Reaktion senden", "button": "Reaktion senden",
"send": "Reaktion {{emoji}} 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": { "options": {
@@ -233,15 +249,34 @@
"clear": "Effekt deaktivieren", "clear": "Effekt deaktivieren",
"blur": { "blur": {
"title": "Hintergrundunschärfe", "title": "Hintergrundunschärfe",
"light": "Leichte Unschärfe", "status": {
"normal": "Unschärfe", "none": "Hintergrund scharf.",
"apply": "Unschärfe aktivieren", "light": "Hintergrund leicht verschwommen.",
"clear": "Unschärfe deaktivieren" "strong": "Hintergrund stark verschwommen."
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
}, },
"virtual": { "virtual": {
"title": "Virtueller Hintergrund", "title": "Virtueller Hintergrund",
"apply": "Virtuellen Hintergrund aktivieren", "selectedLabel": "Hintergrund angewendet:",
"clear": "Virtuellen Hintergrund deaktivieren" "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": { "faceLandmarks": {
"title": "Visuelle Effekte", "title": "Visuelle Effekte",
@@ -256,6 +291,8 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": { "heading": {
"participants": "Teilnehmer", "participants": "Teilnehmer",
"effects": "Effekte", "effects": "Effekte",
@@ -468,6 +505,7 @@
"participants": { "participants": {
"subheading": "Im Raum", "subheading": "Im Raum",
"you": "Du", "you": "Du",
"unknown": "Unbekannter Teilnehmer",
"host": "Host", "host": "Host",
"contributors": "Mitwirkende", "contributors": "Mitwirkende",
"collapsable": { "collapsable": {
@@ -515,6 +553,14 @@
"ariaLabel": "Hefte {{name}} an" "ariaLabel": "Hefte {{name}} an"
} }
}, },
"pinAnnouncements": {
"pin": "Das Video von {{name}} ist angeheftet.",
"unpin": "Das Video von {{name}} ist nicht mehr angeheftet.",
"self": {
"pin": "Dein Video ist angeheftet.",
"unpin": "Dein Video ist nicht mehr angeheftet."
}
},
"recordingStateToast": { "recordingStateToast": {
"transcript": { "transcript": {
"started": "Transkription läuft", "started": "Transkription läuft",
@@ -75,6 +75,8 @@
"options": { "options": {
"french": "Französisch (fr)", "french": "Französisch (fr)",
"english": "Englisch (en)", "english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch" "auto": "Automatisch"
} }
} }
@@ -70,19 +70,42 @@
}, },
"article7": { "article7": {
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM", "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": { "sections": {
"section1": { "section1": {
"title": "7.1. Sicherheit und Plattformzugang", "title": "7.1. Verfügbarkeitsniveau",
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
"paragraphs": [ "paragraphs": [
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.", "Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
"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 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.",
"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." "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": { "section2": {
"title": "7.2. Open Source und Lizenzen", "title": "7.2. Sicherheit des Dienstes",
"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." "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."
} }
} }
}, },
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "You don't have the permission to view this page" "heading": "You don't have the permission to view this page"
}, },
"loading": "Loading…", "loading": "Loading…",
"loggedInUserTooltip": "Logged in as", "loggedInUserTooltip": "Logged in as ",
"login": { "login": {
"buttonLabel": "Login", "buttonLabel": "Login",
"proconnectButtonLabel": "Login with ProConnect", "proconnectButtonLabel": "Login with ProConnect",
+3 -6
View File
@@ -44,18 +44,15 @@
}, },
"slide1": { "slide1": {
"title": "Make the switch to simplicity. Try us now!", "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.", "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"
}, },
"slide2": { "slide2": {
"title": "Host group calls without limits", "title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.", "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"
}, },
"slide3": { "slide3": {
"title": "Transform your meetings with AI", "title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!", "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"
} }
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"success": { "success": {
"title": "Your recording is ready!", "title": "Your recording is ready!",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.", "body": "Recording of the meeting {{room}} from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).", "expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download", "button": "Download",
"warning": { "warning": {
+62 -16
View File
@@ -2,7 +2,8 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "You have left the meeting", "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", "home": "Return to home",
"back": "Rejoin the meeting" "back": "Rejoin the meeting"
@@ -15,7 +16,7 @@
"audio": "Audio settings", "audio": "Audio settings",
"video": "Video settings" "video": "Video settings"
}, },
"effects": "Effects", "effects": "Backgrounds and Effects",
"videoinput": { "videoinput": {
"choose": "Select camera", "choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed", "permissionsNeeded": "Select camera - permission needed",
@@ -38,7 +39,7 @@
}, },
"join": { "join": {
"effects": { "effects": {
"description": "Apply effects", "description": "Apply backgrounds and effects",
"title": "Effects", "title": "Effects",
"subTitle": "Configure your camera's effects." "subTitle": "Configure your camera's effects."
}, },
@@ -85,6 +86,7 @@
"heading": "Your meeting is ready", "heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.", "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.", "permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": { "phone": {
"call": "Call:", "call": "Call:",
"pinCode": "Code:" "pinCode": "Code:"
@@ -154,7 +156,8 @@
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?", "body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
"settings": "To stop seeing this message, go to Settings > General.", "settings": "To stop seeing this message, go to Settings > General.",
"stayButton": "Continue the call", "stayButton": "Continue the call",
"leaveButton": "Leave now" "leaveButton": "Leave now",
"countdownAnnouncement": "Call ends in {{duration}}."
}, },
"controls": { "controls": {
"microphone": "Microphone", "microphone": "Microphone",
@@ -191,7 +194,9 @@
"leave": "Leave", "leave": "Leave",
"participants": { "participants": {
"open": "Hide everyone", "open": "Hide everyone",
"closed": "See everyone" "closed": "See everyone",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
}, },
"tools": { "tools": {
"open": "Hide more tools", "open": "Hide more tools",
@@ -205,7 +210,18 @@
"reactions": { "reactions": {
"button": "Send reaction", "button": "Send reaction",
"send": "Send reaction {{emoji}}", "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": { "options": {
@@ -216,7 +232,7 @@
"screenRecording": "Screen recording", "screenRecording": "Screen recording",
"transcript": "Transcription", "transcript": "Transcription",
"username": "Update Your Name", "username": "Update Your Name",
"effects": "Apply effects", "effects": "Backgrounds and Effects",
"switchCamera": "Switch camera", "switchCamera": "Switch camera",
"fullscreen": { "fullscreen": {
"enter": "Fullscreen", "enter": "Fullscreen",
@@ -233,15 +249,34 @@
"clear": "Disable effect", "clear": "Disable effect",
"blur": { "blur": {
"title": "Background blur", "title": "Background blur",
"light": "Light blur", "status": {
"normal": "Blur", "none": "Background clear.",
"apply": "Enable blur", "light": "Background slightly blurred.",
"clear": "Disable blur" "strong": "Background strongly blurred."
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
}, },
"virtual": { "virtual": {
"title": "Virtual background", "title": "Virtual background",
"apply": "Enable virtual background", "selectedLabel": "Background applied:",
"clear": "Disable virtual background" "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": { "faceLandmarks": {
"title": "Visual Effects", "title": "Visual Effects",
@@ -256,9 +291,11 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effects", "effects": "Backgrounds and Effects",
"chat": "Messages in the chat", "chat": "Messages in the chat",
"transcript": "Transcribe", "transcript": "Transcribe",
"screenRecording": "Record", "screenRecording": "Record",
@@ -268,7 +305,7 @@
}, },
"content": { "content": {
"participants": "participants", "participants": "participants",
"effects": "effects", "effects": "Backgrounds and Effects",
"chat": "messages", "chat": "messages",
"transcript": "transcribe", "transcript": "transcribe",
"screenRecording": "record", "screenRecording": "record",
@@ -468,6 +505,7 @@
"participants": { "participants": {
"subheading": "In room", "subheading": "In room",
"you": "You", "you": "You",
"unknown": "Unknown participant",
"host": "Host", "host": "Host",
"contributors": "Contributors", "contributors": "Contributors",
"collapsable": { "collapsable": {
@@ -515,6 +553,14 @@
"ariaLabel": "Pin {{name}}" "ariaLabel": "Pin {{name}}"
} }
}, },
"pinAnnouncements": {
"pin": "The video of {{name}} is pinned.",
"unpin": "The video of {{name}} is no longer pinned.",
"self": {
"pin": "Your video is pinned.",
"unpin": "Your video is no longer pinned."
}
},
"recordingStateToast": { "recordingStateToast": {
"transcript": { "transcript": {
"started": "Transcribing", "started": "Transcribing",
@@ -543,7 +589,7 @@
"enable": "Pin", "enable": "Pin",
"disable": "Unpin" "disable": "Unpin"
}, },
"effects": "Apply visual effects", "effects": "Apply backgrounds and effects",
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "fullScreen": "Full screen"
}, },
@@ -75,6 +75,8 @@
"options": { "options": {
"french": "French (fr)", "french": "French (fr)",
"english": "English (en)", "english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic" "auto": "Automatic"
} }
} }
@@ -106,12 +108,18 @@
"label": "Language" "label": "Language"
}, },
"settingsButtonLabel": "Settings", "settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": { "tabs": {
"account": "Profile", "account": "Profile",
"audio": "Audio", "audio": "Audio",
"video": "Video", "video": "Video",
"general": "General", "general": "General",
"notifications": "Notifications", "notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription" "transcription": "Transcription"
} }
} }
@@ -70,19 +70,42 @@
}, },
"article7": { "article7": {
"title": "7. DINUM Commitments and Responsibilities", "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": { "sections": {
"section1": { "section1": {
"title": "7.1. Security and Platform Access", "title": "7.1. Service Availability",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"paragraphs": [ "paragraphs": [
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.", "The service is available 24/7, except during maintenance windows.",
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.", "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.",
"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." "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": { "section2": {
"title": "7.2 Open Source and Licenses", "title": "7.2. Service Security",
"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." "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."
} }
} }
}, },
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "Accès interdit" "heading": "Accès interdit"
}, },
"loading": "Chargement…", "loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que", "loggedInUserTooltip": "Connecté en tant que ",
"login": { "login": {
"buttonLabel": "Se connecter", "buttonLabel": "Se connecter",
"proconnectButtonLabel": "S'identifier avec ProConnect", "proconnectButtonLabel": "S'identifier avec ProConnect",
+3 -6
View File
@@ -44,18 +44,15 @@
}, },
"slide1": { "slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !", "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.", "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"
}, },
"slide2": { "slide2": {
"title": "Organisez des appels de groupe sans limite", "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.", "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"
}, },
"slide3": { "slide3": {
"title": "Transformez vos réunions avec l'IA", "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 !", "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"
} }
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"success": { "success": {
"title": "Votre enregistrement est prêt !", "title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.", "body": "Enregistrement de la réunion {{room}} du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).", "expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger", "button": "Télécharger",
"warning": { "warning": {
+63 -17
View File
@@ -2,7 +2,8 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "Vous avez quitté la réunion", "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", "home": "Retourner à l'accueil",
"back": "Réintégrer la réunion" "back": "Réintégrer la réunion"
@@ -15,7 +16,7 @@
"audio": "Paramètres audio", "audio": "Paramètres audio",
"video": "Paramètres video" "video": "Paramètres video"
}, },
"effects": "Effets d'arrière-plan", "effects": "Arrière-plans et effets",
"videoinput": { "videoinput": {
"choose": "Choisir la webcam", "choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires", "permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -39,8 +40,8 @@
"join": { "join": {
"heading": "Rejoindre la réunion ?", "heading": "Rejoindre la réunion ?",
"effects": { "effects": {
"description": "Effets d'arrière-plan", "description": "Arrière-plans et effets",
"title": "Effets", "title": "Arrière-plans et effets",
"subTitle": "Paramétrez les effets de votre caméra." "subTitle": "Paramétrez les effets de votre caméra."
}, },
"joinLabel": "Rejoindre", "joinLabel": "Rejoindre",
@@ -85,6 +86,7 @@
"heading": "Votre réunion est prête", "heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.", "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.", "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": { "phone": {
"call": "Appelez le :", "call": "Appelez le :",
"pinCode": "Code :" "pinCode": "Code :"
@@ -154,7 +156,8 @@
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?", "body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.", "settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel", "stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant" "leaveButton": "Quitter maintenant",
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
}, },
"controls": { "controls": {
"microphone": "Microphone", "microphone": "Microphone",
@@ -191,7 +194,9 @@
"leave": "Quitter", "leave": "Quitter",
"participants": { "participants": {
"open": "Masquer les participants", "open": "Masquer les participants",
"closed": "Afficher les participants" "closed": "Afficher les participants",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
}, },
"tools": { "tools": {
"open": "Masquer les outils de réunion", "open": "Masquer les outils de réunion",
@@ -205,7 +210,18 @@
"reactions": { "reactions": {
"button": "Envoyer une réaction", "button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}", "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": { "options": {
@@ -216,7 +232,7 @@
"screenRecording": "Enregistrement", "screenRecording": "Enregistrement",
"transcript": "Transcription", "transcript": "Transcription",
"username": "Choisir votre nom", "username": "Choisir votre nom",
"effects": "Effets d'arrière-plan", "effects": "Arrière-plans et effets",
"switchCamera": "Changer de caméra", "switchCamera": "Changer de caméra",
"fullscreen": { "fullscreen": {
"enter": "Plein écran", "enter": "Plein écran",
@@ -230,18 +246,37 @@
"cameraDisabled": "Votre caméra est désactivée.", "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 :(", "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", "heading": "Flou",
"clear": "Désactiver l'effect", "clear": "Désactiver l'effet",
"blur": { "blur": {
"title": "Flou d'arrière-plan", "title": "Flou d'arrière-plan",
"light": "Flou léger", "status": {
"normal": "Flou", "none": "Arrière-plan net.",
"apply": "Activer le flou", "light": "Arrière-plan légèrement flouté.",
"clear": "Désactiver le flou" "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": { "virtual": {
"title": "Arrière-plan virtuel", "title": "Arrière-plan virtuel",
"apply": "Activer l'arrière-plan virtuel", "selectedLabel": "Arrière-plan appliqué :",
"clear": "Désactiver l'arrière-plan virtuel" "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": { "faceLandmarks": {
"title": "Effets visuels", "title": "Effets visuels",
@@ -256,9 +291,11 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Effets", "effects": "Arrière-plans et effets",
"chat": "Messages dans l'appel", "chat": "Messages dans l'appel",
"transcript": "Transcrire", "transcript": "Transcrire",
"screenRecording": "Enregistrer", "screenRecording": "Enregistrer",
@@ -468,6 +505,7 @@
"participants": { "participants": {
"subheading": "Dans la réunion", "subheading": "Dans la réunion",
"you": "Vous", "you": "Vous",
"unknown": "Participant inconnu",
"contributors": "Contributeurs", "contributors": "Contributeurs",
"host": "Organisateur de la réunion", "host": "Organisateur de la réunion",
"collapsable": { "collapsable": {
@@ -515,6 +553,14 @@
"ariaLabel": "Épingler {{name}}" "ariaLabel": "Épingler {{name}}"
} }
}, },
"pinAnnouncements": {
"pin": "La vidéo de {{name}} est épinglée.",
"unpin": "La vidéo de {{name}} nest plus épinglée.",
"self": {
"pin": "Votre vidéo est épinglée.",
"unpin": "Votre vidéo nest plus épinglée."
}
},
"recordingStateToast": { "recordingStateToast": {
"transcript": { "transcript": {
"started": "Transcription en cours", "started": "Transcription en cours",
@@ -543,7 +589,7 @@
"enable": "Épingler", "enable": "Épingler",
"disable": "Annuler l'épinglage" "disable": "Annuler l'épinglage"
}, },
"effects": "Effets d'arrière-plan", "effects": "Arrière-plans et effets",
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },

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