Compare commits

...

140 Commits

Author SHA1 Message Date
lebaudantoine 883289bc6f fixup! wip init endpoint for addons/sessions 2026-01-30 13:53:34 +01:00
lebaudantoine 619c961598 wip init endpoint for addons/sessions 2026-01-26 18:59:32 +01:00
lebaudantoine 136d2d610b wip refactor jwt authentication 2026-01-26 16:26:27 +01:00
lebaudantoine e4e2c15505 wip refactor token generation 2026-01-26 16:26:27 +01:00
lebaudantoine 289a24545d wip configure external application api 2026-01-26 16:26:27 +01:00
lebaudantoine 99a18b6e90 🩹(backend) use case-insensitive email matching in the external api
Fix a minor issue in the external API where users were matched using
case-sensitive email comparison, while authentication treats emails as
case-insensitive. This caused inconsistencies that are now resolved.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

It closes #837.

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

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

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

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

Note: if the API to start egress hasn’t responded yet, the webhook may fail to
find the recording because it currently matches by worker ID. This is unstable.
A better approach would be to pass the database ID in the egress metadata and
recover the recording from it in the webhook.
2026-01-05 00:14:00 +01:00
lebaudantoine 2863aa832d 🔥(frontend) remove useless font block on icons
Myabd, this css property is useful only on font face, it has
no effect on materials-related classes.
2026-01-05 00:14:00 +01:00
lebaudantoine 48af2e3a5f 📝(changelog) list all the recent recording-related enhancements
Worked on a large PR (#827) and chose to consolidate all new features and
refactorings in the changelog at the end of the work instead of updating it per
commit. Not ideal—acknowledge this is bad practice.
2026-01-04 20:22:15 +01:00
lebaudantoine 8a0dfd1478 🩹(frontend) make recording statuses more accurate
Link recording statuses to the `isRecording` attribute from the room on the
client side.

After the refactor, the frontend relied only on recording statuses computed by
the backend. However, when egress is started and the backend is notified, the
recording is not actually active yet. It takes some time for the egress to join
the room and begin recording.

Enrich the frontend by combining backend statuses with the room recording state
to more accurately reflect when recording is truly active. This avoids missing
the first few seconds of audio at the beginning of a recording.
2026-01-04 20:22:15 +01:00
lebaudantoine 37a2f3985a 🛂(frontend) display transcription settings for privileged users
Only display transcription settings to room admins or owners. Showing these
controls to users without the required privileges would be misleading, since
they cannot actually configure or apply the settings.
2026-01-04 20:22:15 +01:00
lebaudantoine 39271544d7 (summary) link transcript to their downloadable recording
Link the transcription document to its related recording by adding a short
header explaining that users can download the audio file via a dedicated link.

This was a highly requested feature, as many users need to keep their audio
files.

As part of a small refactor, remove the argument length check in the metadata
analytics class. The hardcoded argument count made code evolution harder and was
easy to forget updating. Argument unwrapping remains fragile and should be
redesigned later to be more robust.

The backend is responsible for generating the download link to ensure
consistency and reliability.

I tried adding a divider, but the Markdown-to-Yjs conversion is very lossy and
almost never handles it correctly. Only about one out of ten conversions works
as expected.
2026-01-04 20:22:15 +01:00
lebaudantoine f7b45622bc 🚸(frontend) enhance recording state toast icon
Specify distinct icons in the recording state toast for each mode to provide
clearer visual feedback on what is actually happening. Remove the pulse CSS
animation, as it did not improve visual clarity and accessibility.
2026-01-04 20:22:15 +01:00
lebaudantoine f3e2bbf701 (frontend) allow user to request recording
Inspired by @ericboucher’s proposal, allow non-admin or non-owner participants
to request the start of a transcription or a recording.

All participants are notified of the request, but only the admin can actually
open the menu and start the recording.

This is a first simple and naive implementation and will be improved later.

Prefer opening the relevant recording menu for admins instead of offering a
direct quick action to start recording. With more options now tied to recording,
keeping the responsibility for starting it encapsulated within the side panel
felt cleaner.

This comes with some UX trade-offs, but it’s worth trying.

I also simplified the notification mechanism by disabling the action button for
the same duration as the notification, preventing duplicate triggers. This is
not perfect, since hovering the notification pauses its display, but it avoids
most accidental re-triggers.
2026-01-04 20:22:15 +01:00
lebaudantoine 6e1ad7fca5 🚸(frontend) introduce an icon on the login prompt for visual distinction
This will be useful when adding an alternative card to request the meeting
creator to start the recording.
2026-01-04 20:22:15 +01:00
lebaudantoine d9dbededee 🚸(frontend) enhance the visual hierarchy of the no access view
Rework the visual hierarchy of the “no access” view to align it with other
presentation modes and ensure the title order is clear and understandable for
users.
2026-01-04 20:22:15 +01:00
lebaudantoine 70403ad0d8 (frontend) handle another recording mode is active
Refactor literals in the recording status hook and introduce a new status.
Align the login prompt style with the newly introduced warning message, and
guide users by clearly indicating that the two modes are mutually exclusive.
Users are prompted to stop the other mode before starting a new one.

This situation should happen less often now that checkboxes allow users to start
transcription and recording together. Hopefully, the UX is clear enough.

The growing number of props passed to the controls buttons may become an issue
and will likely require refactoring later.
2026-01-04 20:22:15 +01:00
lebaudantoine 9d69fe4f4f ♻️(frontend) introduce a recording mutation hook
Mutualize and factorize the recording API error modal in a single place, and
extract all recording mutations into a dedicated hook exposing both start and
stop actions.

This hook is responsible for interacting with the API error dialog when needed.
Previously, this logic was duplicated across each side panel; centralizing it
clarifies responsibilities and reduces duplication.
2026-01-04 20:22:15 +01:00
lebaudantoine 08f281e778 ♻️(frontend) introduce a recording provider with clear responsibilities
This component is now extensible and way easier to understand.

Previously, the recording state toast was implicitly acting as a provider,
making its core responsibility unclear for developers. Its role is not to
inject all recording-related elements into the videoconference DOM, but to
expose a clean recording state toast reflecting the current recording status.

This commit also fixes the limit-reached modal that was no longer appearing
after the refactor, ensures the modal is always rendered,
and removes unused React ARIA labels.

In the original code, the limit reached dialog was wrongly rendered
only when the recording state toast was null.
It was a bug in the implementation. Fix it.
2026-01-04 20:22:15 +01:00
lebaudantoine da3dfedcbc (frontend) update recording metadata alongside recording state changes
Following the previous commit, refactor the frontend to rely on room metadata to
track which recording is running and update the interface accordingly. This
implementation is not fully functional yet.

The limit-reached dialog triggering mechanism is currently broken and will be
fixed in upcoming commits. I also simplified the interface lifecycle, but some
edge cases are not yet handled—for example, transcription controls should be
disabled when a screen recording is started. This will be improved soon.

Controls were extracted into a reusable component using early returns. This
makes the logic easier to read, but slightly increases the overall complexity of
the recording side panel component.

Relying on literals to manage recording statuses is quite poor, feel free to
enhance this part.
2026-01-04 20:22:15 +01:00
lebaudantoine 16badde82d 🚧(backend) update recording metadata alongside recording state changes
Previously, this was handled manually by the client, sending notifications to
other participants and keeping the recording state only in memory. There was no
shared or persisted state, so leaving and rejoining a meeting lost this
information. Delegating this responsibility solely to the client was a poor
choice.

The backend now owns this responsibility and relies on LiveKit webhooks to keep
room metadata in sync with the egress lifecycle.

This also reveals that the room.isRecording attribute does not update as fast
as the egress stop event, which is unexpected and should be investigated
further.

This will make state management working when several room’s owner will be in
the same meeting, which is expected to arrive any time soon.
2026-01-04 20:22:15 +01:00
lebaudantoine 57a7523cc4 ♻️(frontend) extract recording row layout in reusable component
Now that screen recording and transcription share the same UI presentation,
extract the row logic into a reusable component to avoid code duplication and
improve code maintainability.
2026-01-04 20:22:15 +01:00
lebaudantoine 398ef1ae8a ♻️(frontend) encapsulate transcript language logic in a hook
Provide a clear interface to handle transcription language selection and
behavior, reducing code duplication across the codebase.
2026-01-04 20:22:15 +01:00
lebaudantoine f7d463f380 ♻️(frontend) encapsulate recording maximum duration handling
Centralize the logic to compute, internationalize, and present the maximum
recording duration in a human-readable way, reducing duplication across the
codebase.
2026-01-04 20:22:15 +01:00
lebaudantoine 5e1705d259 🚸(frontend) align screen recording side panel ux
Refactor the screen recording side panel to align with the transcription UX,
ensuring a more consistent and homogeneous user experience.

This commit also introduces a checkbox allowing users to request transcription
of the screen recording, which is one of the most requested features.

The side panel will be enriched with more information soon, especially once
Fichier is integrated for storing recordings, so the destination can be made
explicit.

More recording settings (layout, quality, etc.) will be introduced in upcoming
commits.
2026-01-04 20:22:15 +01:00
lebaudantoine 236245740f ♻️(frontend) refactor recording side panels to reduce code duplication
A lot of duplication existed, so I started factorizing components
now that a proper user experience is clearer.

Without over-abstracting, the first step introduces a reusable
“no access” view with configurable message and image.

This is just the beginning: props passing is still not ideal, but
it’s sufficient to merge and significantly reduce duplication.
2026-01-04 20:22:15 +01:00
lebaudantoine 9ebf2f277b 🔊(summarize) log language with more details
Enhance transcription language logging by explicitly indicating
when no language is provided and the code falls back to automatic
detection mode.
2026-01-04 20:22:15 +01:00
lebaudantoine 049a9079c4 (frontend) chose transcription’s language in settings
Add a key feature allowing users to choose the language
of their transcription via a setting.

The default value is set to French, the most commonly used
language across our user base.

Users can still select English or “Automatic,” which re-enables automatic
language detection if no default is configured on the microservice.
2026-01-04 20:22:15 +01:00
lebaudantoine 19f8c96e9d (frontend) allow parametrization of the transcrip document destination
Not all self-hosted instances will configure this setting, so a default text is
shown when the destination is unknown.

This is important to let users quickly click the link and understand which
platform is used to handle the transcription documents.
2026-01-04 20:22:15 +01:00
lebaudantoine 857b4bd1f1 (summary) handle video files more efficiently
Video files are heavy recording files, sometimes several hours long.

Previously, recordings were naively submitted to the Whisper API without
chunking, resulting in very large requests that could take a long time
to process. Video files are much larger than audio-only files, which
could cause performance issues during upload.

Introduce an extra step to extract the audio component from MP4 files,
producing a lighter audio-only file (to be confirmed). No re-encoding
is done, just a minimal FFmpeg extraction based on community guidance,
since I’m not an FFmpeg expert.

This feature is experimental and may introduce regressions, especially
if audio quality or sampling is impacted, which could reduce Whisper’s
accuracy. Early tests with the ASR model worked, but it has not been
tested on long recordings (e.g., 3-hour meetings),
which some users have.
2026-01-04 20:22:15 +01:00
lebaudantoine 309c532811 (backend) submit screen recordings to the summary microservice
Screen recording are MP4 files containing video)

The current approach is suboptimal: the microservice will later be updated to
extract audio paths from video, which can be heavy to send to the Whisper
service.

This implementation is straightforward, but the notification service is now
handling many responsibilities through conditional logic. A refactor with a
more configurable approach (mapping attributes to processing steps via
settings) would be cleaner and easier to maintain.
For now, this works; further improvements can come later.

I follow the KISS principle, and try to make this new feature implemented
with the lesser impact on the codebase. This isn’t perfect.
2026-01-04 20:22:15 +01:00
lebaudantoine 4e5032a7a4 ♻️(summary) enhance file handling in the Celery worker
The previous code lacked proper encapsulation, resulting in an overly complex
worker. While the initial naive approach was great for bootstrapping the
feature, the refactor introduces more maturity with dedicated service classes
that have clear, single responsibilities.

During the extraction to services, several minor issues were fixed:

1) Properly closing the MinIO response.

2) Enhanced validation of object filenames and extensions to ensure
correct file handling.

3) Introduced a context manager to automatically clean up temporary
local files, removing reliance on developers.

4) Slightly improved logging and naming for clarity.

5) Dynamic temporary file extension handling when it was previously
always an hardcoded .ogg file, even when it was not the case.
2026-01-04 20:22:15 +01:00
lebaudantoine 4cb6320b83 (summary) add a language parameter for transcription
Pass recording options’ language to the summary service, allowing users to
personalize the recording language.

This is important because automatic language detection often fails, causing
empty transcriptions or 5xx errors from the Whisper API. Users then do not
receive their transcriptions, which leads to frustration. For most of our
userbase, meetings are in French, and automatic detection is unreliable.

Support for language parameterization in the Whisper API has existed for some
time; only the frontend and backend integration were missing.

I did not force French as the default, since a minority of users hold English or
other European meetings. A proper settings tab to configure this value will be
introduced later.
2026-01-04 20:22:15 +01:00
lebaudantoine 587a5bc574 (frontend) allow starting both a recording and a transcription
Major user feature request: allow starting recording and transcription
simultaneously. Inspired by Google Meet UX, add a subtle checkbox letting users
start a recording alongside transcription.

The backend support for this feature is not yet implemented and will come in
upcoming commits, I can only pass the options to the API. The update of the
notification service will be handled later.
We’re half way with a functional feature.

This is not enabled by default because screen recording is resource-intensive. I
prefer users opt in rather than making it their default choice until feature
usage and performance stabilize.
2026-01-04 20:22:15 +01:00
lebaudantoine 0d8c76cd03 (backend) add a flexible JSON field to store recording options
Using a JSON field allows iterating on recording data without running a new
migration each time additional options or metadata need to be tracked.

This comes with trade-offs, notably weaker data validation and less clarity on
which data can be stored alongside a recording.

In the long run, this JSON field can be refactored into dedicated columns once
the feature and data model have stabilized.
2026-01-04 20:22:15 +01:00
lebaudantoine b19ac7f82b 🚸(frontend) rework the transcription side panel
Inspired by proprietary solutions, add clearer details on how transcription
works and what users can expect from the feature. This new presentation is much
simpler to read, parse, and understand than the previous large block of text
that users were not reading at all.

Using icons helps users quickly understand where the transcription is sent, how
they are notified, and which meeting language is used.

Some information is currently hardcoded and will be parameterized in upcoming
commits. This work is ongoing.
2026-01-04 20:22:15 +01:00
lebaudantoine d3e6af6f82 🚸(frontend) rework the meeting tools side panel UX
Explicitly explain that transcription is reserved for public servants. Remove
the temporary beta form: the feature is now available to all public servants,
with restrictions based on domain. Make white-labeling rules explicit and
clarify who to contact for access.

The beta form created frustration, with users registering and never hearing
back from the team.

Improve guidance when a user may be the meeting host but is not logged in, and
therefore cannot activate recording. Add a clear hint and a quick action to log
in. This decision is based on frequent support requests where users could not
understand why recording was unavailable while they were simply not logged in.
2026-01-04 20:22:15 +01:00
lebaudantoine 2fbb476b02 🔥(frontend) remove beta tag on recording feature
Initially, I thought presenting the recording feature as a beta would clearly
signal that it was still under construction and being improved. In practice, it
sent a negative signal to users, reduced trust, and still generated many
questions for the support team.

Without clearly explaining why the feature was in beta or what was coming next,
the label only added confusion. I chose to simplify the interface and remove the
beta indication altogether.
2026-01-04 20:22:15 +01:00
lebaudantoine 1b2139a9ff 💄(frontend) refactor meeting tools presentation
Follow Robin’s suggestion on the meeting tool layout presentation. The result
does not yet exactly match the Figma design, and I took some freedom to stay
closer to a Google Meet–like layout.

In the initial approach, it was hard to understand that the full option was
clickable. Adding a light background improves discoverability and usability.
2026-01-04 20:22:15 +01:00
lebaudantoine 54e47e33a9 🔧(frontend) configure Material Icons and Symbols
Robin chose to adopt Material Design icons, inspired by NVasse’s commit on
Fichier. This sets up the required CSS to easily use Material Icons throughout
the application.

Eventually, all icons in the app will be replaced with Material ones. For now,
the setup is only used in the recording UI refactor.
2026-01-04 20:22:15 +01:00
lebaudantoine 20b99cf2ad 🚸(frontend) simplify recording wording
Simplify wording and presentation of the recording feature heading,
using a more concise and familiar product-style language inspired by
well-known proprietary solutions.
2026-01-04 20:22:15 +01:00
lebaudantoine db75b0eae9 📱(frontend) solve recording responsiveness issue
Many public servants use PCs with unusual screen resolutions. The screen
height is often quite small, which caused responsiveness issues on the
vertical axis.

When opening the side panel, they could not see the button to start the
recording. I improved the vertical responsiveness to address this issue and
reduce support requests such as “I cannot see the button”.

Users typically do not think about scrolling inside the side panel, so the
layout now better fits constrained screen heights.
2026-01-04 20:22:15 +01:00
lebaudantoine 5163f849e4 ♻️(frontend) enhance feedback banner copywritting
Eliminate the perception of being 'under development,'
which can undermine trust with potential users.

Focus on creating a more confident and reassuring experience.
2025-12-29 12:29:22 +01:00
lebaudantoine 4345711771 (frontend) remove the beta badge
Product is out of beta since the 15th of December.
2025-12-29 12:29:22 +01:00
lebaudantoine 7c690c369e ♻️(agents) remove deprecation warning for RoomInput/OutputOptions
Follow LiveKit's recommendations.
2025-12-28 22:34:38 +01:00
lebaudantoine ef09629566 ⬆️(agent) upgrade temporary livekit-agent plugin for kyutai
0.0.5 was ignoring the API key environment variable. I fixed it.
2025-12-28 22:34:38 +01:00
lebaudantoine cff1dbf39e ♻️(agent) simplify Deepgram config and support Kyutai
The previous attempt to make the Deepgram configuration extensible
introduced unnecessary complexity for a very limited use case and
made it harder to add new STT backends.

Refactor to a deliberately simple and explicit design with minimal
cognitive overhead. Configuration is now fully driven by environment
variables and provides enough flexibility for ops to select and
parameterize the STT backend.
2025-12-28 21:14:20 +01:00
lebaudantoine b466515306 (agent) add a temporary livekit-agent plugin for kyutai
Until a Pull Request is merged with our changes on livekit-agent
to support Kyutai API, we will use a custom and hacky python
library made from Arnaud's researches and published on an
unofficial pypi project page.

Everything is quite "draft" but it allows us to deploy and test
in real situation the work from Arnaud.
2025-12-28 21:14:20 +01:00
lebaudantoine c678e9420e ⬆️(agent) upgrade livekit-agent related dependencies
Our custom LaSuite Kyutai plugin requires livekit-agent above 1.3.3.
2025-12-28 21:14:20 +01:00
lebaudantoine 3af115dafb 🐛(agent) restore missing system deps in Docker image
Some system dependencies were unexpectedly missing, causing the
LiveKit agent framework to fail at runtime.

Install the required dependencies based on runtime error logs.
This fixes Docker image failures in the remote (staging) environment.
2025-12-28 21:14:20 +01:00
lebaudantoine 0daa6d0432 🔖(release) release 1.1.0
- enable user provisioning through the external viewset
- add LLM observability on the summary service
2025-12-22 11:23:28 +01:00
lebaudantoine 493d7b96f1 📝(docs) add missing trailing slash
A trailing slash was missing in the documentation.
Spotted by T. Lemeur when integrating the API.
2025-12-22 09:57:34 +01:00
lebaudantoine c2c478c367 🩹(backend) remove environment prefix from recently introduced settings
The prefix was unintentionally added and wasn’t caught during review.
This change corrects it.
2025-12-21 16:27:11 +01:00
lebaudantoine b5895ccba0 🩹(summary) fix missing f-string
Spotted by code rabbit. Missing F-string was leading
to an unexpected behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine aff87d4953 (summary) add Langfuse observability for LLM API calls
Implement Langfuse tracing integration for LLM service calls to capture
prompts, responses, latency, token usage, and errors, enabling
comprehensive monitoring and debugging of AI model interactions
for performance analysis and cost optimization.
2025-12-19 14:29:56 +01:00
lebaudantoine c81ef38005 ♻️(summary) extract LLMService class into dedicated module
Move LLMService class from existing file into separate dedicated
module to improve code organization.
2025-12-19 14:29:56 +01:00
lebaudantoine 4256eb403d 🔒️(summary) refactor configuration secrets to use Pydantic SecretStr
Replace plain string fields with Pydantic SecretStr class for all
sensitive configuration values in FastAPI settings to prevent accidental
exposure in logs, error messages, or debugging output, following
security best practices for credential handling.
2025-12-19 14:29:56 +01:00
lebaudantoine 43f3e4691b (summmary) add Langfuse to summary service dependencies
Install Langfuse observability client in summary service
to enable LLM tracing, monitoring, and debugging capabilities
for AI-powered summarization workflows,
improving visibility into model performance and behavior.
2025-12-19 14:29:56 +01:00
lebaudantoine 10aac93c36 📝(backend) improve user provisioning documentation
try to make explicit all implicit implementation's details
2025-12-19 13:41:37 +01:00
lebaudantoine 4e6bc157b0 ♻️(backend) standardize error response format in token endpoint
Align error response with the pattern used at other places of the codebase.
2025-12-19 13:41:37 +01:00
lebaudantoine fe83c5fa07 (backend) add unit tests for user provisioning via external API
Add test coverage for provisional user creation through the external API,
including creating users with email-only (no sub)
2025-12-19 13:41:37 +01:00
lebaudantoine 827014c952 ♻️(backend) explicitly enforce sub field immutability
Add OIDC_USER_SUB_FIELD_IMMUTABLE setting to our config and enforce
it in the user viewset. Previously relied on implicit Django
LaSuite defaults.

Makes the sub mutability constraint explicit and ensures it's enforced
at the application level, critical for provisional users where sub is
assigned on first login.
2025-12-19 13:41:37 +01:00
lebaudantoine 9523f52546 📝(docs) clarify sub as optional to support email-only user provisioning
Update the sub field documentation to explicitly reflect its optional nature.
Originally intended to be mandatory, sub became optional due to a code issue.
This change acknowledges and formalizes that behavior as intentional.

The optional sub enables external API integrations to provision users with
only an email address. Full identity (sub) is assigned on first login,
allowing third-party platforms to create users before they authenticate.
2025-12-19 13:41:37 +01:00
lebaudantoine 8348a55f7e (backend) enable user creation via email for external integrations
Allow external platforms using the public API to create provisional users
with email-only identification when the user doesn't yet exist in our
system. This removes a key friction point blocking third-party integrations
from fully provisioning access on behalf of new users.

Provisional users are created with email as the primary identifier. Full
identity reconciliation (sub assignment) occurs on first login, ensuring
reliable user identification is eventually established.

While email-only user creation is not ideal from an identity perspective,
it provides a pragmatic path to unlock integrations and accelerate adoption
through external platforms that are increasingly driving our videoconference
tool's growth.
2025-12-19 13:41:37 +01:00
lebaudantoine a4b76433ab 🧑‍💻(release) introduce a release helper tool
Discussed at lunch with our CTO, enhance tooling
around release preparation. Naive bash script generated
using Claude. Please feel free to enhance it.
2025-12-17 19:55:24 +01:00
lebaudantoine ae863418cd 📝(changelog) reorganize sections to match Keep a Changelog convention
Reorder CHANGELOG section headings to follow standard Keep a Changelog format
(Added, Changed, Deprecated, Removed, Fixed, Security) for consistent structure
that users expect when reviewing release notes.
2025-12-17 18:41:45 +01:00
lebaudantoine dcdae26610 🔖(release) release 1.0.1
Patch several accessibility issues.
2025-12-17 17:36:01 +01:00
Cyril 90c0442d35 (frontend) fix focus scroll jump during side panel animation
preventScroll avoids layout shift that broke the slide-in chat animation

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:25 +01:00
Cyril 9093371d25 (frontend) restore focus on chat close
restore keyboard focus to the triggering element when the chat panel closes.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:13:24 +01:00
Cyril 1d45d3aa7c (frontend) focus chat input on panel open
move keyboard focus to the message input when the chat panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-17 16:12:44 +01:00
Cyril fcb89c520e ️(frontend) fix heading level in modal to maintain semantic hierarchy
replaced h3 with h2 for accessibility and proper document structure
2025-12-17 16:00:35 +01:00
Cyril 309ce0989d ️(frontend) indicate external link opens in new window on feedback
added title attribute to clarify link behavior for screen reader users
2025-12-17 15:42:30 +01:00
Cyril a6c154374f ️(frontend) change ptt keybinding from space to v
ptt now uses v key to avoid accidental activation when typing
2025-12-17 15:18:46 +01:00
lebaudantoine b0e27b38e2 🔒️(backend) avoid serializing rooms's pin code when restricted
Prevent anonymous users waiting in the lobby, or attacker
to discover the room pin code, that would allow them to join a room.
2025-12-17 10:05:23 +01:00
Cyril 9bdc68f9c9 (frontend) create reusable shortcut tooltip component
extracted tooltip into a component to unify style and ease reuse across ui

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:43 +01:00
Cyril 4545e9fa1e 💄(frontend) update shortcut tooltip position and style for consistency
moved tooltip from left to right to avoid overlap with recording indicator
2025-12-16 09:41:43 +01:00
Cyril 3f1edbf134 ️(frontend) fix SR texts/translations to avoid double announcement
Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-16 09:41:42 +01:00
Cyril 4f2764eef4 ️(frontend) add tooltip and sr hint for f2 shortcut to bottom toolbar
helps keyboard and sr users discover the f2 shortcut for toolbar access

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:51 +01:00
Cyril b11cc6e9da ️(frontend) update blur and focus translations for participants
adds fr/en/de/nl translations for blur and focus accessibility labels

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:40 +01:00
Cyril 0a7eb97c90 ️(frontend) hide avatar initials from sr to avoid duplicate names
prevents screen readers from announcing participant names twice

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:57:26 +01:00
Cyril db188075af ️(frontend) improve meeting a11y: blur, focus, hover, sr announcements
enhances keyboard nav and screen reader support for meeting interface

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-11 14:56:58 +01:00
172 changed files with 5655 additions and 1979 deletions
+16
View File
@@ -147,6 +147,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -178,6 +186,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+71 -2
View File
@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -9,4 +8,74 @@ and this project adheres to
## [Unreleased]
-
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
## [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
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
## [1.0.1] - 2025-12-17
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
+160
View File
@@ -0,0 +1,160 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+1 -1
View File
@@ -235,7 +235,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress
image: livekit/egress:v1.11.0
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
## Requirements
-1
View File
@@ -277,7 +277,6 @@ These are the environmental options available on meet backend.
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
+3 -4
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -21,7 +21,6 @@ info:
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -40,7 +39,7 @@ tags:
description: Room management operations
paths:
/application/token:
/application/token/:
post:
tags:
- Authentication
@@ -283,7 +282,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token` endpoint.
JWT token obtained from the `/application/token/` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -63,7 +63,7 @@ RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Telephony
ROOM_TELEPHONY_ENABLED=True
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
+6
View File
@@ -1,5 +1,11 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+29 -66
View File
@@ -5,6 +5,7 @@ import logging
import os
from dotenv import load_dotenv
from lasuite.plugins import kyutai
from livekit import api, rtc
from livekit.agents import (
Agent,
@@ -13,14 +14,15 @@ from livekit.agents import (
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import deepgram, silero
load_dotenv()
@@ -28,60 +30,26 @@ load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
# Default Deepgram STT configuration
DEEPGRAM_STT_DEFAULTS = {
"model": "nova-3",
"language": "multi",
}
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
DEEPGRAM_STT_SUPPORTED_PARAMS = {
"model",
"language",
}
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def _build_deepgram_stt_kwargs():
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
_stt_instance = deepgram.STT(
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
Only parameters supported by LiveKit's deepgram.STT() are included.
Unsupported parameters are logged as warnings.
"""
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
# Scan environment variables for DEEPGRAM_STT_* pattern
for key, value in os.environ.items():
if key.startswith("DEEPGRAM_STT_"):
# Extract parameter name and convert to lowercase
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
# Check if parameter is supported by LiveKit plugin
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
logger.warning(
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
f"Supported parameters: {supported}"
)
continue
# Parse value type
value_lower = value.lower()
if value_lower in ("true", "false"):
# Boolean values
stt_kwargs[param_name] = value_lower == "true"
elif value.isdigit():
# Integer values
stt_kwargs[param_name] = int(value)
else:
# String values
stt_kwargs[param_name] = value
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
return stt_kwargs
return _stt_instance
class Transcriber(Agent):
@@ -89,12 +57,11 @@ class Transcriber(Agent):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
# Build STT configuration from environment variables
stt_kwargs = _build_deepgram_stt_kwargs()
stt = create_stt_provider()
super().__init__(
instructions="not-needed",
stt=deepgram.STT(**stt_kwargs),
stt=stt,
)
self.participant_identity = participant_identity
@@ -156,19 +123,14 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
options=lk_room_io.RoomOptions(
text_input=False, audio_output=False, text_output=True
),
)
await room_io.start()
@@ -231,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+5 -4
View File
@@ -1,12 +1,13 @@
[project]
name = "agents"
version = "1.0.0"
version = "1.3.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.18",
"livekit-plugins-deepgram==1.2.18",
"livekit-plugins-silero==1.2.18",
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.1"
]
+1
View File
@@ -0,0 +1 @@
"""Meet core add-ons module."""
+126
View File
@@ -0,0 +1,126 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from core.models import User
from core.services.jwt_token_service import TokenService
logger = getLogger(__name__)
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = TokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _get_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def init_session(self) -> str:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
cache_key = self._get_cache_key(session_id)
cache.set(
cache_key,
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
return session_id
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
cache_key = self._get_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str) -> None:
"""Remove session data from cache."""
cache_key = self._get_cache_key(session_id)
cache.delete(cache_key)
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._get_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_access_token(
user, settings.ADDONS_SCOPES
)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
+57
View File
@@ -0,0 +1,57 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
session_id = request.GET.get("session_id")
if not session_id:
return render_error(request, _("Session ID is required."), status=400)
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = request.build_absolute_uri("/addons/redirect")
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
+53
View File
@@ -0,0 +1,53 @@
"""Add-ons API endpoints"""
from logging import getLogger
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from rest_framework import throttling, viewsets
from core.addons.service import TokenExchangeService
logger = getLogger(__name__)
class AuthSessionThrottle(throttling.AnonRateThrottle):
"""Throttle request to the addons auth session endpoints."""
scope = "addons_auth_sessions"
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = [AuthSessionThrottle]
def create(self, request):
"""Create a new pending authentication session."""
session_id = TokenExchangeService().init_session()
return drf_response.Response(
{"session_id": session_id}, status=drf_status.HTTP_201_CREATED
)
def retrieve(self, request, pk=None):
"""Retrieve authentication session data by session ID."""
data = TokenExchangeService().get_session(pk)
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Delete an authentication session by session ID."""
TokenExchangeService().clear_session(pk)
return drf_response.Response(
{"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT
)
+8
View File
@@ -159,6 +159,8 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
@@ -179,6 +181,7 @@ class RecordingSerializer(serializers.ModelSerializer):
"updated_at",
"status",
"mode",
"options",
"key",
"is_expired",
"expired_at",
@@ -210,6 +213,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
required=False,
allow_null=True,
default=dict,
)
class RequestEntrySerializer(BaseValidationOnlySerializer):
+4 -1
View File
@@ -308,10 +308,13 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(room=room, mode=mode)
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
+120 -42
View File
@@ -14,12 +14,13 @@ User = get_user_model()
logger = logging.getLogger(__name__)
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
secret_key = None
algorithm = None
issuer = None
audience = None
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -46,6 +47,87 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = pyJwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
issuer=self.issuer,
audience=self.audience,
)
return payload
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -60,36 +142,35 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
payload = self.decode_jwt(token)
if payload is None:
return None
user_id = payload.get("user_id")
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
secret_key = settings.APPLICATION_JWT_SECRET_KEY
algorithm = settings.APPLICATION_JWT_ALG
issuer = settings.APPLICATION_JWT_ISSUER
audience = settings.APPLICATION_JWT_AUDIENCE
def validate_payload(self, payload):
"""Validate application-specific claims."""
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -98,21 +179,18 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
return (user, payload)
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
secret_key = settings.ADDONS_JWT_SECRET_KEY
algorithm = settings.ADDONS_JWT_ALG
issuer = settings.ADDONS_JWT_ISSUER
audience = settings.ADDONS_JWT_AUDIENCE
class ResourceServerBackend(LaSuiteBackend):
+51 -29
View File
@@ -1,14 +1,12 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -22,6 +20,7 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token_service import TokenService
from . import authentication, permissions, serializers
@@ -93,41 +92,63 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email=email)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
token_service = TokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
data = token_service.generate_access_token(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
)
return drf_response.Response(
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
data,
status=drf_status.HTTP_200_OK,
)
@@ -152,6 +173,7 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
+1 -1
View File
@@ -41,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -0,0 +1,18 @@
# Generated by Django 5.2.9 on 2025-12-29 15:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_application_and_more'),
]
operations = [
migrations.AddField(
model_name='recording',
name='options',
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
),
]
+8 -1
View File
@@ -146,7 +146,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -576,6 +577,12 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
options = models.JSONField(
blank=True,
default=dict,
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
class Meta:
db_table = "meet_recording"
@@ -16,6 +16,23 @@ from core import models
logger = logging.getLogger(__name__)
def get_recording_download_base_url() -> str:
"""Get the recording download base URL with backward compatibility."""
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
old_setting = settings.SCREEN_RECORDING_BASE_URL
if old_setting:
logger.warning(
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
)
if new_setting:
return new_setting
return old_setting
class NotificationService:
"""Service for processing recordings and notifying external services."""
@@ -26,7 +43,12 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
return self._notify_user_by_email(recording)
summary_success = True
if recording.options.get("transcribe", False):
summary_success = self._notify_summary_service(recording)
email_success = self._notify_user_by_email(recording)
return email_success and summary_success
logger.error(
"Unknown recording mode %s for recording %s",
@@ -64,7 +86,7 @@ class NotificationService:
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
"link": f"{get_recording_download_base_url()}/{recording.id}",
}
has_failures = False
@@ -137,12 +159,14 @@ class NotificationService:
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
}
headers = {
@@ -1,7 +1,11 @@
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger
from livekit import api
from core import models, utils
logger = getLogger(__name__)
@@ -14,6 +18,27 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
@@ -2,6 +2,7 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from .exceptions import (
@@ -60,6 +61,15 @@ class WorkerServiceMediator:
finally:
recording.save()
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
"Worker started for room %s (worker ID: %s)",
recording.room,
@@ -0,0 +1,97 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class TokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (default: HS256)
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds (default: 3600)
token_type: Token type (default: Bearer)
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
self._key = secret_key
self._alg = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_access_token(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._alg,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
+27 -3
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models
from core import models, utils
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -118,8 +118,10 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
if self._filter_regex and not self._filter_regex.search(data.room.name):
logger.info("Filtered webhook event for room '%s'", data.room.name)
room_name = data.room.name or data.egress_info.room_name
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
try:
@@ -138,6 +140,20 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event."""
egress_id = data.egress_info.egress_id
try:
recording = models.Recording.objects.get(worker_id=egress_id)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {egress_id} does not exist"
) from err
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
@@ -150,6 +166,14 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -0,0 +1,17 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -0,0 +1,17 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
@@ -60,6 +60,26 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
mock_notify_email.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode_with_transcribe(
mock_notify_email, mock_notify_summary
):
"""Test notification routing for screen recording mode with transcribe option."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
mock_notify_summary.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
@@ -82,6 +82,7 @@ def test_api_recordings_list_authenticated_direct(role, settings):
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"options": {},
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -95,6 +95,7 @@ def test_api_recording_retrieve_administrators(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -130,6 +131,7 @@ def test_api_recording_retrieve_owners(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -169,6 +171,7 @@ def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
@@ -209,6 +212,7 @@ def test_api_recording_retrieve_expired(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -2,6 +2,7 @@
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from unittest.mock import Mock
import pytest
@@ -33,14 +34,18 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
def test_start_recording_success(mediator, mock_worker_service):
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
mock_worker_service.start.return_value = worker_id
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED, worker_id=None
status=RecordingStatusChoices.INITIATED,
worker_id=None,
)
mediator.start(mock_recording)
@@ -55,12 +60,18 @@ def test_start_recording_success(mediator, mock_worker_service):
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_worker_errors(
mediator, mock_worker_service, error_class
mock_update_room_metadata, mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -78,6 +89,8 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
"status",
@@ -90,8 +103,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_from_forbidden_status(
mediator, mock_worker_service, status
mock_update_room_metadata, mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -105,6 +119,8 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
"""Test successful recording stop"""
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -347,7 +342,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
from core.utils import MetadataUpdateException, NotificationError
pytestmark = pytest.mark.django_db
@@ -70,7 +70,10 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
@@ -83,13 +86,98 @@ def test_handle_egress_ended_success(mock_notify, mode, notification_type, servi
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "stopped"
@pytest.mark.parametrize(
("egress_status", "status"),
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -108,9 +196,16 @@ def test_handle_egress_ended_notification_fails(mock_notify, service):
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -124,13 +219,17 @@ def test_handle_egress_ended_recording_not_found(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
@@ -141,13 +240,19 @@ def test_handle_egress_ended_recording_not_active(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
@@ -158,6 +263,9 @@ def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@@ -14,7 +14,7 @@ from core.factories import (
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope
from core.models import ApplicationScope, User
pytestmark = pytest.mark.django_db
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
"scope": "user.family@example.com",
},
format="json",
)
@@ -232,6 +232,7 @@ def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -273,3 +274,119 @@ def test_api_applications_token_payload_structure(settings):
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "unknown@world.com",
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
user = User.objects.get(email="unknown@world.com")
assert user.sub is None
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Assert no new user was created
assert len(User.objects.all()) == 1
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
+28
View File
@@ -6,6 +6,8 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -26,12 +28,24 @@ external_router.register(
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -39,12 +53,26 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
+52
View File
@@ -25,6 +25,7 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -244,6 +245,57 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
Binary file not shown.
+116 -63
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,6 +17,30 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
@@ -177,61 +201,61 @@ msgstr "Sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "Kurzname"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "Sprache"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "Gerät"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "aktiv"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +263,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "Benutzer"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "Benutzer"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Raum"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Räume"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,103 +331,108 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:584
#: core/models.py:591
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:692
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:693
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:699
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:705
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:711
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:728
#: core/models.py:735
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:729
#: core/models.py:736
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:730
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:731
#: core/models.py:738
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:732
#: core/models.py:739
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:745
#: core/models.py:752
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:746
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:756
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:767
#: core/models.py:774
msgid "Application"
msgstr "Anwendung"
#: core/models.py:768
#: core/models.py:775
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:791
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:794
#: core/models.py:801
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:807
#: core/models.py:814
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:808
#: core/models.py:815
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:94
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -412,6 +441,30 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -531,18 +584,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Englisch"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Französisch"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+112 -60
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,6 +17,30 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
@@ -175,61 +199,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "full name"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "short name"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "language"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "device"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "staff status"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "active"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +261,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "user"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "users"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Resource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Resources"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Room"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,107 +325,111 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
msgid "Recording"
msgstr "Recording"
#: core/models.py:584
#: core/models.py:591
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:692
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:693
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:699
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:705
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:711
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:728
#: core/models.py:735
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:729
#: core/models.py:736
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:730
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:731
#: core/models.py:738
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:732
#: core/models.py:739
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:745
#: core/models.py:752
msgid "Application name"
msgstr "Application name"
#: core/models.py:746
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:756
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:767
#: core/models.py:774
msgid "Application"
msgstr "Application"
#: core/models.py:768
#: core/models.py:775
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:794
#: core/models.py:801
msgid "Domain"
msgstr "Domain"
#: core/models.py:795
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:807
#: core/models.py:814
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:808
#: core/models.py:815
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:94
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -410,6 +438,30 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -529,18 +581,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "English"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "French"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "German"
Binary file not shown.
+117 -63
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,6 +17,30 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
@@ -179,61 +203,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "nom complet"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "nom court"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "langue"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "appareil"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "actif"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +265,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "utilisateur"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "utilisateurs"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Ressource"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Ressources"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Salle"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Salles"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,103 +331,109 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:584
#: core/models.py:591
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:692
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:693
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:699
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:705
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:711
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:728
#: core/models.py:735
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:729
#: core/models.py:736
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:730
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:731
#: core/models.py:738
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:732
#: core/models.py:739
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:745
#: core/models.py:752
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:746
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:756
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:767
#: core/models.py:774
msgid "Application"
msgstr "Application"
#: core/models.py:768
#: core/models.py:775
msgid "Applications"
msgstr "Applications"
#: core/models.py:791
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:794
#: core/models.py:801
msgid "Domain"
msgstr "Domaine"
#: core/models.py:795
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:807
#: core/models.py:814
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:808
#: core/models.py:815
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:94
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -412,6 +442,30 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -531,18 +585,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Anglais"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Français"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Allemand"
Binary file not shown.
+116 -62
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,6 +17,30 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
@@ -176,60 +200,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:157
#: core/models.py:158
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:162
#: core/models.py:163
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:164
#: core/models.py:165
msgid "full name"
msgstr "volledige naam"
#: core/models.py:166
#: core/models.py:167
msgid "short name"
msgstr "korte naam"
#: core/models.py:172
#: core/models.py:173
msgid "language"
msgstr "taal"
#: core/models.py:173
#: core/models.py:174
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:179
#: core/models.py:180
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:182
#: core/models.py:183
msgid "device"
msgstr "apparaat"
#: core/models.py:184
#: core/models.py:185
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:187
#: core/models.py:188
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:189
#: core/models.py:190
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:192
#: core/models.py:193
msgid "active"
msgstr "actief"
#: core/models.py:195
#: core/models.py:196
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,64 +262,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:208
#: core/models.py:209
msgid "user"
msgstr "gebruiker"
#: core/models.py:209
#: core/models.py:210
msgid "users"
msgstr "gebruikers"
#: core/models.py:268
#: core/models.py:269
msgid "Resource"
msgstr "Bron"
#: core/models.py:269
#: core/models.py:270
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:323
#: core/models.py:324
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:324
#: core/models.py:325
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:330
#: core/models.py:331
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:386
#: core/models.py:387
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:387
#: core/models.py:388
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:394
#: core/models.py:395
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:395
#: core/models.py:396
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:401 core/models.py:555
#: core/models.py:402 core/models.py:556
msgid "Room"
msgstr "Ruimte"
#: core/models.py:402
#: core/models.py:403
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:566
#: core/models.py:567
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:568
#: core/models.py:569
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -302,103 +327,108 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:576
#: core/models.py:577
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:577
#: core/models.py:578
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
msgid "Recording"
msgstr "Opname"
#: core/models.py:584
#: core/models.py:591
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:692
#: core/models.py:699
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:693
#: core/models.py:700
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:699
#: core/models.py:706
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:705
#: core/models.py:712
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:711
#: core/models.py:718
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:728
#: core/models.py:735
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:729
#: core/models.py:736
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:730
#: core/models.py:737
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:731
#: core/models.py:738
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:732
#: core/models.py:739
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:745
#: core/models.py:752
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:746
#: core/models.py:753
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:756
#: core/models.py:763
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:767
#: core/models.py:774
msgid "Application"
msgstr "Applicatie"
#: core/models.py:768
#: core/models.py:775
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:791
#: core/models.py:798
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:794
#: core/models.py:801
msgid "Domain"
msgstr "Domein"
#: core/models.py:795
#: core/models.py:802
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:807
#: core/models.py:814
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:808
#: core/models.py:815
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:94
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -407,6 +437,30 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
@@ -526,18 +580,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:167
#: meet/settings.py:169
msgid "English"
msgstr "Engels"
#: meet/settings.py:168
#: meet/settings.py:170
msgid "French"
msgstr "Frans"
#: meet/settings.py:169
#: meet/settings.py:171
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:170
#: meet/settings.py:172
msgid "German"
msgstr "Duits"
+83 -3
View File
@@ -290,6 +290,11 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"addons_auth_sessions": values.Value(
default="150/minute",
environ_name="ADDONS_AUTH_SESSION_THROTTLE_RATES",
environ_prefix=None,
),
},
}
@@ -342,12 +347,12 @@ class Base(Configuration):
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
}
# Mail
@@ -405,6 +410,10 @@ class Base(Configuration):
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
@@ -630,6 +639,9 @@ class Base(Configuration):
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
RECORDING_DOWNLOAD_BASE_URL = values.Value(
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -770,6 +782,74 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# Allows third-party platforms to create users with email-only identification.
# Required for external integrations, but fragile due to deferred user reconciliation
# on sub. Enable it with care /!\
APPLICATION_ALLOW_USER_CREATION = values.BooleanValue(
False,
environ_name="APPLICATION_ALLOW_USER_CREATION",
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Used in cache key generation
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.0.0"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.2",
"aiohttp==3.13.3",
]
[project.urls]
+16
View File
@@ -6,6 +6,22 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+28 -25
View File
@@ -1,13 +1,15 @@
{
"name": "meet",
"version": "1.0.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.0.0",
"version": "1.3.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
@@ -104,7 +106,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1019,6 +1020,24 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/material-symbols-outlined": {
"version": "5.2.30",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/material-icons-outlined": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource/material-icons-outlined/-/material-icons-outlined-5.2.6.tgz",
"integrity": "sha512-99XKAkwnCg0s0/ywax+o3m01HSNM5gGzSBw+WnlWG2+WY3wOjcN+wXMfm4zP37Yme7Yze2DvKxF78tHWOrlwFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
@@ -3870,7 +3889,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.81.5"
},
@@ -3962,7 +3980,8 @@
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
@@ -3978,7 +3997,8 @@
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -4005,7 +4025,6 @@
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4023,7 +4042,6 @@
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4090,7 +4108,6 @@
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
@@ -4382,7 +4399,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4797,7 +4813,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@@ -5607,7 +5622,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6575,7 +6589,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -7460,7 +7473,6 @@
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.39.3",
@@ -8120,7 +8132,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8234,7 +8245,6 @@
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8388,7 +8398,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8495,7 +8504,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8745,6 +8753,7 @@
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
@@ -9248,7 +9257,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9367,8 +9375,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -9480,7 +9487,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9641,7 +9647,6 @@
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"proxy-compare": "^3.0.1"
},
@@ -9742,7 +9747,6 @@
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -9877,7 +9881,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,6 +13,8 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
+1 -3
View File
@@ -17,9 +17,6 @@ export interface ApiConfig {
feedback: {
url: string
}
transcript: {
form_beta_users: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
@@ -47,6 +44,7 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
transcription_destination?: string
}
const fetchConfig = (): Promise<ApiConfig> => {
+1
View File
@@ -68,6 +68,7 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
@@ -1,10 +1,9 @@
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button, LinkButton } from '@/primitives'
import { Button } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useConfig } from '@/api/useConfig'
const Heading = styled('h2', {
base: {
@@ -165,14 +164,7 @@ export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const { data } = useConfig()
const filteredSlides = useMemo(
() => (data?.transcript?.form_beta_users ? SLIDES : SLIDES.slice(0, 2)),
[data]
)
const NUMBER_SLIDES = filteredSlides.length
const NUMBER_SLIDES = SLIDES.length
return (
<Container>
@@ -198,24 +190,12 @@ export const IntroSlider = () => {
</ButtonVerticalCenter>
</ButtonContainer>
<SlideContainer>
{filteredSlides.map((slide, index) => (
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<Image src={slide.src} alt="" role="presentation" />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
{slide.isAvailableInBeta && (
<LinkButton
href={data?.transcript.form_beta_users}
target="_blank"
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
{t('beta.text')}
</LinkButton>
)}
</TextAnimation>
</Slide>
))}
@@ -241,7 +221,7 @@ export const IntroSlider = () => {
display: { base: 'none', xsm: 'block' },
})}
>
{filteredSlides.map((_, index) => (
{SLIDES.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}
</div>
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
))}
{/* Article 7 */}
<H lvl={2} margin={false}>
{t('articles.article7.title')}
</H>
<H lvl={2}>{t('articles.article7.title')}</H>
<P>{t('articles.article7.content')}</P>
{/* Section 7.1 */}
<H lvl={3} bold>
{t('articles.article7.sections.section1.title')}
</H>
<P>{t('articles.article7.sections.section1.content')}</P>
{ensureArray(
t('articles.article7.sections.section1.paragraphs', {
returnObjects: true,
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
<H lvl={3} bold>
{t('articles.article7.sections.section2.title')}
</H>
{ensureArray(
t('articles.article7.sections.section2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.3 */}
<H lvl={3} bold>
{t('articles.article7.sections.section3.title')}
</H>
{ensureArray(
t('articles.article7.sections.section3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.4 */}
<H lvl={3} bold>
{t('articles.article7.sections.section4.title')}
</H>
{ensureArray(
t('articles.article7.sections.section4.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.5 */}
<H lvl={3} bold>
{t('articles.article7.sections.section5.title')}
</H>
<P>
{t('articles.article7.sections.section2.content')
{t('articles.article7.sections.section5.content')
.split('https://github.com/suitenumerique/meet')[0]
.replace('https://github.com/suitenumerique/meet', '')}{' '}
<A href="https://github.com/suitenumerique/meet" color="primary">
https://github.com/suitenumerique/meet
</A>
{'. '}
{
t('articles.article7.sections.section2.content').split(
t('articles.article7.sections.section5.content').split(
'https://github.com/suitenumerique/meet'
)[1]
}
@@ -104,6 +104,16 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.RECORDING_REQUESTED }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break
@@ -13,4 +13,5 @@ export const NotificationDuration = {
LOWER_HAND: ToastDuration.EXTRA_LONG,
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG,
} as const
@@ -9,8 +9,10 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
@@ -0,0 +1,89 @@
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export function ToastRecordingRequest({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const type = props.toast.content.type
const {
isTranscriptOpen,
openTranscript,
isScreenRecordingOpen,
openScreenRecording,
} = useSidePanel()
const options = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionRequested:
return {
key: 'transcript.requested',
isMenuOpen: isTranscriptOpen,
openMenu: openTranscript,
}
case NotificationType.ScreenRecordingRequested:
return {
key: 'screenRecording.requested',
isMenuOpen: isScreenRecordingOpen,
openMenu: openScreenRecording,
}
default:
return
}
}, [
type,
isTranscriptOpen,
isScreenRecordingOpen,
openTranscript,
openScreenRecording,
])
if (!options) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(options.key, {
name: participant?.name,
})}
{!options.isMenuOpen && (
<div
className={css({
marginLeft: '0.5rem',
})}
>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={options.openMenu}
>
{t('openMenu')}
</Button>
</div>
)}
</HStack>
</StyledToastContainer>
)
}
@@ -12,6 +12,7 @@ import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -52,6 +53,12 @@ const renderToast = (
case NotificationType.ScreenRecordingLimitReached:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
return (
<ToastRecordingRequest key={toast.key} toast={toast} state={state} />
)
case NotificationType.RecordingSaving:
return (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
@@ -7,16 +7,19 @@ import { RecordingMode } from '../types'
export interface StartRecordingParams {
id: string
mode?: RecordingMode
options?: Record<string, string | boolean>
}
const startRecording = ({
id,
mode = RecordingMode.Transcript,
options,
}: StartRecordingParams): Promise<ApiRoom> => {
return fetchApi(`rooms/${id}/start-recording/`, {
method: 'POST',
body: JSON.stringify({
mode: mode,
options: options,
}),
})
}
@@ -0,0 +1,175 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { Button as RACButton } from 'react-aria-components'
import { parseLineBreaks } from '@/utils/parseLineBreaks'
const Layout = ({ children }: { children: ReactNode }) => (
<div
className={css({
marginBottom: '80px',
width: '100%',
})}
>
{children}
</div>
)
interface ControlsButtonProps {
i18nKeyPrefix: string
statuses: RecordingStatuses
handle: () => void
isPendingToStart: boolean
isPendingToStop: boolean
openSidePanel: () => void
}
const MIN_SPINNER_DISPLAY_TIME = 2000
export const ControlsButton = ({
i18nKeyPrefix,
statuses,
handle,
isPendingToStart,
isPendingToStop,
openSidePanel,
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
const [showSaving, setShowSaving] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout>()
const isSaving = statuses.isSaving || isPendingToStop
const isDisabled = !isRoomConnected || statuses.isAnotherModeStarted
useEffect(() => {
if (isSaving) {
clearTimeout(timeoutRef.current)
setShowSaving(true)
} else if (showSaving) {
timeoutRef.current = setTimeout(() => {
setShowSaving(false)
}, MIN_SPINNER_DISPLAY_TIME)
}
return () => clearTimeout(timeoutRef.current)
}, [isSaving, showSaving])
// Saving state
if (showSaving) {
return (
<Layout>
<HStack width="100%" height="46px" justify="center">
<Spinner size={30} />
<Text variant="body">{t('button.saving')}</Text>
</HStack>
</Layout>
)
}
// Starting state
if (statuses.isStarting || isPendingToStart) {
return (
<Layout>
<HStack width="100%" height="46px" justify="center">
<Spinner size={30} />
{t('button.starting')}
</HStack>
</Layout>
)
}
// Active state (Stop button)
if (statuses.isStarted) {
return (
<Layout>
<Button
variant="tertiary"
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
</Layout>
)
}
// Inactive state (Start button)
return (
<Layout>
{statuses.isAnotherModeStarted && (
<RACButton
className={css({
backgroundColor: 'primary.50',
border: '1px solid',
borderColor: 'primary.200',
borderRadius: '6px',
padding: '0.75rem',
marginBottom: '0.75rem',
display: 'flex',
justifyContent: 'left',
textAlign: 'left',
alignItems: 'center',
width: '100%',
cursor: 'pointer',
_hover: {
backgroundColor: 'primary.100',
borderColor: 'primary.400',
},
})}
onPress={() => openSidePanel()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
</RACButton>
)}
<Button
variant={isDisabled ? 'primary' : 'tertiary'}
fullWidth
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
</Layout>
)
}
@@ -0,0 +1,29 @@
import { Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { recordingStore } from '@/stores/recording'
export const ErrorAlertDialog = () => {
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', {
keyPrefix: 'errorRecordingAlertDialog',
})
return (
<Dialog
isOpen={!!recordingSnap.isErrorDialogOpen}
role="alertdialog"
title={t('title')}
aria-label={t('title')}
>
<P>{t(`body.${recordingSnap.isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => (recordingStore.isErrorDialogOpen = '')}
>
{t('button')}
</Button>
</Dialog>
)
}
@@ -1,35 +1,61 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import { useHumanizeRecordingMaxDuration } from '@/features/recording'
import { useEffect, useState } from 'react'
import { NotificationType } from '@/features/notifications'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { useRoomContext } from '@livekit/components-react'
export const LimitReachedAlertDialog = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t, i18n } = useTranslation('rooms', {
export const LimitReachedAlertDialog = () => {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast.limitReachedAlert',
})
const { data } = useConfig()
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const maxDuration = useHumanizeRecordingMaxDuration()
useEffect(() => {
const handleDataReceived = (payload: Uint8Array) => {
if (!isAdminOrOwner) return
const notification = decodeNotificationDataReceived(payload)
if (
notification?.type === NotificationType.TranscriptionLimitReached ||
notification?.type === NotificationType.ScreenRecordingLimitReached
) {
setIsAlertOpen(true)
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room, isAdminOrOwner])
if (!isAdminOrOwner) return null
return (
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<Dialog isOpen={isAlertOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: data?.recording?.max_duration
duration_message: maxDuration
? t('durationMessage', {
duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
duration: maxDuration,
})
: '',
})}
</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
<Button variant="text" size="sm" onPress={() => setIsAlertOpen(false)}>
{t('button')}
</Button>
</HStack>
@@ -0,0 +1,44 @@
import { H, Text, Icon } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
interface LoginPromptProps {
heading: string
body: string
}
export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
return (
<div
className={css({
backgroundColor: 'primary.50',
borderRadius: '5px',
border: '1px solid',
borderColor: 'primary.200',
paddingY: '1rem',
paddingX: '1rem',
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="login" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
</HStack>
<Text variant="smNote" wrap="pretty">
{body}
</Text>
<div
className={css({
marginTop: '1rem',
})}
>
<LoginButton proConnectHint={false} />
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import { A, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { LoginPrompt } from './LoginPrompt'
import { RequestRecording } from './RequestRecording'
import { useUser } from '@/features/auth'
import { HStack, VStack } from '@/styled-system/jsx'
const Divider = ({ label }: { label: string }) => (
<HStack gap="1rem" alignItems="center" width="100%" marginY="1rem">
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
<Text variant="xsNote">{label}</Text>
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
</HStack>
)
interface NoAccessViewProps {
i18nKeyPrefix: string
i18nKey: string
helpArticle?: string
imagePath: string
handleRequest: () => Promise<void>
isActive: boolean
}
export const NoAccessView = ({
i18nKeyPrefix,
i18nKey,
helpArticle,
imagePath,
handleRequest,
isActive,
}: NoAccessViewProps) => {
const { isLoggedIn } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={imagePath}
alt=""
className={css({
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
<VStack gap={0} marginBottom={0}>
<H lvl={1} margin={'sm'} fullWidth centered>
{t(`${i18nKey}.heading`)}
</H>
<Text
variant="note"
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
'@media (max-height: 700px)': {
marginBottom: '1rem',
},
})}
>
{t(`${i18nKey}.body`)}
<br />
{helpArticle && (
<A href={helpArticle} target="_blank">
{t(`${i18nKey}.linkMore`)}
</A>
)}
</Text>
</VStack>
{!isLoggedIn && (
<LoginPrompt
heading={t(`${i18nKey}.login.heading`)}
body={t(`${i18nKey}.login.body`)}
/>
)}
{!isLoggedIn && !isActive && (
<Divider label={t(`${i18nKey}.dividerLabel`)} />
)}
{!isActive && (
<RequestRecording
heading={t(`${i18nKey}.request.heading`)}
body={t(`${i18nKey}.request.body`)}
buttonLabel={t(`${i18nKey}.request.buttonLabel`)}
handleRequest={handleRequest}
/>
)}
</Div>
)
}
@@ -0,0 +1,13 @@
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { RecordingStateToast } from './RecordingStateToast'
import { ErrorAlertDialog } from './ErrorAlertDialog'
export const RecordingProvider = () => {
return (
<>
<RecordingStateToast />
<LimitReachedAlertDialog />
<ErrorAlertDialog />
</>
)
}
@@ -1,210 +1,167 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useRef, useState, useEffect } from 'react'
import { Text } from '@/primitives'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RiRecordCircleLine } from '@remixicon/react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingActive,
useRecordingStatuses,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
})
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
const {
isStarted: isScreenRecordingStarted,
isStarting: isScreenRecordingStarting,
isActive: isScreenRecordingActive,
} = useRecordingStatuses(RecordingMode.ScreenRecording)
useEffect(() => {
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
recordingStore.status = RecordingStatus.ANY_STARTED
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.isRecording])
const {
isStarted: isTranscriptStarted,
isStarting: isTranscriptStarting,
isActive: isTranscriptActive,
} = useRecordingStatuses(RecordingMode.Transcript)
useEffect(() => {
const handleDataReceived = (payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
const isStarted = isScreenRecordingStarted || isTranscriptStarted
const isStarting = isTranscriptStarting || isScreenRecordingStarting
if (!notification) return
switch (notification.type) {
case NotificationType.TranscriptionStarted:
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
break
case NotificationType.TranscriptionStopped:
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.TranscriptionLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
case NotificationType.ScreenRecordingLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
}
}
const handleRecordingStatusChanged = (status: boolean) => {
if (!status) {
recordingStore.status = RecordingStatus.STOPPED
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
} else if (
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
) {
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
} else {
recordingStore.status = RecordingStatus.ANY_STARTED
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
const metadata = useRoomMetadata()
const isRecording = useIsRecording()
const key = useMemo(() => {
switch (recordingSnap.status) {
case RecordingStatus.TRANSCRIPT_STARTED:
return 'transcript.started'
case RecordingStatus.TRANSCRIPT_STARTING:
return 'transcript.starting'
case RecordingStatus.SCREEN_RECORDING_STARTED:
return 'screenRecording.started'
case RecordingStatus.SCREEN_RECORDING_STARTING:
return 'screenRecording.starting'
case RecordingStatus.ANY_STARTED:
return 'any.started'
default:
return
if (!metadata?.recording_status || !metadata?.recording_mode) {
return undefined
}
}, [recordingSnap])
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
if (!isStarting && !isStarted) {
return undefined
}
const isStarted = key?.includes('started')
let status = metadata.recording_status
if (isStarted && !isRecording) {
status = 'starting'
}
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const hasScreenRecordingAccessAndActive =
isScreenRecordingActive && hasScreenRecordingAccess
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
)
}
@@ -0,0 +1,22 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
isTranscriptActive: boolean
}
export const RecordingStatusIcon = ({
isStarted,
isTranscriptActive,
}: RecordingStatusIconProps) => {
if (!isStarted) {
return <Spinner size={20} variant="dark" />
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
}
return <Icon type="symbols" name="screen_record" />
}
@@ -0,0 +1,87 @@
import { Button, Icon, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { Spinner } from '@/primitives/Spinner.tsx'
import { NotificationDuration } from '@/features/notifications/NotificationDuration'
interface RequestRecordingProps {
heading: string
body: string
buttonLabel: string
handleRequest: () => Promise<void>
}
export const RequestRecording = ({
heading,
body,
buttonLabel,
handleRequest,
}: RequestRecordingProps) => {
const [isDisabled, setIsDisabled] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout>()
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const onPress = async () => {
setIsDisabled(true)
try {
await handleRequest()
} catch {
setIsDisabled(false)
return
}
timeoutRef.current = setTimeout(() => {
setIsDisabled(false)
}, NotificationDuration.RECORDING_REQUESTED)
}
return (
<div
className={css({
backgroundColor: 'neutral.50',
borderRadius: '5px',
border: '1px solid',
borderColor: 'neutral.200',
paddingY: '1rem',
paddingX: '1rem',
display: 'flex',
flexDirection: 'column',
marginBottom: '1.5rem',
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="person_raised_hand" />
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
</HStack>
<Text variant="smNote" wrap="pretty">
{body}
</Text>
<div
className={css({
marginTop: '1rem',
})}
>
<Button
variant="tertiary"
fullWidth
onPress={onPress}
isDisabled={isDisabled}
>
{isDisabled && <Spinner size={24} />}
{buttonLabel}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,64 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
const BORDER_RADIUS_MAP: Record<RowPosition, string> = {
first: '4px 4px 0 0',
middle: '0',
last: '0 0 4px 4px',
single: '4px',
} as const
interface RowWrapperProps {
iconName: string
children: ReactNode
position?: RowPosition
}
export const RowWrapper = ({
iconName,
children,
position = 'middle',
}: RowWrapperProps) => {
return (
<div
style={{
borderRadius: BORDER_RADIUS_MAP[position],
}}
className={css({
width: '100%',
background: 'gray.100',
paddingBlock: '0.5rem',
paddingInline: '0',
display: 'flex',
marginTop: '0.25rem',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingInline: '0.25rem',
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} />
</div>
<div
className={css({
flex: 6,
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
paddingInlineEnd: '8px',
})}
>
{children}
</div>
</div>
)
}
@@ -1,18 +1,15 @@
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
import { A, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '@/features/recording'
import { useEffect, useMemo, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
NotificationType,
@@ -20,59 +17,48 @@ import {
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
const [isLoading, setIsLoading] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const keyPrefix = 'screenRecording'
const { t } = useTranslation('rooms', { keyPrefix })
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
useTranscriptionLanguage()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
useMutateRecording()
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStarting:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING,
isStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStopping:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
}
}, [recordingSnap])
const statuses = useRecordingStatuses(RecordingMode.ScreenRecording)
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
const { openTranscript } = useSidePanel()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleRequestScreenRecording = async () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
posthog.capture('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
if (!roomId) {
@@ -80,10 +66,10 @@ export const ScreenRecordingSidePanel = () => {
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
if (statuses.isStarted || statuses.isStarting) {
setIncludeTranscript(false)
await stopRecording({ id: roomId })
await notifyParticipants({
type: NotificationType.ScreenRecordingStopped,
})
@@ -92,30 +78,44 @@ export const ScreenRecordingSidePanel = () => {
room.localParticipant
)
} else {
await startRecordingRoom({
const recordingOptions = {
...(!isLanguageSetToAuto && {
language: selectedLanguageKey,
}),
...(includeTranscript && { transcribe: true }),
}
await startRecording({
id: roomId,
mode: RecordingMode.ScreenRecording,
options: recordingOptions,
})
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {})
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
console.error('Failed to handle recording:', error)
}
}
const isDisabled = useMemo(
() =>
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
if (!isAdminOrOwner) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_recording}
imagePath="/assets/intro-slider/4.png"
handleRequest={handleRequestScreenRecording}
isActive={statuses.isActive}
/>
)
}
return (
<Div
@@ -128,130 +128,74 @@ export const ScreenRecordingSidePanel = () => {
>
<img
src="/assets/intro-slider/4.png"
alt={''}
alt=""
className={css({
minHeight: '309px',
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('start.linkMore')}
</A>
)}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
size="sm"
variant="tertiary"
>
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('linkMore')}
</A>
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
<RowWrapper iconName="mail" position="last">
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
<div className={css({ height: '15px' })} />
<div
className={css({
width: '100%',
marginLeft: '20px',
})}
>
{t('alert.button')}
</Button>
</Dialog>
<Checkbox
size="sm"
isSelected={includeTranscript}
onChange={setIncludeTranscript}
isDisabled={statuses.isActive || isPendingToStart}
>
<Text variant="sm">{t('details.transcription')}</Text>
</Checkbox>
</div>
</VStack>
<ControlsButton
i18nKeyPrefix={keyPrefix}
handle={handleScreenRecording}
statuses={statuses}
isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop}
openSidePanel={openTranscript}
/>
</Div>
)
}
@@ -1,4 +1,4 @@
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
import { A, Button, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -6,15 +6,12 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import {
NotificationType,
@@ -22,23 +19,35 @@ import {
notifyRecordingSaveInProgress,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
import {
useSettingsDialog,
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const keyPrefix = 'transcript'
const { t } = useTranslation('rooms', { keyPrefix })
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const recordingSnap = useSnapshot(recordingStore)
const [includeScreenRecording, setIncludeScreenRecording] = useState(false)
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { openSettingsDialog } = useSettingsDialog()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -52,40 +61,20 @@ export const TranscriptSidePanel = () => {
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
useMutateRecording()
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStarting: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING,
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
}
}, [recordingSnap])
const isRecordingTransitioning = useIsRecordingTransitioning()
const statuses = useRecordingStatuses(RecordingMode.Transcript)
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
const { openScreenRecording } = useSidePanel()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleRequestTranscription = async () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
posthog.capture('transcript-requested', {})
}
const handleTranscript = async () => {
if (!roomId) {
@@ -93,10 +82,10 @@ export const TranscriptSidePanel = () => {
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
if (statuses.isStarted || statuses.isStarting) {
await stopRecording({ id: roomId })
setIncludeScreenRecording(false)
await notifyParticipants({
type: NotificationType.TranscriptionStopped,
})
@@ -105,27 +94,64 @@ export const TranscriptSidePanel = () => {
room.localParticipant
)
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
const recordingMode = includeScreenRecording
? RecordingMode.ScreenRecording
: RecordingMode.Transcript
const recordingOptions = {
...(!isLanguageSetToAuto && {
language: selectedLanguageKey,
}),
...(includeScreenRecording && {
transcribe: true,
original_mode: RecordingMode.Transcript,
}),
}
await startRecording({
id: roomId,
mode: recordingMode,
options: recordingOptions,
})
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {})
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() =>
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}
if (!hasTranscriptAccess) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="premium"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}
return (
<Div
@@ -138,199 +164,101 @@ export const TranscriptSidePanel = () => {
>
<img
src="/assets/intro-slider/3.png"
alt={''}
alt=""
className={css({
minHeight: '309px',
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
{!hasTranscriptAccess ? (
<>
{hasFeatureWithoutAdminRights ? (
<>
<Text>{t('notAdminOrOwner.heading')}</Text>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('notAdminOrOwner.body')}
<br />
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('notAdminOrOwner.linkMore')}
</A>
)}
</Text>
</>
) : (
<>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('start.linkMore')}
</A>
)}
</Text>
{data?.transcript.form_beta_users && (
<LinkButton
size="sm"
variant="tertiary"
href={data?.transcript.form_beta_users}
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_transcript && (
<A href={data.support.help_article_transcript} target="_blank">
{t('linkMore')}
</A>
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="article" position="first">
<Text variant="sm">
{data?.transcription_destination ? (
<>
{t('details.destination')}{' '}
<A
href={data.transcription_destination}
target="_blank"
rel="noopener noreferrer"
>
{t('beta.button')}
</LinkButton>
)}
</>
)}
</>
) : (
<>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('start.linkMore')}
</A>
)}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
{data.transcription_destination.replace('https://', '')}
</A>
</>
) : (
t('details.destinationUnknown')
)}
</Text>
</RowWrapper>
<RowWrapper iconName="mail">
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
<RowWrapper iconName="language" position="last">
<Text variant="sm">{t('details.language')}</Text>
<Text variant="sm">
<Button
variant="text"
size="xs"
onPress={() =>
openSettingsDialog(SettingsDialogExtendedKey.TRANSCRIPTION)
}
>
{selectedLanguageLabel}
</Button>
</Text>
</RowWrapper>
<div className={css({ height: '15px' })} />
<div
className={css({
width: '100%',
marginLeft: '20px',
})}
>
{t('alert.button')}
</Button>
</Dialog>
<Checkbox
size="sm"
isSelected={includeScreenRecording}
onChange={setIncludeScreenRecording}
isDisabled={statuses.isActive || isPendingToStart}
>
<Text variant="sm">{t('details.recording')}</Text>
</Checkbox>
</div>
</VStack>
<ControlsButton
i18nKeyPrefix={keyPrefix}
handle={handleTranscript}
statuses={statuses}
isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop}
openSidePanel={openScreenRecording}
/>
</Div>
)
}
@@ -0,0 +1,17 @@
import { useMemo } from 'react'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useConfig } from '@/api/useConfig'
export const useHumanizeRecordingMaxDuration = () => {
const { data } = useConfig()
return useMemo(() => {
if (!data?.recording?.max_duration) return
return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
delimiter: ' ',
})
}, [data])
}
@@ -1,22 +0,0 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RecordingMode } from '@/features/recording'
export const useIsRecordingActive = (mode: RecordingMode) => {
const recordingSnap = useSnapshot(recordingStore)
switch (mode) {
case RecordingMode.Transcript:
return [
RecordingStatus.TRANSCRIPT_STARTED,
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
].includes(recordingSnap.status)
case RecordingMode.ScreenRecording:
return [
RecordingStatus.SCREEN_RECORDING_STARTED,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
].includes(recordingSnap.status)
}
}
@@ -1,15 +0,0 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsRecordingTransitioning = () => {
const recordingSnap = useSnapshot(recordingStore)
const transitionalStates = [
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
]
return transitionalStates.includes(recordingSnap.status)
}
@@ -0,0 +1,24 @@
import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording'
export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'start'
},
})
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'stop'
},
})
return {
startRecording,
isPendingToStart,
stopRecording,
isPendingToStop,
}
}
@@ -0,0 +1,62 @@
import { RecordingMode } from '@/features/recording'
import { useRoomMetadata } from './useRoomMetadata'
import { useMemo } from 'react'
import { useIsRecording } from '@livekit/components-react'
export enum RecordingStatus {
Starting = 'starting',
Started = 'started',
Saving = 'saving',
}
const ACTIVE_STATUSES = [
RecordingStatus.Starting,
RecordingStatus.Started,
RecordingStatus.Saving,
] as const
export interface RecordingStatuses {
isAnotherModeStarted: boolean
isStarting: boolean
isStarted: boolean
isSaving: boolean
isActive: boolean
}
export const useRecordingStatuses = (
mode: RecordingMode
): RecordingStatuses => {
const metadata = useRoomMetadata()
const isRecording = useIsRecording()
return useMemo(() => {
if (metadata && metadata?.recording_mode === mode) {
return {
isAnotherModeStarted: false,
isStarting:
metadata.recording_status === RecordingStatus.Starting ||
(metadata.recording_status === RecordingStatus.Started &&
!isRecording),
isStarted:
metadata.recording_status === RecordingStatus.Started && isRecording,
isSaving: metadata.recording_status === RecordingStatus.Saving,
isActive: ACTIVE_STATUSES.includes(
metadata.recording_status as RecordingStatus
),
}
}
const isAnotherModeStarted =
!!metadata?.recording_mode &&
metadata?.recording_mode !== mode &&
ACTIVE_STATUSES.includes(metadata.recording_status as RecordingStatus)
return {
isAnotherModeStarted,
isStarting: false,
isStarted: false,
isSaving: false,
isActive: false,
}
}, [mode, metadata, isRecording])
}
@@ -0,0 +1,18 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
return useMemo(() => {
if (metadata) {
try {
return JSON.parse(metadata)
} catch (error) {
console.error('Failed to parse room metadata:', error)
return undefined
}
} else {
return undefined
}
}, [metadata])
}
+3 -3
View File
@@ -1,9 +1,9 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useHumanizeRecordingMaxDuration } from './hooks/useHumanizeRecordingMaxDuration'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
// api
export { useStartRecording } from './api/startRecording'
@@ -11,7 +11,7 @@ export { useStopRecording } from './api/stopRecording'
export { RecordingMode, RecordingStatus } from './types'
// components
export { RecordingStateToast } from './components/RecordingStateToast'
export { RecordingProvider } from './components/RecordingProvider'
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({
roomId,
@@ -228,10 +228,20 @@ export const Conference = ({
posthog.captureException(e)
}}
onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo('feedback')
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
}
}}
onMediaDeviceFailure={(e, kind) => {
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
aria-label={isCopied ? t('copied') : t('copy')}
style={{
justifyContent: 'start',
}}
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('copy')}
aria-label={isCopied ? t('copied') : t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -35,6 +35,7 @@ export const ParticipantName = ({
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
@@ -29,6 +29,9 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -102,9 +105,33 @@ export const ParticipantTile: (
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
const target = event.target as HTMLElement | null
const isFocusVisible = !!target?.matches?.(':focus-visible')
setHasKeyboardFocus(isFocusVisible)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
@@ -195,10 +222,16 @@ export const ParticipantTile: (
</>
)}
{!disableMetadata && (
<ParticipantTileFocus trackRef={trackReference} />
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (hovered && !isIdleRef) {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
} else {
setOpacity(0)
}
}, [hovered, isIdleRef])
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{hovered && (
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -140,11 +143,53 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -13,27 +13,32 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
}
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<div
<aside
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -58,32 +63,37 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size={'sm'}
size="sm"
square
className={css({ marginRight: '0.5rem' })}
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} />
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
{title}
</Heading>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
@@ -104,7 +114,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</div>
</aside>
)
type PanelProps = {
@@ -125,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -144,6 +153,7 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -153,6 +163,7 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
@@ -164,7 +175,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen}>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -1,19 +1,16 @@
import { A, Div, Text } from '@/primitives'
import { A, Div, Icon, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
useHasRecordingAccess,
TranscriptSidePanel,
ScreenRecordingSidePanel,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useConfig } from '@/api/useConfig'
export interface ToolsButtonProps {
@@ -21,8 +18,6 @@ export interface ToolsButtonProps {
title: string
description: string
onPress: () => void
isBetaFeature?: boolean
isActive?: boolean
}
const ToolButton = ({
@@ -30,8 +25,6 @@ const ToolButton = ({
title,
description,
onPress,
isBetaFeature = false,
isActive = false,
}: ToolsButtonProps) => {
return (
<RACButton
@@ -42,9 +35,9 @@ const ToolButton = ({
justifyContent: 'start',
paddingY: '0.5rem',
paddingX: '0.75rem 1.5rem',
borderRadius: '5px',
gap: '1.25rem',
borderRadius: '30px',
width: 'full',
backgroundColor: 'gray.50',
textAlign: 'start',
'&[data-hovered]': {
backgroundColor: 'primary.50',
@@ -55,81 +48,78 @@ const ToolButton = ({
>
<div
className={css({
height: '50px',
minWidth: '50px',
height: '40px',
minWidth: '40px',
borderRadius: '25px',
backgroundColor: 'primary.800',
marginRight: '0.75rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
background: 'primary.800',
color: 'white',
})}
>
{icon}
{isBetaFeature && (
<div
className={css({
position: 'absolute',
backgroundColor: 'primary.50',
color: 'primary.800',
fontSize: '12px',
fontWeight: 500,
borderRadius: '4px',
paddingX: '4px',
paddingBottom: '1px',
bottom: -8,
right: -8,
})}
>
BETA
</div>
)}
</div>
<div>
<Text
margin={false}
as="h3"
className={css({ display: 'flex', gap: 0.25 })}
className={css({
display: 'flex',
gap: 0.25,
fontWeight: 'semibold',
})}
>
{title}
{isActive && (
<div
className={css({
backgroundColor: 'primary.500',
height: '10px',
width: '10px',
marginTop: '5px',
borderRadius: '100%',
})}
/>
)}
</Text>
<Text as="p" variant="smNote" wrap="pretty">
{description}
</Text>
</div>
<div
className={css({
marginLeft: 'auto',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<Icon type="symbols" name="chevron_forward" />
</div>
</RACButton>
)
}
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId } =
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
RecordingMode.ScreenRecording
)
@@ -150,6 +140,7 @@ export const Tools = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
gap={0.5}
>
<Text
variant="note"
@@ -157,8 +148,8 @@ export const Tools = () => {
className={css({
textStyle: 'sm',
paddingX: '0.75rem',
marginBottom: '1rem',
})}
margin="md"
>
{t('body')}{' '}
{data?.support?.help_article_more_tools && (
@@ -172,22 +163,18 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<RiFileTextFill size={24} color="white" />}
icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isBetaFeature
isActive={isTranscriptActive}
/>
)}
{hasScreenRecordingAccess && (
{isScreenRecordingEnabled && (
<ToolButton
icon={<RiLiveFill size={24} color="white" />}
icon={<Icon type="symbols" name="mode_standby" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isBetaFeature
isActive={isScreenRecordingActive}
/>
)}
</Div>
@@ -93,7 +93,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
@@ -19,6 +20,8 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -31,21 +34,24 @@ export const ParticipantsToggle = ({
display: 'inline-block',
})}
>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<div
className={css({
position: 'absolute',
@@ -10,6 +10,7 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -145,7 +146,7 @@ export const ReactionsToggle = () => {
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji })}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark"
size="sm"
square
@@ -9,13 +9,13 @@ import {
} from '../blur'
import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,11 +50,17 @@ export const EffectsConfiguration = ({
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -69,16 +75,95 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
@@ -104,7 +189,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (isSelected(type, options)) {
if (wasSelectedBeforeToggle) {
// Stop processor.
await clearEffect()
} else if (
@@ -131,6 +216,8 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -153,8 +240,28 @@ export const EffectsConfiguration = ({
)
}
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
}
return (
@@ -265,65 +372,60 @@ export const EffectsConfiguration = ({
>
{t('blur.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
<div>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
<ToggleButton
ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div>
<div
className={css({
@@ -343,39 +445,37 @@ export const EffectsConfiguration = ({
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
<ToggleButton
variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
)
})}
</div>
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import * as React from 'react'
import React from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -36,6 +37,19 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -45,7 +59,7 @@ export function Chat({ ...props }: ChatProps) {
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus()
inputRef?.current?.focus({ preventScroll: true })
}
// TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -11,6 +11,7 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -19,6 +20,18 @@ export function DesktopControlBar({
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
const firstButton = root.querySelector<HTMLButtonElement>(
'button, [role="button"], [tabindex="0"]'
)
firstButton?.focus()
},
})
return (
<div
ref={desktopControlBarEl}

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