Compare commits

...

96 Commits

Author SHA1 Message Date
lebaudantoine 9366c8c4dd 🔖(minor) bump release to 0.1.19
Introduce screen recording.
2025-04-23 23:45:20 +02:00
lebaudantoine 3636bfa703 (frontend) display recording expiration status in frontend
Add recording expiration information to frontend interface, showing number
of days until expiration or indicating permanent status. For expired
recordings, display the date since which content has been unavailable.
Improves transparency about content lifecycle.
2025-04-23 19:52:29 +02:00
lebaudantoine 34c14cc516 (mail) include expiration information in recording notification emails
Add validity duration (number of days valid) to email
notifications for recordings. Informs users about their recording's lifespan,
providing important context about content availability.
2025-04-23 19:52:29 +02:00
lebaudantoine 1a0051a90b (backend) implement recording expiration mechanism
Add expiration system for recordings.

Include option for users to set recordings as permanent (no expiration)
which is the default behavior.

System only calculates expiration dates and tracks status - actual deletion
is handled by Minio bucket lifecycle policies, not by application code.
2025-04-23 18:53:42 +02:00
renovate[bot] af21478143 ⬆️(dependencies) update vite [SECURITY] 2025-04-23 15:36:44 +02:00
lebaudantoine 986b75ba39 (backend) personalize recording notification emails by user preferences
Customize email notifications for recording availability based on each user's
language and timezone settings. Improves user experience through localized
communications.

Prioritize simple, maintainable implementation over complex code that would
form subgroups based on user preferences. Note: Changes individual email
sending instead of batch processing, which may impact performance for large
groups but is acceptable for typical recording access patterns.
2025-04-23 15:36:44 +02:00
lebaudantoine df55fb2424 🐛(backend) rename copy-pasted unit tests
Fix inconsistent test naming resulting from copy-pasted examples. Rename
tests to properly reflect their actual testing purpose and improve code
maintainability.
2025-04-23 14:17:09 +02:00
lebaudantoine ec114808b2 (frontend) add hook to sync user preferences
Implement new hook that synchronizes user language and timezone preferences
from frontend to backend. Ensures consistent localization across and
notifications. Note: Current implementation works but auth code requires
future refactoring.
2025-04-23 14:17:09 +02:00
lebaudantoine 7f0e866eac ♻️(frontend) rename isLoading to isConfigLoading for clarity
Rename generic "isLoading" variable to more specific "isConfigLoading" to
accurately reflect its purpose. Improves code readability by making state
variable names more explicit.
2025-04-23 14:17:09 +02:00
lebaudantoine 8e0e286bc4 (backend) serialize user language and timezone for frontend use
Add user language and timezone to serialized user data to enable frontend
customization. Allows backend email notifications to respect user's
localization preferences for improved communication relevance.
2025-04-23 14:17:09 +02:00
lebaudantoine ee9148fe9f 🌐(backend) add and compile Dutch translations for backend
Add Dutch language translations for backend text strings and compile
translation files for production use. Improves localization support for
Dutch-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine c863bc7456 🌐(backend) add and compile English translations for backend
Add English language translations for backend text strings and compile
translation files for production use. Improves localization support for
English-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine eab361326f 🌐(backend) add and compile French translations for backend
Add French language translations for backend text strings and compile
translation files for production use. Improves localization support for
French-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine 86c82f584c 🌐(backend) convert translation tags to blocktrans for quoted strings
Replace simple trans tags with blocktrans tags in download instructions
to properly handle quoted text in translations. Ensures quotes within
translated strings are correctly preserved during localization.
2025-04-23 14:17:09 +02:00
lebaudantoine af6ac954e9 🌐(backend) add Dutch language support to backend
Add Dutch (nl) language configuration to backend to match available frontend
languages. Ensures consistent language options across the entire application.
2025-04-23 14:17:09 +02:00
lebaudantoine 35cc6cd902 🌐(backend) regenerate translation files including email templates
Update translation files to include previously missed strings from email
templates. Ensures complete localization coverage across all backend
components including notification emails.
2025-04-23 14:17:09 +02:00
lebaudantoine 9cb7b0f154 ️(frontend) improve mic button accessibility in participant list
Add aria-label to microphone buttons in participant list to clearly indicate
mute functionality for current user and other participants. Set
aria-hidden on SVG icons to prevent redundant screen reader announcements.
2025-04-23 10:02:41 +02:00
lebaudantoine d105603a9b 🔖(helm) bump Helm chart version after job name modifications
Increment Helm chart version to reflect changes to backend job component
naming. Ensures proper versioning of configuration changes in deployment
pipeline.
2025-04-22 18:26:40 +02:00
lebaudantoine d2da1e37b9 🚚(helm) specify unique component names for all backend jobs
Update backend job configurations to use distinct component names instead of
sharing names with deployments. Prevents conflicts during cluster updates
and migrations that were causing unexpected behavior. Improves deployment
reliability and resource identification.
2025-04-22 18:26:40 +02:00
lebaudantoine 0206762e6d 🚚(helm) rename migration job to more explicit 'backend_job_migrate'
Rename backend migration job to a more descriptive name that clearly
indicates its purpose. Improves code clarity and makes deployment
configuration more self-documenting.
2025-04-22 18:26:40 +02:00
lebaudantoine c4fe341a74 🔧(backend) make data directory location configurable via env var
Add environment variable to control data directory location when building
outside of Docker. Improves flexibility for non-containerized deployments
where storing data at filesystem root is inappropriate or undesirable.
2025-04-22 16:25:59 +02:00
lebaudantoine bc53916c68 🚸(frontend) prevent auth screen flashing during user data fetch
Fix UI flickering where authentication screen briefly appeared for logged-in
users during initial data loading. Address issue caused by increased request
delay from waterfall cascade introduced by configurable silent auth setting.
2025-04-18 11:05:47 +02:00
lebaudantoine 886919c23d 🐛(backend) update media auth endpoint to check correct recording status
Modify media auth endpoint to properly handle recordings with "Notification
succeeded" status alongside "Saved" status. Previous code incorrectly
expected only "Saved" status, causing access issues after email notifications
were sent and status was updated.
2025-04-18 10:48:44 +02:00
lebaudantoine 4afbd9ba7f 🔐(helm) bump chart version
Bump chart version to publish a new one with media related
logic (ingress, service, etc…)
2025-04-18 10:01:05 +02:00
lebaudantoine a5fb3b910f (frontend) add meeting info side panel with copy functionality
Implement new side panel that provides easy access to meeting information
with copy/paste capabilities. Introduces xs text size to accommodate longer
URLs. Panel includes space for future documentation links about meeting
functionality. Addresses direct user requests for simpler sharing of meeting
details.
2025-04-18 09:42:09 +02:00
lebaudantoine d1a17d2aa9 🚸(frontend) add error handling for unsaved recording states
Implement proper error message display when recordings are unavailable due
to being active, pending webhook notification, or other transitional states.
Improves user experience by clearly communicating why content cannot be
accessed and setting appropriate expectations.
2025-04-17 16:58:33 +02:00
lebaudantoine e5af74685e (frontend) introduce dedicated recording download page
Create initial version of dedicated page for recording downloads, linked
directly from email notifications sent to users. Implementation is basic
but functional, serving as temporary solution until files can be stored in
drive storage. Enables recipients to access recordings through direct links.
2025-04-17 16:58:33 +02:00
lebaudantoine 06462a55b0 (frontend) create utility for media download URL generation
Encapsulate URL base generation logic for media downloads into a reusable
utility function, mirroring the approach used in the API. Improves code
consistency and reduces duplication across frontend components.
2025-04-17 16:58:33 +02:00
lebaudantoine 67967f00b5 (frontend) add date formatting utility function
Create reusable utility function to format dates into specified patterns.
Provides consistent date formatting across the application.
2025-04-17 16:58:33 +02:00
lebaudantoine a8053b46cd (frontend) implement API endpoint for recording details
Add new API call to retrieve detailed information about recordings.
Enables frontend to access metadata and status information needed for
download interfaces.
2025-04-17 16:58:33 +02:00
lebaudantoine b927be9f16 (frontend) serialize recording key for frontend download links
Add recording key to serialized API response to enable frontend to generate
proper download links without additional backend calls. Simplifies media
access workflow across the application.
2025-04-17 16:58:33 +02:00
lebaudantoine 2092d586ab 🎨(backend) harmonize decorator actions to lowercase format
Standardize all method decorator actions to lowercase format following DRF
documentation conventions. Creates consistent style across codebase.
2025-04-17 11:22:34 +02:00
lebaudantoine 132a1fbac7 🩹(backend) update HasPrivilegesOnRoom permission error message
Generalize error message in HasPrivilegesOnRoom permission class to reflect
its broader usage beyond just recording contexts. Improves clarity when
this permission check fails in various application scenarios.
2025-04-17 11:22:34 +02:00
lebaudantoine 90b4449040 (backend) add email invitation endpoint for meeting participants
Implement new endpoint allowing admin/owner to invite participants via email.
Provides explicit way to search users and send meeting invitations with
direct links.

In upcoming commits, frontend will call ResourceAccess endpoint to add
invited people as members if they exist in visio, bypassing waiting room
for a smoother experience.
2025-04-17 11:22:34 +02:00
lebaudantoine 205bb3aac1 (backend) introduce configuration for app base URL
Add new application base URL configuration setting. While somewhat redundant
with existing domain setting, these serve different purposes in the
application. Base URL will be used for constructing complete URLs in
notifications and external references.
2025-04-17 11:22:34 +02:00
lebaudantoine d5c9ee79f4 🩹(backend) fix email domain environment variable
Value wasn't properly set to a domain, but to an URL. Fix it.
2025-04-17 11:22:34 +02:00
lebaudantoine 1ef2bd99d8 🩹(backend) fix wrong environment variable
Oopsie, wrongly copy-pasted a Django settings leading in
using the wrong environment variable for a new setting.
I should have read my code;
2025-04-17 11:22:34 +02:00
lebaudantoine acbc5dba73 🩹(backend) fix exception messages
Oopsie, badly copy-pasted code when creating new serializer.
Not critical.
2025-04-17 11:22:34 +02:00
lebaudantoine 3438b4608c 🚸(frontend) make recording state toast clickable to reopen side panel
Enable recording status toast to function as clickable element that reopens
the side panel for admins and owners. Provides convenient way to access
recording controls when side panel has been previously closed.
2025-04-16 23:41:34 +02:00
lebaudantoine b4484540f7 🚸(frontend) add subtle indicator for active tools
Implement discreet visual notification that appears when tools are active.
Helps users locate and return to active tools they may have closed
during their session.
2025-04-16 23:41:34 +02:00
lebaudantoine 7021272075 🔒️(backend) remove personal data from email failure logs
Fix code that accidentally exposed personal email addresses in logs during
email sending failures. Modify logging to remove identifying information
to protect user privacy while still providing useful debugging context.

Original code was inspired by Docs.
2025-04-16 23:41:34 +02:00
lebaudantoine d92adb7435 🚸(frontend) differentiate loading and active recording states in toast
Add visual distinction between recording initialization and active recording
phases in status toast. Clearly communicates to users when recording becomes
active versus when it's still in the loading/preparation phase.
2025-04-16 23:41:34 +02:00
lebaudantoine d92c13d85a 🚸(frontend) increase toast visibility for recording status
Change recording toast color to red following standard recording conventions.
Improves visibility based on user feedback to make recording status more
apparent during active sessions.
2025-04-16 23:41:34 +02:00
lebaudantoine 46d60661f1 ♻️(frontend) align RecordingStateToast i18n keys with component name
Update translation keys to match component naming convention for improved
code maintainability and clarity.
2025-04-16 23:41:34 +02:00
lebaudantoine ba20fbe3a5 🚸(frontend) add beta tags to transcript and screen recording features
Add explicit beta tags to transcript and screen recording functionality to
clearly indicate these features are still in development. Helps set proper
user expectations by communicating that these capabilities may be unstable.
2025-04-16 23:41:34 +02:00
lebaudantoine f1b45af7d7 🚸(frontend) add loading spinner to recording start button
Add visual spinner indication to start button when initializing transcript
or screen recording. Provides clear feedback that recording process is
starting rather than leaving users uncertain about system status.
2025-04-16 23:41:34 +02:00
lebaudantoine 0b6869a4dc 🚸(frontend) add loading spinner for recording save process
Display loading spinner in side panel during transcript and screen recording
save operations. Provides visual feedback about ongoing processing that was
previously only indicated by title text, making the save status more explicit
to users.
2025-04-16 23:41:34 +02:00
lebaudantoine 7278061a80 🔧(backend) update egress worker layout for screen recording
Modify screen recording layout to focus on active speaker or shared screen
content. Provides better recording quality by prioritizing relevant visual
elements. Temporary solution until custom visio template is implemented.
2025-04-16 23:41:34 +02:00
lebaudantoine e5eb1e9916 🔧(backend) add backend toggle for silent login feature
Implement configuration option in backend to enable or disable silent login
functionality. Provides flexibility to control this authentication behavior
through server settings.

Requested by user self-hosting the project. Not all OIDC provider support
prompt=none param.
2025-04-16 23:37:04 +02:00
lebaudantoine 4060e891f2 🚸(frontend) add reset button during meeting creation process
Implement cancel/reset functionality that appears while meeting creation is
processing. Allows users to abort the operation if it stalls or encounters
issues, improving recovery from error states.
2025-04-16 23:35:47 +02:00
lebaudantoine 314468c68d 🚸(frontend) clarify "create a link" button text
Update button text based on user feedback to more clearly communicate that
it creates a link with Visio tool.
Improves user understanding of the feature's purpose.
2025-04-16 23:35:47 +02:00
lebaudantoine 52a7a6efab 🚸(frontend) add sound notification for lobby
Add a sound notification while a participant is waiting in the lobby.
KISS, use the same notification as the one when participant join
the room, thus, without any extra works, user can already toggle the
notification in settings.

In a v2, a dedicated notification could be added.
Requested by a user.
2025-04-16 23:34:55 +02:00
lebaudantoine 83d04c499b ⬆️ (dependencies) upgrade boto3 to more recent version
Update boto3 dependency to latest stable release to benefit from bug fixes,
performance improvements, and expanded AWS service support.
2025-04-16 12:13:42 +02:00
lebaudantoine 41c1f41ed2 (backend) add authenticated recording file access method
Implement secure recording file access through authentication instead of
exposing S3 bucket or using temporary signed links with loose permissions.
Inspired by docs and @spaccoud's implementation, with comprehensive
viewset checks to prevent unauthorized recording downloads.

The ingress reserved to media intercept the original request, and thanks to
Nginx annotations, check with the backend if the user is allowed to donwload
this recording file. This might introduce a dependency to Nginx in the project
by the way.

Note: Tests are integration-based rather than unit tests, requiring minio in
the compose stack and CI environment. Implementation includes known botocore
deprecation warnings that per GitHub issues won't be resolved for months.
2025-04-16 12:13:42 +02:00
lebaudantoine dc06b55693 (backend) enable retrieve viewset on recording model
Add Django built-in mixins to recording viewset to support individual record
retrieval. Enables frontend to access single recording details needed for
the upcoming download page implementation.
2025-04-16 12:13:42 +02:00
lebaudantoine 20aceb1932 (backend) add property to check if recording file is saved
Introduce new property that verifies if a recording file has a saved
status. While the implementation is straightforward, it improves code
readability and provides a clear, semantic way to check file status.
2025-04-16 12:13:42 +02:00
lebaudantoine 3671f2a0dd ♻️(backend) encapsulate recording key and extension logic as properties
Move logic for calculating recording keys and file extensions into proper
properties on recording objects. Simplifies access to Minio storage keys
and clearly documents expected behavior when saving recordings across the
application.
2025-04-16 12:13:42 +02:00
lebaudantoine d74dd967af ♻️(backend) replace hardcoded file extensions with Python enum
Convert hardcoded string file extensions into a well-defined Python enum.
Improves type safety and centralizes extension definitions for better
maintainability and consistency across the codebase.

It was dirty manipulating literals for file extension validation …
2025-04-16 12:13:42 +02:00
lebaudantoine 3a4f4e7016 (backend) add recording mode to serialized fields
Include recording mode in serialized data to enable conditional UI elements
in frontend. Allows download controls to be dynamically enabled or disabled
based on the specific recording type being used.

Screen recording will be downloadable when transcript won't.
2025-04-16 12:13:42 +02:00
lebaudantoine b7d964db56 (backend) add email notifications for screen recordings
Implement backend method to send email notifications when screen recordings
are ready for download. Enables users to be alerted when their recordings are
available. Frontend implementation to follow in upcoming commits.

This service is triggered by the storage hook from Minio.

Add minimal unit test coverage for notification service, addressing previous
lack of tests in this area. The notification service was responsible for
calling the unstable summary service feature, which was developped way too
quickly.

The email template has been reviewed by a LLM, to make it user-friendly and
crystal clear.
2025-04-15 13:46:57 +02:00
lebaudantoine 88b7a7dc58 🌐(backend) generate missing translations for backend updates
Regenerate translation files to include all recent backend string changes.
Address backlog of untranslated content that accumulated during recent
development cycles.
2025-04-14 21:00:46 +02:00
lebaudantoine baca9fc001 🌐(frontend) add missing german translations
Oopsie. I forgot some keys for german, add them.
2025-04-11 11:38:21 +02:00
lebaudantoine c432524f2a 🚸(frontend) display option to user with screen recording access
Introduce a feature flag for screen recording to allow gradual
rollout of the feature.
2025-04-11 11:38:21 +02:00
lebaudantoine a079ceef71 🚸(frontend) inform user when recording is saving
Saving a recording can take a bit of time, display a clear message to
inform user it can takes few minutes.
2025-04-11 11:38:21 +02:00
lebaudantoine f0742a0978 📈(frontend) add analytics on screen recording and transcript features
Use posthog to track whether user starts transcript or screen recording.
2025-04-11 11:38:21 +02:00
lebaudantoine fc1b4d7fa7 💄(frontend) replace recording side panel title with proper header
Update recording side panel to use semantic header element instead of plain
text. Improves accessibility by providing proper document structure and
enhances visual hierarchy in the user interface.
2025-04-11 11:38:21 +02:00
lebaudantoine 46f26eb493 ♻️(frontend) extract recording side panel into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine ff09c3d969 ♻️(frontend) factorize notifyParticipants
Extract notifyParticipants in a proper hook to avoid duplication.
2025-04-11 11:38:21 +02:00
lebaudantoine ba9d22f6c8 ♻️(frontend) extract feature flag into a dedicated enum
Extract code elements related to feature flag into a proper enum
to avoid hardcoded literals.
2025-04-11 11:38:21 +02:00
lebaudantoine 94e71ba15d ♻️(frontend) extract recording api into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine 9a1384b188 ♻️(frontend) rename RecordingStateBadge to RecordingStateToast
Rename component to match existing LiveKit naming conventions like
connectionStateToast. Improves naming consistency.
2025-04-11 11:38:21 +02:00
lebaudantoine 695ac47014 ♻️(frontend) extract recording hooks into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine b3c1deeb9c 🍱(frontend) add La Suite logo
Will be necessary for email notifications, when notifying users their
recording are ready for download.
2025-04-11 11:38:21 +02:00
lebaudantoine c08c3efdbb 🩹(frontend) avoid having the two recording modes concurrently
Implement mutual exclusivity between transcript and screen recording modes
to prevent both from being active simultaneously. Add validation logic to
ensure users can only enable one recording type at a time.
2025-04-11 11:38:21 +02:00
lebaudantoine 468d09dc3b (frontend) introde the Screen Recording side panel
Offer one new tool to user, the screen recording side panel.
2025-04-11 11:38:21 +02:00
lebaudantoine a22d052f46 ♻️(frontend) generalize recording availability hook for all types
Extend recording availability and access hook to work with all recording
types instead of being transcript-specific. Create flexible implementation
that determines availability and permissions for screen recordings and
future recording formats.
2025-04-11 11:38:21 +02:00
lebaudantoine 9734df9d5d ♻️(frontend) generalize recording state badge for all recording types
Extend recording state badge component to work with all types of recordings
instead of just transcripts. Create flexible implementation that supports
screen recordings and future recording formats while maintaining consistent
visual indicators.
2025-04-11 11:38:21 +02:00
lebaudantoine f596aae1e8 ♻️(frontend) generalize transcript notification for all recording types
Convert transcript-specific toast notification into a flexible component
that works with any recording type. Create extensible design that can
accommodate screen recordings and future recording formats. Implementation
is functional though not perfect, with room for future enhancement.
2025-04-11 11:38:21 +02:00
lebaudantoine 32956f495f ⚰️(frontend) remove dead code
Traces of debugging, never been useful.
2025-04-11 11:38:21 +02:00
lebaudantoine 69381a6c4b 🌐(frontend) prepare translation keys for expanded recording types
Restructure translation keys to support a more extensive set
of recording types in the future.
2025-04-11 11:38:21 +02:00
lebaudantoine d537a4449a 🔖(minor) bump release to 0.1.18
Refactoring of transcription ui + SDK
2025-04-08 23:04:24 +02:00
lebaudantoine 50c9304afe (back) remove url-normalize dependency
We have the dependency url-normalize installed but we don't use it in
our codebase.

(from @lunika on docs)
2025-04-07 21:51:54 +02:00
lebaudantoine 37fe23c0f7 🚸(frontend) enhance transcript copywritting
Simplify the instructions. Also offer a way to user to discover
the feature and register as beta users.
2025-04-04 19:11:33 +02:00
lebaudantoine 255da4bf60 ♻️(frontend) refactor the transcription side panel into a sub menu
Add a new level of nesting while handling side panel.
Code quality is quite poor, we need a better interface.
2025-04-04 19:11:33 +02:00
lebaudantoine 6dccb507d2 🎨(frontend) remove duplicated forms literals
Use a proper constant, which already exists.
2025-04-04 19:11:33 +02:00
lebaudantoine d202a025e7 🚸(frontend) share more information about transcription state
Previous toast state was too naive. Enhance message deliver to
participants.
2025-04-04 19:11:33 +02:00
lebaudantoine ac9ba0df62 💩(frontend) introduce transcription store
Introduce a dedicated store for transcription to better manage its status
independently of meeting recording status. This lays the groundwork for
future improvements, especially as we plan to support additional recording
options beyond the current setup.

This isn't perfect and still coupled with room recording status
2025-04-04 19:11:33 +02:00
lebaudantoine 91562d049c ♻️(frontend) introduce a reusable tools sidepanel
tools will be added in the future, let's generalize the sidepanel
previously reserved to transcription.
2025-04-04 19:11:33 +02:00
lebaudantoine 60321296e5 (frontend) introduce a notification for transcription
Main goal: notify participant when the meeting is being recorded.
It helps understand the whole system behavior with clear
notification states.
2025-04-04 19:11:33 +02:00
lebaudantoine ae06873ff5 👔(frontend) update feedback form's URL
Gregoire reworked our framework on user feedbacks.
Update the Grist form's URL.
2025-04-04 15:57:45 +02:00
lebaudantoine 70ffb758c7 ♻️(frontend) refactor Grist forms urls in a single constants file
Consolidate all Grist forms into a single file to improve code organization
and enhance maintainability.
2025-04-04 15:57:45 +02:00
lebaudantoine 33a94da636 🩹(frontend) add a disabled style on tertiary button
Was lacking, necessary in my next jobs working on
transcript side pannel v2.
2025-04-04 15:57:45 +02:00
lebaudantoine aa6757601d 💄(frontend) force having medium font weight buttons
Improve the readability of the buttons by increasing their thickness.
2025-04-04 15:57:45 +02:00
lebaudantoine c26b1b711c (frontend) add external link support to LinkButton component
Enhanced LinkButton with target props for external link usage.
2025-04-04 15:57:45 +02:00
lebaudantoine 3ee33fc2ec ♻️(frontend) parametrize spinner size
Allow passing custom spinner size.
Needed to align the spinner height with an inline button.
2025-04-04 14:31:31 +02:00
lebaudantoine d43b2857c1 🩹(sdk) notify iframe parent when room is gathered through callback
On the first room creation, when the authentication redirection takes
place, the iframe wasn't properly messaging the parent.
Fixed it. Send the room data in any of the two methods to acquire it.

Actually, the code when gathering data through the callback endpoint is
a bit dirty.
2025-04-04 13:22:12 +02:00
127 changed files with 5386 additions and 643 deletions
+30
View File
@@ -135,6 +135,9 @@ jobs:
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
@@ -152,6 +155,33 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start MinIO
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
dockerize -wait tcp://localhost:9000 -timeout 10s
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set meet http://localhost:9000 meet password && \
mc alias ls && \
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v5
with:
+33
View File
@@ -15,6 +15,37 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -40,6 +71,7 @@ services:
- redis
- nginx
- livekit
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -73,6 +105,7 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
+11
View File
@@ -12,12 +12,20 @@ PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN=localhost:3000
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
# Backend url
MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -42,3 +50,6 @@ LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
# Recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
+1
View File
@@ -40,6 +40,7 @@ def get_frontend_configuration(request):
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+1 -1
View File
@@ -94,7 +94,7 @@ class HasAbilityPermission(IsAuthenticated):
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges to start a recording."
message = "You must have privileges on room to perform this action."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
+35 -8
View File
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
@@ -11,9 +12,11 @@ from core import models, utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField()
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -156,7 +159,17 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
read_only_fields = fields
@@ -189,11 +202,11 @@ class RequestEntrySerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("RequestEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
@@ -204,11 +217,11 @@ class ParticipantEntrySerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
@@ -218,8 +231,22 @@ class CreationCallbackSerializer(serializers.Serializer):
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
+122 -4
View File
@@ -2,6 +2,7 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -20,7 +21,8 @@ from rest_framework import (
status as drf_status,
)
from core import models, utils
from core import enums, models, utils
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -39,6 +41,7 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
@@ -363,7 +366,7 @@ class RoomViewSet(
@decorators.action(
detail=True,
methods=["POST"],
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
@@ -446,7 +449,7 @@ class RoomViewSet(
@decorators.action(
detail=False,
methods=["POST"],
methods=["post"],
url_path="webhooks-livekit",
permission_classes=[],
)
@@ -472,7 +475,7 @@ class RoomViewSet(
@decorators.action(
detail=False,
methods=["POST"],
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
@@ -495,6 +498,38 @@ class RoomViewSet(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="invite",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def invite(self, request, pk=None): # pylint: disable=unused-argument
"""Send email invitations to join a room.
This API endpoint allows a user with appropriate privileges to send email invitations
to one or more recipients, inviting them to join the specified room.
"""
room = self.get_object()
serializer = serializers.RoomInviteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
emails = serializer.validated_data.get("emails")
emails = list(set(emails))
InvitationService().invite_to_room(
room=room, sender=request.user, emails=emails
)
return drf_response.Response(
{"status": "success", "message": "invitations sent"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -544,6 +579,7 @@ class ResourceAccessViewSet(
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -617,3 +653,85 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+12
View File
@@ -2,9 +2,21 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+56 -1
View File
@@ -3,8 +3,9 @@ Declare and configure the models for the Meet core application
"""
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List
from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,12 +13,15 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -576,6 +580,57 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
}
@property
def extension(self):
"""Get recording extension based on its mode."""
extensions = {
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
}
return extensions.get(self.mode, FileExtension.MP4.value)
@property
def key(self):
"""Generate the file key based on recording mode."""
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
+10
View File
@@ -0,0 +1,10 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -1,8 +1,13 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import requests
@@ -21,10 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -33,6 +35,79 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"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}",
}
has_failures = False
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -57,10 +132,8 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
@@ -7,6 +7,7 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -89,7 +90,9 @@ class VideoCompositeEgressService(BaseEgressService):
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(filename=recording_id, extension="mp4")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -98,8 +101,7 @@ class VideoCompositeEgressService(BaseEgressService):
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name,
file_outputs=[file_output],
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
@@ -120,7 +122,9 @@ class AudioCompositeEgressService(BaseEgressService):
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(filename=recording_id, extension="ogg")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
+59
View File
@@ -0,0 +1,59 @@
"""Invitation Service."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
logger = getLogger(__name__)
class InvitationError(Exception):
"""Exception raised when invitation emails cannot be sent."""
status_code = 500
class InvitationService:
"""Service for invitations to users."""
@staticmethod
def invite_to_room(room, sender, emails):
"""Send invitation emails to join a room."""
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
"sender_email": sender.email,
}
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(
_(
f"Video call in progress: {sender.email} is waiting for you to connect"
)
) # Force translation
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e
@@ -0,0 +1,224 @@
"""
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
from unittest import mock
from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@pytest.fixture
def mocked_current_site():
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
site_mock = mock.Mock()
site_mock.domain = "test-domain.com"
with mock.patch.object(
Site.objects, "get_current", return_value=site_mock
) as patched:
yield patched
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
def test_notify_external_services_transcript_mode(mock_notify_summary):
"""Test notification routing for transcript mode recordings."""
service = NotificationService()
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
result = service.notify_external_services(recording)
assert result is True
mock_notify_summary.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode(mock_notify_email):
"""Test notification routing for screen recording mode."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
# Bypass validation
recording.mode = "unknown"
service = NotificationService()
result = service.notify_external_services(recording)
assert result is False
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
call_args_list = mock_send_mail.call_args_list
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
]
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
# Recording with no access
recording = factories.RecordingFactory()
result = notification_service._notify_user_by_email(recording)
assert result is False
assert f"No owner found for recording {recording.id}" in caplog.text
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
"""Test email notification when an exception occurs."""
recording = factories.RecordingFactory(room__name="Conference Room A")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is False
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -22,15 +22,37 @@ def test_api_recordings_list_anonymous():
assert response.status_code == 401
def test_api_recordings_list_authenticated_no_recording():
"""
Authenticated users listing recordings should only
see recordings to which they have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.UserRecordingAccessFactory(user=other_user)
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert results == []
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
def test_api_recordings_list_authenticated_direct(role, settings):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -57,7 +79,9 @@ def test_api_recordings_list_authenticated_direct(role):
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -66,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -0,0 +1,284 @@
"""
Test media-auth authorization API endpoint in docs core app.
"""
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import models
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
pytestmark = pytest.mark.django_db
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = APIClient().get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=wrong_status)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(
status=models.RecordingStatusChoices.SAVED,
mode=models.RecordingModeChoices.TRANSCRIPT,
)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found with this extension."}
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = (
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
)
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 400
assert response.json() == {"detail": "Unsupported extension."}
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@@ -0,0 +1,243 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
client = APIClient()
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
The API returns 404 instead of 403 to avoid revealing the existence of
resources the user doesn't have permission to access.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
other_user = UserFactory()
recording = UserRecordingAccessFactory(user=other_user).recording
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
],
)
def test_api_recording_retrieve_any_status(status):
"""Test that recordings with any status can be retrieved."""
user = UserFactory()
recording = RecordingFactory(status=status)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(recording.id)
assert content["status"] == status
@@ -0,0 +1,295 @@
"""
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=W0621,W0613
import json
import random
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...services.invitation import InvitationError, InvitationService
pytestmark = pytest.mark.django_db
def test_api_rooms_invite_anonymous():
"""Test anonymous users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 401
def test_api_rooms_invite_no_access():
"""Test non-privileged users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_member():
"""Test member users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
room.accesses.create(user=user, role="member")
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_missing_emails():
"""Test missing email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"foo": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This field is required.",
]
}
def test_api_rooms_invite_empty_emails():
"""Test empty email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This list may not be empty.",
]
}
def test_api_rooms_invite_invalid_emails():
"""Test invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["abdc", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"0": ["Enter a valid email address."],
"1": ["Enter a valid email address."],
}
}
def test_api_rooms_invite_partially_invalid_emails():
"""Test partially invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabrice@yopmail.com", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"1": ["Enter a valid email address."],
}
}
@mock.patch.object(InvitationService, "invite_to_room")
def test_api_rooms_invite_duplicates(mock_invite_to_room):
"""Test duplicate emails should be deduplicated before processing."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
mock_invite_to_room.assert_called_once()
_, kwargs = mock_invite_to_room.call_args
assert kwargs["room"] == room
assert kwargs["sender"] == user
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
def test_api_rooms_invite_error(mock_invite_to_room):
"""Test invitation service error should return appropriate error response."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError) as excinfo:
client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
mock_invite_to_room.assert_called_once()
assert "Could not send invitation" in str(excinfo.value)
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
settings.EMAIL_DOMAIN = "acme.com"
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -336,10 +336,11 @@ def test_api_rooms_retrieve_authenticated():
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
"""
Users who are members of a room should be allowed to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
@@ -369,6 +370,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
@@ -380,6 +383,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -415,11 +420,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_administrators(
mock_token, django_assert_num_queries, settings
):
"""
A user who is an administrator or owner of a room should be allowed
to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
@@ -448,6 +456,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -459,6 +469,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
+5 -1
View File
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
}
def test_api_users_retrieve_me_authenticated():
def test_api_users_retrieve_me_authenticated(settings):
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
settings.TIME_ZONE = "UTC"
user = factories.UserFactory()
client = APIClient()
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
}
@@ -12,7 +12,8 @@ from core.factories import (
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
from core.recording.enums import FileExtension
pytestmark = pytest.mark.django_db
@@ -216,3 +217,74 @@ def test_models_recording_worker_id_very_long():
too_long_id = "w" * 256
with pytest.raises(ValidationError):
RecordingFactory(worker_id=too_long_id)
# Test key property method
def test_models_recording_key_for_transcript(settings):
"""Test key property returns correct path for transcript mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
assert recording.key == expected_path
def test_models_recording_key_for_screen_recording(settings):
"""Test key property returns correct path for screen recording mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
def test_models_recording_key_for_unknown_mode(settings):
"""Test key property uses default extension for unknown mode."""
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
recording = RecordingFactory()
# Directly set an invalid mode (bypassing validation)
recording.mode = "unknown_mode"
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
assert recording.key == expected_path
# Test is_saved method
def test_models_recording_is_saved_true():
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
assert recording.is_saved is True
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
assert recording.is_saved is True
def test_models_recording_is_saved_false_active():
"""Test is_saved property returns False for ACTIVE status."""
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
assert recording.is_saved is False
def test_models_recording_is_saved_false_initiated():
"""Test is_saved property returns False for INITIATED status."""
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
assert recording.is_saved is False
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED,
],
)
def test_models_recording_is_saved_false_error_states(status):
"""Test is_saved property returns False for error statuses."""
recording = RecordingFactory(status=status)
assert recording.is_saved is False
+32
View File
@@ -11,7 +11,9 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
@@ -110,3 +112,33 @@ def generate_livekit_config(
room=room_id, user=user, username=username, color=color
),
}
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our recording' metadata
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
Binary file not shown.
+347 -106
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-04-23 14:04+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,192 +17,433 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Important dates"
#: core/api/serializers.py:128
msgid "Markdown Body"
msgstr ""
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/authentication.py:71
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
msgstr "User info contained no recognizable user identification"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "User account is disabled"
#: core/models.py:27
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Multiple user accounts share a common email."
#: core/models.py:30
msgid "Member"
msgstr ""
msgstr "Member"
#: core/models.py:28
#: core/models.py:31
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: core/models.py:29
#: core/models.py:32
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
msgstr "Owner"
#: core/models.py:48
msgid "created on"
msgstr ""
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Active"
msgstr "Active"
#: core/models.py:50
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:51
msgid "Saved"
msgstr "Saved"
#: core/models.py:52
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:53
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:55
msgid "date and time at which a record was last updated"
msgstr ""
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:75
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:90
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:91
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:110
msgid "created on"
msgstr "created on"
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:116
msgid "updated on"
msgstr "updated on"
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:137
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:81
#: core/models.py:143
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:83
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:91
#: core/models.py:153
msgid "identity email address"
msgstr ""
msgstr "identity email address"
#: core/models.py:96
#: core/models.py:158
msgid "admin email address"
msgstr ""
msgstr "admin email address"
#: core/models.py:103
#: core/models.py:160
msgid "full name"
msgstr "full name"
#: core/models.py:162
msgid "short name"
msgstr "short name"
#: core/models.py:168
msgid "language"
msgstr ""
msgstr "language"
#: core/models.py:104
#: core/models.py:169
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "The language in which the user wants to see the interface."
#: core/models.py:110
#: core/models.py:175
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "The timezone in which the user wants to see times."
#: core/models.py:113
#: core/models.py:178
msgid "device"
msgstr ""
msgstr "device"
#: core/models.py:115
#: core/models.py:180
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Whether the user is a device or a real user."
#: core/models.py:118
#: core/models.py:183
msgid "staff status"
msgstr ""
msgstr "staff status"
#: core/models.py:120
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Whether the user can log into this admin site."
#: core/models.py:123
#: core/models.py:188
msgid "active"
msgstr ""
msgstr "active"
#: core/models.py:126
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:138
#: core/models.py:204
msgid "user"
msgstr ""
msgstr "user"
#: core/models.py:139
#: core/models.py:205
msgid "users"
msgstr ""
msgstr "users"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:264
msgid "Resource"
msgstr "Resource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:265
msgid "Resources"
msgstr "Resources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:319
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:320
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:166
msgid "public"
msgstr ""
#: core/models.py:326
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
msgstr ""
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Room"
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:390
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:520
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:263
msgid "This user is already in this template."
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:530
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:275
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:537
msgid "Recording"
msgstr "Recording"
#: core/models.py:538
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Either user or team must be set, not both."
#: meet/settings.py:134
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authentication is enabled but token is not configured."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Authorization header is required"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Invalid authorization header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Invalid token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
#: core/services/invitation.py:44
#, python-brace-format
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/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "English"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
Binary file not shown.
+352 -103
View File
@@ -8,201 +8,450 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/api/serializers.py:128
msgid "Markdown Body"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/authentication.py:71
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
"Les informations utilisateur ne contiennent aucune identification "
"utilisateur reconnaissable"
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Le compte utilisateur est désactivé"
#: core/models.py:27
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
#: core/models.py:30
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:28
#: core/models.py:31
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:29
#: core/models.py:32
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:48
msgid "created on"
msgstr ""
msgid "Initiated"
msgstr "Initié"
#: core/models.py:49
msgid "date and time at which a record was created"
msgstr ""
msgid "Active"
msgstr "Actif"
#: core/models.py:50
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:51
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:52
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:53
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:54
msgid "updated on"
msgstr ""
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:55
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:89
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:90
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:91
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:110
msgid "created on"
msgstr "créé le"
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:116
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:75
#: core/models.py:137
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:81
#: core/models.py:143
msgid "sub"
msgstr ""
msgstr "identifiant"
#: core/models.py:83
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:91
#: core/models.py:153
msgid "identity email address"
msgstr ""
msgstr "adresse e-mail d'identité"
#: core/models.py:96
#: core/models.py:158
msgid "admin email address"
msgstr ""
msgstr "adresse e-mail d'administrateur"
#: core/models.py:103
#: core/models.py:160
msgid "full name"
msgstr "nom complet"
#: core/models.py:162
msgid "short name"
msgstr "nom court"
#: core/models.py:168
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:104
#: core/models.py:169
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:110
#: core/models.py:175
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:113
#: core/models.py:178
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:115
#: core/models.py:180
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:118
#: core/models.py:183
msgid "staff status"
msgstr ""
msgstr "statut du personnel"
#: core/models.py:120
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:123
#: core/models.py:188
msgid "active"
msgstr ""
msgstr "actif"
#: core/models.py:126
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:138
#: core/models.py:204
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:139
#: core/models.py:205
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:161
msgid "title"
msgstr ""
#: core/models.py:264
msgid "Resource"
msgstr "Ressource"
#: core/models.py:162
msgid "description"
msgstr ""
#: core/models.py:265
msgid "Resources"
msgstr "Ressources"
#: core/models.py:163
msgid "code"
msgstr ""
#: core/models.py:319
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:164
msgid "css"
msgstr ""
#: core/models.py:320
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:166
msgid "public"
#: core/models.py:326
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:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Salle"
#: core/models.py:390
msgid "Rooms"
msgstr "Salles"
#: core/models.py:520
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
"facile."
#: core/models.py:174
msgid "Template"
msgstr ""
#: core/models.py:530
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:175
msgid "Templates"
msgstr ""
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:256
msgid "Template/user relation"
msgstr ""
#: core/models.py:537
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:257
msgid "Template/user relations"
msgstr ""
#: core/models.py:538
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:263
msgid "This user is already in this template."
msgstr ""
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:269
msgid "This team is already in this template."
msgstr ""
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:275
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: meet/settings.py:134
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "L'en-tête d'autorisation est requis"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Jeton invalide"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
#: core/services/invitation.py:44
#, python-brace-format
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/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr ""
msgstr "Anglais"
#: meet/settings.py:135
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
Binary file not shown.
@@ -0,0 +1,449 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Gebruikersaccount is uitgeschakeld"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
#: core/models.py:30
msgid "Member"
msgstr "Lid"
#: core/models.py:31
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:32
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:48
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:49
msgid "Active"
msgstr "Actief"
#: core/models.py:50
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:51
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:52
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:53
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:54
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:55
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:90
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:91
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:110
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:116
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:137
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:143
msgid "sub"
msgstr "sub"
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:153
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:158
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:160
msgid "full name"
msgstr "volledige naam"
#: core/models.py:162
msgid "short name"
msgstr "korte naam"
#: core/models.py:168
msgid "language"
msgstr "taal"
#: core/models.py:169
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:175
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:178
msgid "device"
msgstr "apparaat"
#: core/models.py:180
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:183
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:188
msgid "active"
msgstr "actief"
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:204
msgid "user"
msgstr "gebruiker"
#: core/models.py:205
msgid "users"
msgstr "gebruikers"
#: core/models.py:264
msgid "Resource"
msgstr "Bron"
#: core/models.py:265
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:319
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:320
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:326
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Ruimte"
#: core/models.py:390
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:520
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
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:530
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:537
msgid "Recording"
msgstr "Opname"
#: core/models.py:538
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Autorisatie-header is vereist"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Ongeldige autorisatie-header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Ongeldig token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/services/invitation.py:44
#, python-brace-format
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/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
+19 -1
View File
@@ -23,7 +23,6 @@ from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
def get_release():
@@ -70,6 +69,8 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
@@ -160,6 +161,7 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
)
)
@@ -307,6 +309,9 @@ class Base(Configuration):
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
}
# Mail
@@ -318,6 +323,11 @@ class Base(Configuration):
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_DOMAIN = values.Value(None)
EMAIL_APP_BASE_URL = values.Value(None)
AUTH_USER_MODEL = "core.User"
@@ -475,12 +485,20 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy
# Set to None for no expiration
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = values.Value(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.17"
version = "0.1.19"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.18",
"boto3==1.37.24",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -53,7 +53,6 @@ dependencies = [
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.24.1",
"url-normalize==1.4.3",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.17",
"version": "0.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.17",
"version": "0.1.19",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
@@ -49,7 +49,7 @@
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.4",
"vite": "6.2.6",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -9571,9 +9571,9 @@
}
},
"node_modules/vite": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.17",
"version": "0.1.19",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -54,7 +54,7 @@
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.4",
"vite": "6.2.6",
"vite-tsconfig-paths": "5.1.4"
}
}
+6
View File
@@ -397,6 +397,12 @@ const config: Config = {
lineHeight: '1.25rem',
},
},
xs: {
value: {
fontSize: '0.825rem',
lineHeight: '1.15rem',
},
},
badge: {
value: {
fontSize: '0.75rem',
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+11
View File
@@ -0,0 +1,11 @@
export const mediaUrl = (path: string) => {
const origin =
import.meta.env.VITE_API_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
// Remove leading/trailing slashes from origin/path if it exists
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/media/${sanitizedPath}`
}
+3 -1
View File
@@ -1,7 +1,7 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { RecordingMode } from '@/features/recording'
export interface ApiConfig {
analytics?: {
@@ -12,9 +12,11 @@ export interface ApiConfig {
id: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
expiration_days?: number
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

@@ -3,12 +3,14 @@ import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
import { useLocation } from 'wouter'
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
const SDK_BASE_ROUTE = '/sdk'
export const AppInitialization = () => {
const { data } = useConfig()
const [location] = useLocation()
useSyncUserPreferencesWithBackend()
const {
analytics = {},
@@ -2,7 +2,7 @@ import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
import { GRIST_FORM } from '@/utils/constants'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
export const FeedbackBanner = () => {
const { t } = useTranslation()
@@ -35,7 +35,7 @@ export const FeedbackBanner = () => {
gap: 0.25,
})}
>
<A href={GRIST_FORM} target="_blank" size="sm">
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
{t('feedback.cta')}
</A>
<RiExternalLinkLine size={16} aria-hidden="true" />
@@ -0,0 +1,5 @@
export enum FeatureFlags {
Transcript = 'transcription-summary',
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
}
@@ -1,6 +1,10 @@
import { BackendLanguage } from '@/utils/languages'
export type ApiUser = {
id: string
email: string
full_name: string
last_name: string
language: BackendLanguage
timezone: string
}
@@ -0,0 +1,15 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise<ApiUser> => {
return await fetchApi(`/users/${user.id}/`, {
method: 'PUT',
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
})
}
@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser'
/**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
* Automatically updates backend when browser settings change for logged-in users.
*/
export const useSyncUserPreferencesWithBackend = () => {
const { i18n } = useTranslation()
const { user, isLoggedIn } = useUser()
const { mutateAsync } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
useEffect(() => {
if (!user || !isLoggedIn) return
const syncBrowserPreferencesToBackend = async () => {
const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const currentLanguage = convertToBackendLanguage(i18n.language)
if (
currentLanguage !== user.language ||
currentTimezone !== user.timezone
) {
await mutateAsync({
user: {
id: user.id,
timezone: currentTimezone,
language: currentLanguage,
},
})
}
}
syncBrowserPreferencesToBackend()
}, [i18n.language, isLoggedIn, user, mutateAsync])
}
+18 -2
View File
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import {
startAnalyticsSession,
terminateAnalyticsSession,
@@ -12,6 +12,7 @@ import {
terminateSupportSession,
} from '@/features/support/hooks/useSupport'
import { logoutUrl } from '../utils/logoutUrl'
import { useConfig } from '@/api/useConfig'
/**
* returns info about currently logged-in user
@@ -23,11 +24,25 @@ export const useUser = (
fetchUserOptions?: Parameters<typeof fetchUser>[0]
} = {}
) => {
const { data, isLoading: isConfigLoading } = useConfig()
const options = useMemo(() => {
if (isConfigLoading) return
if (data?.is_silent_login_enabled !== true) {
return {
...opts,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts, isConfigLoading])
const query = useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [keys.user],
queryFn: () => fetchUser(opts.fetchUserOptions),
queryFn: () => fetchUser(options),
staleTime: Infinity,
enabled: !isConfigLoading,
})
useEffect(() => {
@@ -51,6 +66,7 @@ export const useUser = (
refetch: query.refetch,
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
isLoggedIn,
isLoading: query.isLoading,
logout,
}
}
+1
View File
@@ -1,3 +1,4 @@
export { useUser } from './api/useUser'
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
export { authUrl } from './utils/authUrl'
export { UserAware } from './components/UserAware'
@@ -8,10 +8,7 @@ import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// todo - extract in a proper env variable
const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
import { BETA_USERS_FORM_URL } from '@/utils/constants'
const Heading = styled('h2', {
base: {
@@ -207,6 +204,7 @@ export const IntroSlider = () => {
{slide.isAvailableInBeta && (
<LinkButton
href={BETA_USERS_FORM_URL}
target="_blank"
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
@@ -88,6 +88,18 @@ export const MainNotificationToast = () => {
if (notification.data?.emoji)
handleEmoji(notification.data.emoji, participant)
break
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
)
break
default:
return
}
@@ -6,4 +6,8 @@ export enum NotificationType {
LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived',
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
}
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
return (
<StyledToastContainer {...toastProps} ref={ref}>
<StyledToast>
<div {...contentProps}>{props.toast.content?.message} machine a</div>
<div {...contentProps}>{props.toast.content?.message}</div>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine color="white" />
</Button>
@@ -0,0 +1,48 @@
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'
export function ToastAnyRecording({ state, ...props }: 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 key = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionStarted:
return 'transcript.started'
case NotificationType.TranscriptionStopped:
return 'transcript.stopped'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
default:
return
}
}, [type])
if (!key) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(key, {
name: participant.name,
})}
</HStack>
</StyledToastContainer>
)
}
@@ -9,6 +9,7 @@ import { ToastRaised } from './ToastRaised'
import { ToastMuted } from './ToastMuted'
import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -36,6 +37,12 @@ const renderToast = (
case NotificationType.LowerHand:
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -10,10 +10,14 @@ import { usePrevious } from '@/hooks/usePrevious'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications'
export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => {
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', {
keyPrefix: 'waitingParticipants',
})
@@ -37,6 +41,9 @@ export const WaitingParticipantNotification = () => {
!isParticipantsOpen
) {
setShowQuickActionsMessage(true)
triggerNotificationSound(NotificationType.ParticipantJoined)
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
}
@@ -48,7 +55,12 @@ export const WaitingParticipantNotification = () => {
// Hide notification when the participant count changes
setShowQuickActionsMessage(false)
}
}, [waitingParticipants, prevWaitingParticipant, isParticipantsOpen])
}, [
waitingParticipants,
prevWaitingParticipant,
isParticipantsOpen,
triggerNotificationSound,
])
useEffect(() => {
// This cleanup function will only run when the component unmounts
@@ -0,0 +1,37 @@
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '../NotificationType'
import { NotificationPayload } from '../NotificationPayload'
export const useNotifyParticipants = () => {
const room = useRoomContext()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const notifyParticipants = async <T extends Record<string, any>>(options: {
type: NotificationType
destinationIdentities?: string[]
additionalData?: T
reliable?: boolean
}): Promise<void> => {
const {
type,
destinationIdentities,
additionalData = {} as T,
reliable = true,
} = options
const payload: NotificationPayload & T = {
type,
...additionalData,
}
const encoder = new TextEncoder()
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable,
destinationIdentities,
})
}
return { notifyParticipants }
}
@@ -0,0 +1,2 @@
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
export { NotificationType } from './NotificationType'
@@ -0,0 +1,18 @@
import { fetchApi } from '@/api/fetchApi'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode, RecordingStatus } from '@/features/recording'
export type RecordingApi = {
id: string
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
created_at: string
key: string
mode: RecordingMode
status: RecordingStatus
is_expired: boolean
expired_at: string
}
export const fetchRecording = ({ recordingId }: { recordingId?: string }) => {
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
}
@@ -1,12 +1,8 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode } from '../types'
export interface StartRecordingParams {
id: string
@@ -1,7 +1,7 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
export interface StopRecordingParams {
id: string
@@ -0,0 +1,198 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio/index'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo } from 'react'
import { Text } from '@/primitives'
import { RemoteParticipant, 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,
} 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'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
})
const room = useRoomContext()
const { openTranscript, openScreenRecording } = useSidePanel()
const recordingSnap = useSnapshot(recordingStore)
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
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])
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const notification = decodeNotificationDataReceived(payload)
if (!participant || !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.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
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])
const key = useMemo(() => {
switch (recordingSnap.status) {
case RecordingStatus.TRANSCRIPT_STARTED:
return 'transcript.started'
case RecordingStatus.TRANSCRIPT_STOPPING:
return 'transcript.stopping'
case RecordingStatus.TRANSCRIPT_STARTING:
return 'transcript.starting'
case RecordingStatus.SCREEN_RECORDING_STARTED:
return 'screenRecording.started'
case RecordingStatus.SCREEN_RECORDING_STOPPING:
return 'screenRecording.stopping'
case RecordingStatus.SCREEN_RECORDING_STARTING:
return 'screenRecording.starting'
case RecordingStatus.ANY_STARTED:
return 'any.started'
default:
return
}
}, [recordingSnap])
if (!key) return
const isStarted = key?.includes('started')
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',
})}
/>
) : (
<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>
)
}
@@ -0,0 +1,211 @@
import { A, Button, Div, H, Text } from '@/primitives'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
import { useIsRecordingTransitioning } from '@/features/recording'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
export const ScreenRecordingSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const { notifyParticipants } = useNotifyParticipants()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
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 room = useRoomContext()
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleScreenRecording = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
await notifyParticipants({
type: NotificationType.ScreenRecordingStopped,
})
} else {
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
})
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={fourthSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{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 ? (
<>
<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={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_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 ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
</Div>
)
}
@@ -0,0 +1,245 @@
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
import { FeatureFlags } from '@/features/analytics/enums'
import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
export const TranscriptSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const recordingSnap = useSnapshot(recordingStore)
const { notifyParticipants } = useNotifyParticipants()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
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 room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
await notifyParticipants({
type: NotificationType.TranscriptionStopped,
})
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={thirdSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{!hasTranscriptAccess ? (
<>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<LinkButton
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{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 ? (
<>
<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={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_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 ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
</>
)}
</Div>
)
}
@@ -0,0 +1,22 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
export const useHasRecordingAccess = (
mode: RecordingMode,
featureFlag: FeatureFlags
) => {
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isRecordingModeEnabled
)
}
@@ -0,0 +1,22 @@
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)
}
}
@@ -0,0 +1,11 @@
import { RecordingMode } from '../types'
import { useConfig } from '@/api/useConfig'
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(mode)
)
}
@@ -0,0 +1,15 @@
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,18 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
// api
export { useStartRecording } from './api/startRecording'
export { useStopRecording } from './api/stopRecording'
export { RecordingMode, RecordingStatus } from './types'
// components
export { RecordingStateToast } from './components/RecordingStateToast'
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
// routes
export { RecordingDownload as RecordingDownloadRoute } from './routes/RecordingDownload'
@@ -0,0 +1,112 @@
import { useParams } from 'wouter'
import { useQuery } from '@tanstack/react-query'
import { Center, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { mediaUrl } from '@/api/mediaUrl'
import { UserAware, useUser } from '@/features/auth'
import { Screen } from '@/layout/Screen'
import { H, LinkButton, Text } from '@/primitives'
import { formatDate } from '@/utils/formatDate'
import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording'
import { useConfig } from '@/api/useConfig'
export const RecordingDownload = () => {
const { t } = useTranslation('recording')
const { data: configData } = useConfig()
const { recordingId } = useParams()
const { isLoggedIn, isLoading: isAuthLoading } = useUser()
const { data, isLoading, isError } = useQuery({
queryKey: ['recording', recordingId],
queryFn: () => fetchRecording({ recordingId }),
retry: false,
enabled: !!recordingId,
})
if (isLoading || !data || isAuthLoading) {
return <LoadingScreen />
}
if (!isLoggedIn) {
return (
<ErrorScreen
title={t('authentication.title')}
body={t('authentication.body')}
/>
)
}
if (isError) {
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
}
if (
data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed
) {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
}
if (data.is_expired) {
return (
<ErrorScreen
title={t('expired.title')}
body={t('expired.body', {
date: formatDate(data?.expired_at, 'YYYY-MM-DD HH:mm'),
})}
/>
)
}
return (
<UserAware>
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<img
src={fourthSlide}
alt={''}
className={css({
maxHeight: '309px',
})}
/>
<H lvl={1} centered>
{t('success.title')}
</H>
<Text centered margin="md" wrap={'balance'}>
<span
dangerouslySetInnerHTML={{
__html: t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
}),
}}
/>
<span>
{configData?.recording?.expiration_days && (
<>
{' '}
{t('success.expiration', {
expiration_days: configData?.recording?.expiration_days,
})}
</>
)}
</span>
</Text>
<LinkButton
href={mediaUrl(data.key)}
download={`${data.room.name}-${formatDate(data.created_at)}`}
>
{t('success.button')}
</LinkButton>
</VStack>
</Center>
</Screen>
</UserAware>
)
}
@@ -0,0 +1,15 @@
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
export enum RecordingStatus {
Initiated = 'initiated',
Active = 'active',
Stopped = 'stopped',
Saved = 'saved',
Aborted = 'aborted',
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded',
}
@@ -3,25 +3,15 @@ import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
export const useMuteParticipant = () => {
const data = useRoomData()
const room = useRoomContext()
const notifyParticipant = async (participant: Participant) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ParticipantMuted,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [participant.identity],
})
}
const { notifyParticipants } = useNotifyParticipants()
const muteParticipant = async (participant: Participant) => {
if (!data || !data?.livekit) {
@@ -53,7 +43,10 @@ export const useMuteParticipant = () => {
}
)
await notifyParticipant(participant)
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
return response
} catch (error) {
@@ -0,0 +1,75 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
import { Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<VStack alignItems="start">
<Text
as="h3"
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{t('roomInformation.title')}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
</Text>
<Button
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
}}
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
{t('roomInformation.button.copy')}
</>
)}
</Button>
</VStack>
</Div>
)
}
@@ -1,43 +0,0 @@
import { css } from '@/styled-system/css'
import { RiRecordCircleLine } from '@remixicon/react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useRoomContext } from '@livekit/components-react'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
const room = useRoomContext()
if (!room?.isRecording) return
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.25rem 0.35rem',
backgroundColor: 'primaryDark.200',
borderColor: 'primaryDark.400',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RiRecordCircleLine
size={20}
className={css({
color: 'white',
backgroundColor: 'danger.700',
padding: '3px',
borderRadius: '3px',
})}
/>
<Text variant={'sm'}>{t('label')}</Text>
</div>
)
}
@@ -3,15 +3,16 @@ import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
import { Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
type StyledSidePanelProps = {
title: string
@@ -19,6 +20,8 @@ type StyledSidePanelProps = {
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
}
const StyledSidePanel = ({
@@ -27,6 +30,8 @@ const StyledSidePanel = ({
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
}: StyledSidePanelProps) => (
<div
className={css({
@@ -61,9 +66,22 @@ const StyledSidePanel = ({
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && (
<Button
variant="secondaryText"
size={'sm'}
square
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
>
<RiArrowLeftLine size={20} />
</Button>
)}
{title}
</Heading>
<Div
@@ -114,19 +132,27 @@ export const SidePanel = () => {
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
isTranscriptOpen,
isToolsOpen,
isAdminOpen,
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
return (
<StyledSidePanel
title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.activePanelId = null)}
title={t(`heading.${activeSubPanelId || activePanelId}`)}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activePanelId}`),
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
@@ -137,12 +163,15 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
<Panel isOpen={isTranscriptOpen}>
<Transcript />
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
<Info />
</Panel>
</StyledSidePanel>
)
}
@@ -0,0 +1,190 @@
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
import { ReactNode } from 'react'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import {
useIsRecordingModeEnabled,
RecordingMode,
useHasRecordingAccess,
TranscriptSidePanel,
ScreenRecordingSidePanel,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
export interface ToolsButtonProps {
icon: ReactNode
title: string
description: string
onPress: () => void
isBetaFeature?: boolean
isActive?: boolean
}
const ToolButton = ({
icon,
title,
description,
onPress,
isBetaFeature = false,
isActive = false,
}: ToolsButtonProps) => {
return (
<RACButton
className={css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'start',
paddingY: '0.5rem',
paddingX: '0.75rem 1.5rem',
borderRadius: '5px',
gap: '1.25rem',
width: 'full',
textAlign: 'start',
'&[data-hovered]': {
backgroundColor: 'primary.50',
cursor: 'pointer',
},
})}
onPress={onPress}
>
<div
className={css({
height: '50px',
minWidth: '50px',
borderRadius: '25px',
backgroundColor: 'primary.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
})}
>
{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 })}
>
{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>
</RACButton>
)
}
export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
switch (activeSubPanelId) {
case SubPanelId.TRANSCRIPT:
return <TranscriptSidePanel />
case SubPanelId.SCREEN_RECORDING:
return <ScreenRecordingSidePanel />
default:
break
}
return (
<Div
display="flex"
overflowY="scroll"
padding="0 0.75rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
paddingX: '0.75rem',
})}
margin="md"
>
{t('body')}{' '}
<A href={CRISP_HELP_ARTICLE_MORE_TOOLS} target="_blank">
{t('moreLink')}
</A>
.
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<RiFileTextFill size={24} color="white" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isBetaFeature
isActive={isTranscriptActive}
/>
)}
{hasScreenRecordingAccess && (
<ToolButton
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isBetaFeature
isActive={isScreenRecordingActive}
/>
)}
</Div>
)
}
@@ -1,108 +0,0 @@
import { Button, Div, H, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
export const Transcript = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const hasTranscriptAccess = useHasTranscriptAccess()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
if (!hasTranscriptAccess) return
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img src={thirdSlide} alt={'wip'} />
{room.isRecording ? (
<>
<H lvl={2}>{t('stop.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('stop.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button
isDisabled={isLoading}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
>
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('stop.button')}
</Button>
</>
) : (
<>
<H lvl={2}>{t('start.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('start.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button
isDisabled={isLoading}
onPress={() => handleTranscript()}
data-attr="start-transcript"
>
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('start.button')}
</Button>
</>
)}
</Div>
)
}
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const InfoToggle = ({
onPress,
...props
}: Partial<ToggleButtonProps>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
onPress={(e) => {
toggleInfo()
onPress?.(e)
}}
data-attr={`controls-info-${tooltipLabel}`}
{...props}
>
<RiInformationLine />
</ToggleButton>
</div>
)
}
@@ -2,14 +2,14 @@ import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FORM } from '@/utils/constants'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
return (
<MenuItem
href={GRIST_FORM}
href={GRIST_FEEDBACKS_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
@@ -33,19 +33,20 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
const label = isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
return (
<>
<Button
square
variant="greyscale"
size="sm"
tooltip={
isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
}
tooltip={label}
aria-label={label}
isDisabled={isMuted}
onPress={() =>
!isMuted && isLocal(participant)
@@ -55,7 +56,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} />
<RiMicOffFill color={'gray'} aria-hidden={true} />
) : (
<RiMicFill
className={css({
@@ -64,6 +65,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
? 'pulse_background 800ms infinite'
: undefined,
})}
aria-hidden={true}
/>
)}
</Button>
@@ -1,24 +1,19 @@
import { ToggleButton } from '@/primitives'
import { RiBardLine } from '@remixicon/react'
import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = ({
export const ToolsToggle = ({
variant = 'primaryTextDark',
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
const hasTranscriptAccess = useHasTranscriptAccess()
if (!hasTranscriptAccess) return
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
return (
<div
@@ -32,15 +27,15 @@ export const TranscriptToggle = ({
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
isSelected={isToolsOpen}
onPress={(e) => {
toggleTranscript()
toggleTools()
onPress?.(e)
}}
{...props}
data-attr="toggle-transcript"
data-attr="toggle-tools"
>
<RiBardLine />
<RiShapesLine />
</ToggleButton>
</div>
)
@@ -1,8 +1,9 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { FeatureFlags } from '@/features/analytics/enums'
export const useHasFaceLandmarksAccess = () => {
const featureEnabled = useFeatureFlagEnabled('face-landmarks')
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return featureEnabled || !isAnalyticsEnabled
@@ -1,17 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsTranscriptEnabled()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isTranscriptEnabled
)
}
@@ -1,11 +0,0 @@
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsTranscriptEnabled = () => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
)
}
@@ -5,53 +5,92 @@ export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TRANSCRIPT = 'transcript',
TOOLS = 'tools',
ADMIN = 'admin',
INFO = 'info',
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isInfoOpen = activePanelId == PanelId.INFO
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTranscript = () => {
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
return {
activePanelId,
activeSubPanelId,
toggleParticipants,
toggleChat,
toggleEffects,
toggleTranscript,
toggleTools,
toggleAdmin,
toggleInfo,
openTranscript,
openScreenRecording,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
isTranscriptOpen,
isToolsOpen,
isAdminOpen,
isInfoOpen,
isTranscriptOpen,
isScreenRecordingOpen,
}
}
@@ -14,6 +14,7 @@ import {
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
@@ -21,7 +22,7 @@ import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
export function MobileControlBar({
@@ -133,7 +134,7 @@ export function MobileControlBar({
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<TranscriptToggle
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
@@ -150,7 +151,7 @@ export function MobileControlBar({
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
href={GRIST_FEEDBACKS_FORM}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
@@ -1,7 +1,8 @@
import { css } from '@/styled-system/css'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { InfoToggle } from '../../components/controls/InfoToggle'
import { AdminToggle } from '../../components/AdminToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react'
@@ -18,9 +19,10 @@ const NavigationControls = ({
tooltipType = 'instant',
}: Partial<ToggleButtonProps>) => (
<>
<InfoToggle onPress={onPress} tooltipType={tooltipType} />
<ChatToggle onPress={onPress} tooltipType={tooltipType} />
<ParticipantsToggle onPress={onPress} tooltipType={tooltipType} />
<TranscriptToggle onPress={onPress} tooltipType={tooltipType} />
<ToolsToggle onPress={onPress} tooltipType={tooltipType} />
<AdminToggle onPress={onPress} tooltipType={tooltipType} />
</>
)
@@ -28,7 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '../components/RecordingStateToast'
import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
@@ -48,6 +48,12 @@ export const CreateMeetingButton = () => {
setRoom(data.room)
setCallbackId(undefined)
setIsPending(false)
popupManager.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
...data.room,
},
})
}, [data])
useEffect(() => {
@@ -71,8 +77,21 @@ export const CreateMeetingButton = () => {
if (isPending) {
return (
<div>
<Spinner />
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<Spinner size={34} />
<Button
variant="quaternaryText"
square
icon={<RiCloseLine />}
onPress={resetState}
aria-label={t('resetLabel')}
/>
</div>
)
}
@@ -39,6 +39,11 @@ export class PopupManager {
this.messageParent(ClientMessageType.STATE_CLEAR, {})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public sendRoomData(data: { room: any }) {
this.messageParent(ClientMessageType.ROOM_CREATED, data)
}
public setupMessageListener(
onCallbackId: (id: string) => void,
onRoomData: (data: CallbackCreationRoomData) => void
@@ -54,7 +59,7 @@ export class PopupManager {
case PopupMessageType.ROOM_DATA:
if (!data?.room) return
onRoomData(data.room)
this.messageParent(ClientMessageType.ROOM_CREATED, {
this.sendRoomData({
room: {
url: getRouteUrl('room', data.room.slug),
...data.room,
@@ -21,5 +21,13 @@
"several": "",
"open": "",
"accept": ""
},
"transcript": {
"started": "",
"stopped": ""
},
"screenRecording": {
"started": "",
"stopped": ""
}
}
@@ -0,0 +1,24 @@
{
"error": {
"title": "",
"body": ""
},
"expired": {
"title": "",
"body": ""
},
"authentication": {
"title": "",
"body": ""
},
"unsaved": {
"title": "",
"body": ""
},
"success": {
"title": "",
"body": "",
"expiration": "",
"button": ""
}
}
+78 -5
View File
@@ -91,6 +91,10 @@
}
}
},
"info": {
"open": "",
"closed": ""
},
"hand": {
"raise": "",
"lower": ""
@@ -104,7 +108,7 @@
"open": "",
"closed": ""
},
"transcript": {
"tools": {
"open": "",
"closed": ""
},
@@ -169,26 +173,83 @@
"effects": "",
"chat": "",
"transcript": "",
"admin": ""
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
},
"content": {
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"admin": ""
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"moreTools": {
"body": "",
"moreLink": "",
"tools": {
"transcript": {
"title": "",
"body": ""
},
"screenRecording": {
"title": "",
"body": ""
}
}
},
"info": {
"roomInformation": {
"title": "",
"button": {
"ariaLabel": "",
"copy": "",
"copied": ""
}
}
},
"transcript": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
},
"stop": {
"heading": "",
"body": "",
"button": ""
},
"stopping": {
"heading": "",
"body": ""
},
"beta": {
"heading": "",
"body": "",
"button": ""
}
},
"screenRecording": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
},
"stopping": {
"heading": "",
"body": ""
},
"stop": {
"heading": "",
"body": "",
@@ -274,8 +335,20 @@
}
}
},
"recording": {
"label": ""
"recordingBadge": {
"transcript": {
"started": "",
"starting": "",
"stopping": ""
},
"screenRecording": {
"started": "",
"starting": "",
"stopping": ""
},
"any": {
"started": ""
}
},
"participantTileFocus": {
"pin": {
@@ -21,5 +21,13 @@
"several": "Several people want to join this call.",
"open": "Open",
"accept": "Accept"
},
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording."
}
}
@@ -0,0 +1,24 @@
{
"error": {
"title": "Recording unavailable",
"body": "This recording could not be found or was deleted."
},
"expired": {
"title": "Recording expired",
"body": "This recording was deleted on {{date}}."
},
"authentication": {
"title": "Authentication required",
"body": "Please log in to access this recording."
},
"unsaved": {
"title": "Download Unavailable",
"body": "This recording is not ready for download yet. Please try again later."
},
"success": {
"title": "Your recording is ready!",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download"
}
}
+90 -15
View File
@@ -90,6 +90,10 @@
}
}
},
"info": {
"open": "Hide information",
"closed": "Show information"
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
@@ -103,9 +107,9 @@
"open": "Hide everyone",
"closed": "See everyone"
},
"transcript": {
"open": "Hide AI assistant",
"closed": "Show AI assistant"
"tools": {
"open": "Hide more tools",
"closed": "Show more tools"
},
"admin": {
"open": "Hide admin",
@@ -167,31 +171,90 @@
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "AI Assistant",
"admin": "Admin settings"
"transcript": "Transcription",
"screenRecording": "Recording",
"admin": "Admin settings",
"tools": "More tools",
"info": "Meeting information"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages",
"transcript": "AI assistant",
"admin": "Admin settings"
"transcript": "transcription",
"screenRecording": "recording",
"admin": "admin settings",
"tools": "more tools",
"info": "meeting information"
},
"closeButton": "Hide {{content}}"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"moreTools": {
"body": "Access more tools in Visio to enhance your meetings,",
"moreLink": "learn more",
"tools": {
"transcript": {
"title": "Transcription",
"body": "Keep a written record of your meeting."
},
"screenRecording": {
"title": "Recording",
"body": "Record your meeting to watch it again whenever you like."
}
}
},
"info": {
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
}
}
},
"transcript": {
"start": {
"heading": "Start the Assistant!",
"body": "The assistant automatically starts recording your meeting audio (limited to 1 hour). At the end, you'll receive a clear and concise summary of the discussion directly via email.",
"button": "Start"
"heading": "Transcribe this call",
"body": "Automatically transcribe this call and receive the summary in Docs.",
"button": "Start transcription",
"loading": "Transcription starting",
"linkMore": "Learn more"
},
"stop": {
"heading": "Recording in Progress...",
"body": "Your meeting is currently being recorded. You will receive a summary via email once the meeting ends.",
"button": "Stop Recording"
"heading": "Transcription in progress...",
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
"button": "Stop transcription"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"beta": {
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
"button": "Sign up"
}
},
"screenRecording": {
"start": {
"heading": "Record this call",
"body": "Record this call to watch it later and receive the video recording by email.",
"button": "Start recording",
"loading": "Recording starting",
"linkMore": "Learn more"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
}
},
"admin": {
@@ -273,8 +336,20 @@
}
}
},
"recording": {
"label": "Recording"
"recordingStateToast": {
"transcript": {
"started": "Transcribing",
"starting": "Transcription starting",
"stopping": "Transcription stopping"
},
"screenRecording": {
"started": "Recording in progress",
"starting": "Starting recording",
"stopping": "Stopping recording"
},
"any": {
"started": "Recording in progress"
}
},
"participantTileFocus": {
"pin": {
@@ -21,5 +21,13 @@
"several": "Plusieurs personnes souhaitent participer à cet appel.",
"open": "Afficher",
"accept": "Accepter"
},
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
}
}
@@ -0,0 +1,24 @@
{
"error": {
"title": "Enregistrement indisponible",
"body": "Cet enregistrement est introuvable ou a été supprimé."
},
"expired": {
"title": "Enregistrement expiré",
"body": "Cet enregistrement a été supprimé le {{date}}."
},
"authentication": {
"title": "Authentification requise",
"body": "Veuillez vous connecter pour accéder à cet enregistrement."
},
"unsaved": {
"title": "Téléchargement indisponible",
"body": "Cet enregistrement n'est pas encore prêt à être téléchargé. Veuillez réessayer plus tard."
},
"success": {
"title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger"
}
}
+88 -13
View File
@@ -90,6 +90,10 @@
}
}
},
"info": {
"open": "Masquer les informations",
"closed": "Afficher les informations"
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
@@ -103,9 +107,9 @@
"open": "Masquer les participants",
"closed": "Afficher les participants"
},
"transcript": {
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
"tools": {
"open": "Masquer plus d'outils",
"closed": "Afficher plus d'outils"
},
"admin": {
"open": "Masquer l'admin",
@@ -167,30 +171,89 @@
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Assistant IA",
"admin": "Commandes de l'organisateur"
"transcript": "Transcription",
"screenRecording": "Enregistrement",
"admin": "Commandes de l'organisateur",
"tools": "Plus d'outils",
"info": "Informations sur la réunion"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages",
"transcript": "l'assistant IA",
"admin": "Commandes de l'organisateur"
"transcript": "transcription",
"screenRecording": "enregistrement",
"admin": "commandes de l'organisateur",
"tools": "plus d'outils",
"info": "informations sur la réunion"
},
"closeButton": "Masquer {{content}}"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"moreTools": {
"body": "Accèder à d'avantage d'outils dans Visio pour améliorer vos réunions,",
"moreLink": "en savoir plus",
"tools": {
"transcript": {
"title": "Transcription",
"body": "Conservez une trace écrite de votre réunion."
},
"screenRecording": {
"title": "Enregistrement",
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
}
}
},
"info": {
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
}
}
},
"transcript": {
"start": {
"heading": "Démarrer l'assistant !",
"body": "L'assistant démarre automatiquement l'enregistrement sonore de votre réunion (limité à 1h). À la fin, vous recevrez un résumé clair et concis des échanges directement par e-mail.",
"button": "Démarrer"
"heading": "Transcrire cet appel",
"body": "Transcrivez cet appel automatiquement et recevez le compte rendu dans Docs.",
"button": "Démarrer la transcription",
"loading": "Démarrage de la transcription",
"linkMore": "En savoir plus"
},
"stop": {
"heading": "Transcription en cours …",
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
"button": "Arrêter la transcription"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"beta": {
"heading": "Devenez beta testeur",
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Inscrivez-vous"
}
},
"screenRecording": {
"start": {
"heading": "Enregistrer cet appel",
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
"button": "Démarrer l'enregistrement",
"loading": "Démarrage de l'enregistrement",
"linkMore": "En savoir plus"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "L'enregistrement de votre réunion est en cours. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
"button": "Arrêter l'enregistrement"
}
},
@@ -273,8 +336,20 @@
}
}
},
"recording": {
"label": "Enregistrement"
"recordingStateToast": {
"transcript": {
"started": "Transcription en cours",
"starting": "Démarrage de la transcription",
"stopping": "Arrêt de la transcription"
},
"screenRecording": {
"started": "Enregistrement en cours",
"starting": "Démarrage de l'enregistrement",
"stopping": "Arrêt de l'enregistrement"
},
"any": {
"started": "Enregistrement en cours"
}
},
"participantTileFocus": {
"pin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"createMeeting": {
"createButton": "Créer un lien",
"createButton": "Créer un lien Visio",
"joinButton": "Participer avec Visio",
"copyLinkTooltip": "Copier le lien",
"resetLabel": "Réinitialiser",
@@ -21,5 +21,13 @@
"several": "Meerdere mensen willen deelnemen aan dit gesprek.",
"open": "Openen",
"accept": "Accepteren"
},
"transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
}
}
@@ -0,0 +1,24 @@
{
"error": {
"title": "Opname niet beschikbaar",
"body": "Deze opname is niet gevonden of is verwijderd."
},
"expired": {
"title": "Opname verlopen",
"body": "Deze opname is verwijderd op {{date}}."
},
"authentication": {
"title": "Authenticatie vereist",
"body": "Log in om toegang te krijgen tot deze opname."
},
"unsaved": {
"title": "Download niet beschikbaar",
"body": "Deze opname is nog niet klaar om te worden gedownload. Probeer het later opnieuw."
},
"success": {
"title": "Je opname is klaar!",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
"button": "Downloaden"
}
}
+90 -15
View File
@@ -90,6 +90,10 @@
}
}
},
"info": {
"open": "Informatie verbergen",
"closed": "Informatie tonen"
},
"hand": {
"raise": "Hand opsteken",
"lower": "Hand laten zakken"
@@ -103,9 +107,9 @@
"open": "Verberg iedereen",
"closed": "Toon iedereen"
},
"transcript": {
"open": "Verberg AI-assistent",
"closed": "Toon AI-assistant"
"tools": {
"open": "Meer tools verbergen",
"closed": "Meer tools weergeven"
},
"admin": {
"open": "Verberg beheerder",
@@ -167,31 +171,90 @@
"participants": "Deelnemers",
"effects": "Effecten",
"chat": "Berichten in de chat",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"transcript": "Transcriptie",
"screenRecording": "Schermopname",
"admin": "Beheerdersbediening",
"tools": "Meer tools",
"info": "Vergaderinformatie"
},
"content": {
"participants": "deelnemers",
"effects": "effecten",
"chat": "berichten",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"screenRecording": "transcriptie",
"transcript": "schermopname",
"admin": "beheerdersbediening",
"tools": "meer tools",
"info": "vergaderinformatie"
},
"closeButton": "Verberg {{content}}"
},
"chat": {
"disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek."
},
"moreTools": {
"body": "Toegang tot meer tools in Visio om je vergaderingen te verbeteren,",
"moreLink": "lees meer",
"tools": {
"transcript": {
"title": "Transcriptie",
"body": "Bewaar een schriftelijk verslag van je vergadering."
},
"screenRecording": {
"title": "Opname",
"body": "Neem je vergadering op om die later opnieuw te bekijken."
}
}
},
"info": {
"roomInformation": {
"title": "Verbindingsinformatie",
"button": {
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
}
}
},
"transcript": {
"start": {
"heading": "Start de assistent!",
"body": "De assistent begint automatisch de audio van uw vergadering op te nemen (beperkt tot 1 uur). Na afloop krijgt u direct een heldere en beknopte samenvatting van de discussies in uw e-mail.",
"button": "Start"
"heading": "Transcribeer dit gesprek",
"body": "Transcribeer dit gesprek automatisch en ontvang het verslag in Docs.",
"button": "Transcriptie starten",
"loading": "Transcriptie begint",
"linkMore": "Meer informatie"
},
"stop": {
"heading": "Opname loopt ...",
"body": "Uw vergadering wordt momenteel opgenomen. U ontvangt een samenvatting via e-mail, zo gauw de vergarding gesloten wordt.",
"button": "Stop met opname"
"heading": "Transcriptie bezig...",
"body": "De transcriptie van uw vergadering is bezig. U ontvangt het resultaat per e-mail zodra de vergadering is afgelopen.",
"button": "Transcriptie stoppen"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"beta": {
"heading": "Word betatester",
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
"button": "Aanmelden"
}
},
"screenRecording": {
"start": {
"heading": "Dit gesprek opnemen",
"body": "Neem dit gesprek op om het later terug te kijken. Je ontvangt de video-opname per e-mail.",
"button": "Opname starten",
"loading": "Opname gestarten",
"linkMore": "Meer informatie"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"stop": {
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
"button": "Opname stoppen"
}
},
"admin": {
@@ -273,8 +336,20 @@
}
}
},
"recording": {
"label": "Opnemen"
"recordingStateToast": {
"transcript": {
"started": "Transcriptie bezig",
"starting": "Transcriptie begint",
"stopping": "Transcriptie stopt"
},
"screenRecording": {
"started": "Opname bezig",
"starting": "Opname starten",
"stopping": "Opname stoppen"
},
"any": {
"started": "Opname bezig"
}
},
"participantTileFocus": {
"pin": {

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