Compare commits

...

92 Commits

Author SHA1 Message Date
Sylvain Zimmer c7fdd7c317 🚀(paas) add PaaS deployment scripts, tested on Scalingo 2025-08-08 10:40:12 +02:00
lebaudantoine 201069aa4c ♻️(frontend) refactor clipboard logic into dedicated reusable hook
Extract clipboard content logic from UI components into a separate
custom hook to decouple interface elements from clipboard functionality.

Creates a reusable hook that can better adapt to future UX changes
without requiring modifications to individual UI components.
2025-08-07 12:51:03 +02:00
lebaudantoine b6a5b1a805 🎨(frontend) remove space between PIN digits and # symbol
Update PIN formatting to remove space before the # symbol to clearly
indicate that # is part of the complete PIN code.

Improves user understanding that the hash symbol is an integral part
of the PIN entry sequence, not a separate element.
2025-08-07 12:51:03 +02:00
lebaudantoine f3af637fd6 (frontend) add telephony info to encourage phone participation
Add telephony information to the share dialog when available to help
users take advantage of the newly introduced phone join feature.

Promotes phone participation as an alternative connection method when
users need it, improving meeting accessibility and user adoption of
telephony capabilities.
2025-08-07 12:51:03 +02:00
lebaudantoine de3a5aa404 💄(frontend) update secondaryText button variant to medium font weight
Change secondaryText button style from default to medium font weight
for improved visual comfort and better readability.

I haven't tested this change with Marianne.
2025-08-07 12:51:03 +02:00
lebaudantoine 5e9d20e685 🐛(frontend) fix public room warning showing for all rooms
Remove incorrect public room warning that was always displayed
regardless of room privacy settings.

Warning should only appear for genuinely public rooms since the lobby
system introduction changed room privacy behavior.

Prevents user confusion about room privacy settings.
2025-08-07 12:51:03 +02:00
lebaudantoine b54445739a (frontend) add telephony info to meeting dialog with layout stability
Add telephony information display to the later meeting dialog while
preserving existing layout when telephony is disabled.

Prevent layout shift on modal close by collapsing all modal content
immediately when room becomes undefined.

Critical enhancement for users creating meeting links to have complete
connection information available.
2025-08-07 12:51:03 +02:00
lebaudantoine 7c67bacd94 🚸(frontend) fix locale issues and improve meeting dialog copywriting
Remove ProConnect mentions from frontend locale and enhance the meeting
dialog description text to clearly explain that meeting links are
permanent and persistent.

Improves user understanding of meeting link permanence and cleans up
branding references.
2025-08-07 12:51:03 +02:00
lebaudantoine 1fd1b184ee 💬(frontend) update wording from "room address" to "room information"
Change UI text to use "room information" instead of "room address"
to better reflect the functionality whether copying just the meeting
link or complete meeting details.
2025-08-07 12:51:03 +02:00
lebaudantoine adb517a336 🎨(frontend) harmonize room info sidepanel display with meeting dialog
Remove protocol prefix from room URLs in the information sidepanel to
match the syntax used in the meeting dialog.

This creates consistent URL display formatting across both UI components
for better user experience.
2025-08-07 12:51:03 +02:00
lebaudantoine eec9ff9f26 🚸(frontend) fix copy to include all meeting info from side panel
Rework clipboard functionality to copy complete meeting information when
users click the copy button in the information side panel.

Previously only partial data was copied, causing user confusion. Now
includes all relevant meeting details as expected.

Improves user experience by meeting user expectations for copy behavior.
2025-08-07 12:51:03 +02:00
lebaudantoine e0258a1765 🔧(tilt) configure telephony in tilt stack for development
Add default telephony configuration to the tilt stack to enable
development workflow around authentication features.

Note: This is a fake/mock configuration and is not functional for
production use. It's intended solely for development purposes.
2025-08-07 12:51:03 +02:00
lebaudantoine 872ce1ecc6 (frontend) add icon support to select primitive component
Enhance select component by adding icon display capability for better
visual representation of options.
2025-08-01 17:45:48 +02:00
lebaudantoine e2c3b745ca 🩹(frontend) avoid video glitch on the prejoin while init a processor
We've introduced simplifications that improve performance, enhance ux,
and contribute to the overall perception of experience quality.

Previously, our processor handling was overly complex. LiveKit allows us to set
a processor before starting the local video preview track, which eliminates
the black blink glitch that appeared when loading the join component
with a default processor.

This change prevents the unnecessary stopping and restarting
of the local video track.

I'm glad this issue is now resolved.

We also simplified component communication by avoiding props drilling.
Now, we use a single flag to indicate when the user is ready to enter the room.
This significantly reduces the complexity of props passed through components.
2025-08-01 17:40:11 +02:00
lebaudantoine 965d823d08 (frontend) display position of a raised hand in the queue
Users requested an enhanced visual indicator
for raised hands on the participant tile.

Most major video conferencing platforms display the position of a raised hand
in the queue. This helps hosts quickly see who is requesting to speak,
and in what order, without needing to open the full participant list.

While a minor feature, this improvement is especially valuable for power user
2025-08-01 16:54:24 +02:00
lebaudantoine 1db189ace2 🚸(frontend) sort raised hand by order of arrival
Previously, the participant list was sorted alphabetically by name.
This unintentionally affected the raised hands list,
which inherited the same sorting behavior.

Users requested that raised hands be sorted by order of arrival.
This simple change improves the UX by ensuring that raised hands
are displayed in the correct order.
2025-08-01 16:54:24 +02:00
lebaudantoine 199e0908e9 ♻️(frontend) refactor raised hand to rely on participant's attributes
Previously managed participant hand raised using raw metadata. LiveKit
introduced attributes concept, saves as metadata under hood but makes
update/handling easier.

Still pain that attributes must be strings, cannot pass boolean…

Refactor whole app to use attributes instead of metadata for the raised
hand feature. This commit might introduce regressions. I've double
checked participant list side pannel and the notification features.

Previously I persisted a boolean, I now persist the timestamp at which
the hand was raised. This will be useful in upcoming commits, especially
for sorting raised hands by order of arrival.
2025-08-01 16:54:24 +02:00
Jacques ROUSSEL 8518f83211 (helm) add the ability to configure tls secretName
Yesterday during a deployment, we were unable to configure the tls
secretName for ingress.
2025-08-01 16:53:36 +02:00
lebaudantoine 0240d85837 📝(backend) add Firefox proxy workaround parameter to installation guide
Document LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND setting that enables
connection warmup by pre-calling WebSocket endpoint to resolve proxy
connectivity issues.
2025-08-01 16:23:22 +02:00
lebaudantoine 162896c93c 🩹(backend) allow enforcing WSS protocol to resolve browser compatibility
The LiveKit API URL is necessary to interact with the API. It uses https
protocol.

Eplicit wss protocol is necessary in Websocket constructor for some
older browsers.

This resolves critical compatibility issues with legacy browsers
(notably Firefox <124, Chrome <125, Edge <125) that lack support
for HTTPS URLs in the WebSocket() constructor. Without explicit WSS
URLs, WebSocket signaling connections may fail, crash, or be blocked
entirely in these environments.

The setting is optional and defaults to the current behavior when
not specified, ensuring zero breaking changes for existing deployments.
2025-08-01 16:23:22 +02:00
Jacques ROUSSEL 483a219ac4 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 16:14:55 +02:00
lebaudantoine 1b26dea178 🐛(frontend) use feature detection for adaptiveStream and dynacast
Replace hardcoded true values with supportsAdaptiveStream() and
supportsDynacast() checks. LiveKit SDK supports broad browser range but
requires specific APIs - modern features need explicit compatibility checks.

Prevents enabling unsupported WebRTC features on incompatible browsers,
which could led to a poor user experience.

One alternative solution could be to install polyfills.
2025-08-01 15:46:17 +02:00
lebaudantoine bdaf4245da 🔖(minor) bump release to 0.1.33
Warmup with WebSocket pre-authentication on FF
2025-07-25 08:50:33 +02:00
lebaudantoine be63993ba2 🩹(frontend) fix connection warmup with WebSocket pre-authentication
Connection warmup wasn't working properly - only works when trying to
establish WebSocket first, then workaround kicks in. Call WebSocket
endpoint without auth info expecting 401 error, but enough to initiate
cache for subsequent WebSocket functionality.

Scope this **dirty** trick to Firefox users only. Haven't figured out
how to detect proxy from JS code simply.

Tested in staging and works on our constrained WiFi.
2025-07-25 08:50:33 +02:00
lebaudantoine 3d245c3bd4 🔖(minor) bump release to 0.1.32
warmup livekit connection.
2025-07-24 14:57:48 +02:00
lebaudantoine 66a36eff73 ♻️(frontend) refactor connection warmup to use LiveKit methods
Replace custom connection warmup implementation with LiveKit's built-in
methods for better reliability and consistency.
2025-07-24 14:49:49 +02:00
lebaudantoine 387bc2e1f4 🐛(frontend) add LiveKit connection warmup for Firefox+proxy fixes
Implement HTTPS prefetch before joining rooms to resolve WebSocket
handshake failures where Firefox+proxy returns HTTP 200 instead of 101.

Reproduced locally with Squid container. No proxy configuration fixes
found - HTTPS warmup is only working workaround. Issue doesn't occur
when signaling server shares webapp domain, making warmup unnecessary.

Use HEAD request to minimize bandwidth.
2025-07-24 14:32:51 +02:00
lebaudantoine d44b45b6aa 🐛(frontend) prevent shortcut handling when key is undefined
Add null check to avoid processing keyboard shortcuts with undefined
key values that could cause errors.

Exception caught in PostHog
2025-07-23 12:06:39 +02:00
lebaudantoine 224b98fd9a 🧪(frontend) capture LiveKit exceptions in PostHog for debugging
Test tracking signaling failures to determine root causes when connection
fails. Will remove if it spams analytics - goal is understanding failure
patterns and reasons.
2025-07-23 12:06:39 +02:00
lebaudantoine 031852d0b1 🔖(minor) bump release to 0.1.31
Fix noise reduction feature flag
2025-07-21 12:00:06 +02:00
lebaudantoine 24915b0485 🐛(frontend) fix wrong feature flag for noise reduction
Replace incorrect faceLandmarks flag with proper noiseReduction flag
to ensure correct feature availability checking.
2025-07-21 10:29:36 +02:00
ericboucher 0862203d5d 🚸(frontend) add Safari warning for unavailable speaker settings
Display notice explaining that missing browser settings are due to Safari
limitations, not app bugs, to prevent user confusion.
2025-07-20 18:35:32 +02:00
ericboucher ee604abe00 🐛(frontend) replace useSize with useMediaQuery in account settings
Fix buggy layout transitions on Safari mobile by using media queries
instead of size detection for smoother settings panel responsive behavior.
2025-07-20 18:35:32 +02:00
lebaudantoine 26d668b478 🐛(frontend) prevent Crisp crash when superuser has no email
Fix crash when switching from admin session to app with superuser account
that lacks email field. Add null check to prevent Crisp initialization
errors.
2025-07-20 18:00:08 +02:00
lebaudantoine 04081f04fc 🌐(frontend) internationalize missing error message
Add translation support for previously untranslated error message to
complete localization coverage.
2025-07-20 18:00:08 +02:00
ericboucher f7268c507b 🚸(frontend) display email with username to clarify logged-in account
Show email alongside full name when available to prevent user confusion
about which account is currently logged in. Enhances general app UX.
2025-07-20 18:00:08 +02:00
lebaudantoine cadb20793a 🔖(minor) bump release to 0.1.30
various fixes
2025-07-18 11:48:02 +02:00
lebaudantoine 8a417806e4 🐛(backend) fix lobby notification type error breaking participant alerts
Correct data type issue that was preventing lobby notifications from
being sent to other participants in the room.
2025-07-18 11:42:43 +02:00
lebaudantoine 223c744e3f 🚨(summary) lint celery_config module to pass ruff checks
Apply code formatting and style fixes to celery configuration
module to meet linting standards.
2025-07-18 11:29:13 +02:00
lebaudantoine f67335f306 🐛(summary) fix Celery task execution by number of required args
Forgot to update the args verification while enriching the task
with more recording metadata.
2025-07-18 11:29:13 +02:00
lebaudantoine 4eb7f29f8e 🔊(summary) add Celery exporter configuration for monitoring
Enable Celery task lifecycle events and broker dispatch events per
@rouja's exporter requirements. Basic configuration following
documentation without parameterization.
2025-07-17 23:53:09 +02:00
lebaudantoine 912bac8756 🔖(minor) bump release to 0.1.29
What:

- fix minor issue on summary microservice
- PKCE (oidc) support
- add limitation on recording if supported

and more.
2025-07-17 20:41:29 +02:00
lebaudantoine 3c97418a70 🐛(summary) fix Celery task execution by switching to apply_async()
Replace delay() with apply_async() to resolve invalid arguments error.
2025-07-17 20:41:29 +02:00
lebaudantoine 26a90456f7 🚸(frontend) add alert for media devices already in use
Show explicit warning when microphone/camera are occupied by other
applications. Helps users understand permission failures and reminds
them to close other video conferencing apps.

Spotted issue through Crisp support - users often forget to quit other
webconfs that don't auto-disconnect when alone.
2025-07-17 20:41:29 +02:00
lebaudantoine d068558c8f 🚸(frontend) explain duplicate identity disconnect on feedback page
Add context to feedback page when user is disconnected for joining with
same identity, which is forbidden by LiveKit. Improves user understanding
of disconnection reasons.
2025-07-17 20:41:29 +02:00
lebaudantoine dac6bfe142 ♻️(frontend) refactor feedback redirect to global onDisconnect hook
Centralize disconnect handling to ensure all client-initiated disconnects
trigger feedback page navigation. More extensible for future disconnect
event routing needs, especially when errors happen.
2025-07-17 17:41:04 +02:00
lebaudantoine 6b3e5d747a 📝(backend) add OIDC PKCE parameters to installation documentation
Document PKCE-related configuration options for OpenID Connect setup
in installation guide for proper authentication flow.
2025-07-16 15:17:39 +02:00
K900 3066e3a83c 🔒️(backend) add environment variables for PKCE settings
Defaulting to off for now to keep compatibility.
2025-07-16 14:52:44 +02:00
lebaudantoine 5e05b6b2a5 ⬆️(frontend) bump libphonenumber-js dependency
Update phone number formatting library to latest version for bug fixes
and improved international number handling.
2025-07-16 14:47:24 +02:00
lebaudantoine d075d60d19 🚸(frontend) notify all participants when recording/transcription stops
Broadcast limit-reached notifications to all room participants, not just
owner, to ensure everyone knows recording has stopped due to duration
limits.
2025-07-16 14:47:24 +02:00
lebaudantoine 7c631bb76f 🚸(frontend) alert room owner when recording exceeds max duration
Display notification to prevent silent recording failures. Shows
configured max duration in proper locale if backend provides the limit.
Prevents users from missing recording termination.
2025-07-16 14:47:24 +02:00
lebaudantoine 59cd1f766a (backend) add egress limit notification handler to LiveKit service
Implement method to process egress limit reached events from LiveKit
webhooks for better recording duration management.

Livekit by default is not notifying the participant of a room when
an egress reached its limit. I needed to proxy it through the back.
2025-07-16 14:47:24 +02:00
lebaudantoine f0a17b1ce1 (backend) add dedicated service for LiveKit recording webhook events
Create new service to handle recording-related webhooks, starting with
limit reached events. Will expand to enhance UX by notifying backend
of other LiveKit events.

Doesn't fit cleanly with existing recording package - may need broader
redesign. Chose dedicated service over mixing responsibilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 17c486f7bf ♻️(backend) extract notify_participant to util function
Move from lobby service to utils for reuse across services. Method is
generic enough for utility status. Future: create dedicated LiveKit
service to encapsulate all LiveKit-related utilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 85bde9633f 🔧(frontend) pass recording max duration to frontend for user alerts
Send backend recording duration limit to frontend to display warning
messages when recordings approach or reach maximum allowed length.

This configuration needs to be synced with the egres. I chose to keep
this duration in ms to be consistent with other settings.
2025-07-16 14:47:24 +02:00
Charles Hall 0eb715b0cd 📝(doc) document the oidc redirect uri 2025-07-14 16:58:50 +02:00
Charles Hall b0617dcfed 📝(doc) update project name in installation prose
References relating to k8s and things remain unchanged, because that's
probably going to be more involved to make sure such changes are
accurate.
2025-07-14 16:58:50 +02:00
lebaudantoine 6c4c44e933 (summary) enhance transcription document naming with room context
Add optional room name, recording time and date to generate better
document names based on user feedback. Template is customizable for
internationalization support.
2025-07-11 15:40:12 +02:00
lebaudantoine 16dde229cc 🚨(summary) lint analytics sources
Fix formatting issues.
2025-07-11 15:40:12 +02:00
lebaudantoine d01d6dd9d1 🚸(backend) clarify link sharing limitations in recording email
Add notice to email notifications that recording link sharing is not
supported in beta to prevent user confusion.
2025-07-11 14:14:02 +02:00
lebaudantoine 2a39978245 🚸(frontend) add beta warning about recording link sharing limitations
Display notice on recording page that link sharing is not available in
beta to prevent user confusion and set proper expectations.
2025-07-11 14:14:02 +02:00
lebaudantoine d85ed031a9 🚸(frontend) improve error messaging for recording access failures
Clarify that recording links are not shareable when users cannot load
recordings. Previous error messages were unclear about access restrictions.
2025-07-11 14:14:02 +02:00
lebaudantoine 5c8c81f97b 🐛(frontend) fix infinite loading for unauthenticated users on downloads
Reorganize exception handling in recording download screen to prevent
unauthenticated users from getting stuck in loading state. Caught by
production users - my bad;
2025-07-11 14:14:02 +02:00
lebaudantoine 3368a9b6af 🔧(summary) extract hardcoded title to configurable setting
Replace hardcoded title with configurable option to enable custom
branding for different deployments of the microservice.
2025-07-11 11:38:28 +02:00
lebaudantoine e87d0519ff 🔧(summary) make audio duration limit optional for deployment safety
Allow deploying without breaking changes while frontend implements proper
egress limit handling. Duration restriction can be enabled when ready.
2025-07-11 11:38:28 +02:00
lebaudantoine 731f0471aa ♻️(summary) rename TaskTracker to MetadataManager for clarity
Update class name to better reflect its responsibility for handling task
metadata rather than tracking tasks themselves.
2025-07-11 11:38:28 +02:00
lebaudantoine e458272745 🐛(summary) fix data type serialization issues with Redis and PostHog
Resolve float/int to string conversion problems when deserializing Redis
data for PostHog. Added type conversion fix - not bulletproof but works
for most cases. Avoid using for critical operations.
2025-07-11 11:38:28 +02:00
lebaudantoine d71e417d58 📝(summary) replace "wip" placeholder with proper docstring
Add proper function documentation to replace temporary placeholder
committed in previous hasty commit.
2025-07-11 11:38:28 +02:00
lebaudantoine d91f343ba9 🔖(minor) bump release to 0.1.28
telephony frontend + backend
source map
enhance analytics on the summary microservice
2025-07-10 22:25:07 +02:00
lebaudantoine 4fc0744433 🐛(summary) fix minor Python typing issue
Correct type annotation to resolve static type checking warnings.
2025-07-10 18:13:32 +02:00
lebaudantoine 93e3f05348 🔧(backend) make PostHog event names configurable for transcript tracking
Add setting to customize PostHog event names when tracking transcript
results for flexible analytics configuration.
2025-07-10 18:13:32 +02:00
lebaudantoine cbabcb877b 🐛(summary) add audio duration limit to prevent long-running jobs
Set default 1h30 limit for audio processing to prevent Whisper from
running excessively long on large recordings. Improves resource
management and job completion times.
2025-07-10 18:13:32 +02:00
lebaudantoine 30cd6573ef 🔧(backend) refine Celery retry policy to HTTP errors only
Only retry jobs on HTTP errors (docs push failures) rather than all
errors. Skip retries for Whisper processing failures as they won't
succeed on retry.
2025-07-10 18:13:32 +02:00
lebaudantoine 921d69031e 🔊(summary) increase logging level to info for Celery worker insights
Change logging levels to provide more visibility into Celery worker
operations and task processing for better monitoring.
2025-07-10 18:13:32 +02:00
lebaudantoine 1716e11900 🔧(summary) configure OpenAI max retries via FastAPI settings
Set default OpenAI retries to 0 and add configurable setting to control
retry behavior for API requests in summary service.
2025-07-10 18:13:32 +02:00
lebaudantoine 6e55013b15 📈(summary) kickstart analytics tracking in summary microservice
Add product analytics to understand summary feature usage and behavior.
Track transcript and task metadata for insights without exposing sensitive
content or speaker data.

Hacky but functional PostHog usage - fully optional for self-hosting.
Extensive tracking approach works for current needs despite not being
PostHog's typical use case.
2025-07-10 18:13:32 +02:00
lebaudantoine 21bed40484 (summary) add mutagen library for audio file duration detection
Install mutagen to analyze audio file metadata and determine recording
length for summary processing.
2025-07-10 18:13:32 +02:00
lebaudantoine 5959e82bf0 (summary) install PostHog in summary microservice
Add PostHog analytics integration to summary service for error
tracking and usage monitoring capabilities.
2025-07-10 18:13:32 +02:00
lebaudantoine b5113580b3 📸(frontend) inject PostHog sourcemap ID in chunks during build
Add PostHog CLI step to inject proper IDs into chunks, enabling error
tracking to map exceptions back to original source code locations
via sourcemaps.
2025-07-08 15:56:26 +02:00
lebaudantoine 053e4bc7b9 🔧(frontend) add conditional sourcemap generation for error tracking
Enable sourcemaps via env variable to link Sentry/PostHog exceptions to
source code. Enable by default for DINUM frontend image to improve
debugging capabilities.
2025-07-08 15:56:26 +02:00
lebaudantoine 9e57c25a25 🌐(frontend) fix wrong plural in footer
Accessibility should be singular.
2025-07-08 15:30:21 +02:00
lebaudantoine dfbcbed485 🐛(tilt) enable Keycloak in DINUM Tilt stack configuration
Fix missing Keycloak service in tilt-dinum stack. Error went unnoticed
when switching from tilt-keycloak due to pods not being cleaned between
stack changes.
2025-07-08 14:39:01 +02:00
lebaudantoine 9c28e1b266 📝(readme) document BrowserStack free license usage in README
Add note about using BrowserStack's free open-source license for
cross-browser testing to clarify testing infrastructure.
2025-07-07 19:45:43 +02:00
lebaudantoine 2a586445b5 📝(readme) update feature status in README with recent developments
Reflect current implementation status of features that have been completed
or are in progress to keep documentation accurate.
2025-07-07 19:45:43 +02:00
lebaudantoine 75ffb7f5f7 (frontend) add phone number and pin display in info side panel
Create minimal UI to show telephony access details for testing SIP
functionality in staging deployment. Basic implementation for feature
validation.
2025-07-07 19:21:39 +02:00
lebaudantoine 6a1c85809d (frontend) add libphonenumber-js for phone number formatting
Install JavaScript implementation of Google's libphonenumber for proper
international phone number validation and formatting.
2025-07-07 19:21:39 +02:00
lebaudantoine 70d250cc9c 🔧(frontend) add telephony feature configuration from backend
Pass telephony service availability and settings to frontend to enable
conditional UI rendering based on SIP functionality status.
2025-07-07 19:21:39 +02:00
lebaudantoine 988e5aa256 (backend) add telephony service for automatic SIP dispatch rules
Implemented a service that automatically creates a SIP dispatch rule when
the first WebRTC participant joins a room and removes it when the room
becomes empty.

Why? I don’t want a SIP participant to join an empty room.
The PIN code could be easily leaked, and there is currently no lobby
mechanism available for SIP participants.

A WebRTC participant is still required to create a room.
This behavior is inspired by a proprietary tool. The service uses LiveKit’s
webhook notification system to react to room lifecycle events. This is
a naive implementation that currently supports only a single SIP trunk and
will require refactoring to support multiple trunks. When no trunk is
specified, rules are created by default on a fallback trunk.

@rouja wrote a minimal Helm chart for LiveKit SIP with Asterisk, which
couldn’t be versioned yet due to embedded credentials. I deployed it
locally and successfully tested the integration with a remote
OVH SIP trunk.

One point to note: LiveKit lacks advanced filtering capabilities when
listing dispatch rules. Their recommendation is to fetch all rules and
filter them within your backend logic. I’ve opened a feature request asking
for at least the ability to filter dispatch rules by room, since filtering
by trunk is already supported, room-based filtering feels like a natural
addition.

Until there's an update, I prefer to keep the implementation simple.
It works well at our current scale, and can be refactored when higher load
or multi-trunk support becomes necessary.

While caching dispatch rule IDs could be a performance optimization,
I feel it would be premature and potentially error-prone due to the complexity
of invalidation. If performance becomes an issue, I’ll consider introducing
caching at that point. To handle the edge case where multiple dispatch rules
with different PIN codes are present, the service performs an extensive
cleanup during room creation to ensure SIP routing remains clean and
predictable. This edge case should not happen.

In the 'delete_dispatch_rule' if deleting one rule fails, method would exit
without deleting the other rules. It's okay IMO for a first iteration.
If multiple dispatch rules are often found for room, I would enhance this part.
2025-07-07 19:21:39 +02:00
lebaudantoine d3178eff5d (backend) serialize room pin code for frontend access
Add pin code to API response to enable frontend display of room access
codes. UI implementation will follow in upcoming commits.
2025-07-07 19:21:39 +02:00
lebaudantoine 9c840a4e06 🐛(frontend) revert || to ?? operator for metadata parsing
Fix broken parsing when metadata contains empty strings. Nullish coalescing
operator (??) doesn't handle empty strings like logical OR (||) does.
2025-07-07 19:21:39 +02:00
renovate[bot] e6754b49e0 ⬆️(dependencies) update js dependencies 2025-07-07 15:14:15 +02:00
lebaudantoine ec586eaab4 🔥(backend) remove outdated GitHub workflow
Clean up CI/CD by deleting obsolete workflow file that is no longer
needed or maintained since we deploy project through private repo.
2025-07-07 11:02:50 +02:00
133 changed files with 5165 additions and 3315 deletions
-35
View File
@@ -1,35 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+2
View File
@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+3 -2
View File
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording (coming soon)
- Meeting recording
- Meeting transcription (currently in beta)
- Telephony integration (in development)
- Telephony integration
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
@@ -111,6 +111,7 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
## License
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
mv src/backend/* ./
mv src/nginx/* ./
echo "3.13" > .python-version
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b :8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+7 -1
View File
@@ -11,15 +11,21 @@ RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
### ---- Front-end builder image ----
# ---- Front-end builder image ----
FROM frontend-deps AS meet-builder
WORKDIR /home/frontend
ENV VITE_APP_TITLE="Visio"
ENV VITE_BUILD_SOURCEMAP="true"
RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
-3
View File
@@ -35,9 +35,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+128 -123
View File
@@ -1,6 +1,6 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
## Prerequisites for a kubernetes setup
@@ -114,7 +114,7 @@ Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, excep
### What will you use to authenticate your users ?
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
@@ -134,6 +134,8 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
From here the important information you will need are :
```
@@ -152,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis :
@@ -194,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
### Find postgresql connexion values
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -215,14 +217,11 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
@@ -243,123 +242,129 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
| ----------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+13
View File
@@ -41,6 +41,19 @@ def get_frontend_configuration(request):
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+2 -2
View File
@@ -107,8 +107,8 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level"]
read_only_fields = ["id", "slug"]
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def to_representation(self, instance):
"""
@@ -136,6 +136,13 @@ class NotificationService:
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
}
headers = {
@@ -0,0 +1,49 @@
"""Recording-related LiveKit Events Service"""
from logging import getLogger
from core import models, utils
logger = getLogger(__name__)
class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
+84 -3
View File
@@ -1,13 +1,25 @@
"""LiveKit Events Service"""
# pylint: disable=E1101
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
@@ -77,6 +89,8 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -108,10 +122,77 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError("Failed to process room finished event") from e
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
+8 -56
View File
@@ -1,6 +1,5 @@
"""Lobby Service"""
import json
import logging
import uuid
from dataclasses import dataclass
@@ -11,13 +10,6 @@ from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils
logger = logging.getLogger(__name__)
@@ -46,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass
class LobbyParticipant:
"""Participant in a lobby system."""
@@ -211,9 +199,6 @@ class LobbyService:
Create a new participant entry in waiting status and notify room
participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
"""
color = utils.generate_color(participant_id)
@@ -226,10 +211,15 @@ class LobbyService:
)
try:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
# If room not created yet, there is no participants to notify
pass
logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
@@ -334,44 +324,6 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
+124
View File
@@ -0,0 +1,124 @@
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
SIPDispatchRule,
SIPDispatchRuleDirect,
)
from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
return f"SIP_{str(room_id)}"
@async_to_sync
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.id), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.id)
)
lkapi = utils.create_livekit_client()
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
async def _list_dispatch_rules_ids(self, room_id):
"""List SIP dispatch rule IDs for a specific room.
Fetches all existing SIP dispatch rules and filters them by room name
since LiveKit API doesn't support server-side filtering by 'room_name'.
This approach is acceptable for moderate scale but may need refactoring
for high-volume scenarios.
Note:
Feature request for server-side filtering: livekit/sip#405
"""
lkapi = utils.create_livekit_client()
try:
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
list=ListSIPDispatchRuleRequest()
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
if not existing_rules or not existing_rules.items:
return []
rule_name = self._rule_name(room_id)
return [
existing_rule.sip_dispatch_rule_id
for existing_rule in existing_rules.items
if existing_rule.name == rule_name
]
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
if not rules_ids:
logger.info("No dispatch rules found for room %s", room_id)
return False
if len(rules_ids) > 1:
logger.error("Multiple dispatch rules found for room %s", room_id)
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
@@ -0,0 +1,72 @@
"""
Test RecordingEventsService service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Initialize RecordingEventsService."""
return RecordingEventsService()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode=mode)
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
# Mock external service calls to isolate the test
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
# Make request as a new anonymous user
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
@@ -255,7 +255,7 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
@@ -315,7 +315,7 @@ def test_request_entry_waiting_participant_public_room(settings):
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with (
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -251,6 +257,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -295,6 +302,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -324,6 +332,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -372,6 +381,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -458,6 +468,7 @@ def test_api_rooms_retrieve_administrators(
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -143,7 +143,7 @@ def test_handled_event_type(
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "room_started"})
event_data = json.dumps({"event": "participant_joined"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
@@ -1,13 +1,16 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212
# pylint: disable=W0621,W0613, W0212, E0611
import uuid
from unittest import mock
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
@@ -17,6 +20,8 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -53,45 +58,253 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-2"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
with pytest.raises(
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
assert recording.status == "stopped"
@mock.patch.object(LobbyService, "clear_room_cache")
def test_handle_room_finished(mock_clear_cache, service):
"""Should clear lobby cache when room is finished."""
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
def test_handle_room_finished_error(mock_clear_cache, service):
"""Should raise ActionFailedError when processing fails."""
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
expected_error = (
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(
uuid.UUID("00000000-0000-0000-0000-000000000000")
)
def test_handle_room_finished_invalid_room_name(service):
"""Should raise ActionFailedError when processing fails."""
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
"delete_dispatch_rule",
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
expected_error = f"Room with ID {mock_data.room.name} does not exist"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
+7 -117
View File
@@ -5,7 +5,6 @@ Test lobby service.
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import json
import uuid
from unittest import mock
@@ -14,18 +13,17 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant,
LobbyParticipantNotFound,
LobbyParticipantParsingError,
LobbyParticipantStatus,
LobbyService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
@mock.patch("core.utils.notify_participants")
def test_enter_success(
mock_notify,
mock_generate_color,
@@ -443,13 +441,15 @@ def test_enter_success(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(room_id=room.id)
mock_notify.assert_called_once_with(
room_name=str(room.id), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
@mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = LobbyNotificationError("Error notifying")
mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -776,116 +776,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
@@ -0,0 +1,305 @@
"""
Test telephony service.
"""
# pylint: disable=W0212
from unittest import mock
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
ListSIPDispatchRuleResponse,
SIPDispatchRule,
SIPDispatchRuleInfo,
)
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
def create_mock_livekit_client():
"""Factory for creating LiveKit client mock."""
mock_api = mock.Mock()
mock_api.sip = mock.Mock()
mock_api.aclose = mock.AsyncMock()
return mock_api
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-3",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
assert "rule-3" in result
assert "rule-2" not in result
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
assert isinstance(list_request, ListSIPDispatchRuleRequest)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
assert delete_request.sip_dispatch_rule_id == "rule-1"
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
deleted_rule_ids = [
call_args[1]["delete"].sip_dispatch_rule_id
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
]
assert all(
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
)
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
call_count = 0
def delete_side_effect(*args, **kwargs):
nonlocal call_count
if call_count == 0:
call_count += 1
return None
raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
+107 -1
View File
@@ -2,9 +2,13 @@
Test utils functions
"""
import json
from unittest import mock
from core.utils import create_livekit_client
import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
@mock.patch("asyncio.get_running_loop")
@@ -60,3 +64,105 @@ def test_create_livekit_client_custom_configuration(
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""Test participant notification with API error."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client):
"""Test successful participant notification."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
+43 -1
View File
@@ -15,7 +15,15 @@ from django.core.files.storage import default_storage
import aiohttp
import botocore
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
VideoGrants,
)
def generate_color(identity: str) -> str:
@@ -158,3 +166,37 @@ def create_livekit_client(custom_configuration=None):
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
class NotificationError(Exception):
"""Notification delivery to room participants fails."""
@async_to_sync
async def notify_participants(room_name: str, notification_data: dict):
"""Send notification data to all participants in a LiveKit room."""
lkapi = create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=room_name,
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
Binary file not shown.
+32 -23
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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"
@@ -29,15 +29,15 @@ msgstr "Berechtigungen"
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:143
#: core/admin.py:147
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:146
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:63
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
@@ -251,7 +251,8 @@ msgstr "PIN-Code für den Raum"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
msgid "Room"
@@ -366,9 +367,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
@@ -394,33 +395,41 @@ msgstr ""
msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -429,18 +438,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "Englisch"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "Französisch"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:165
#: meet/settings.py:166
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+31 -22
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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"
@@ -29,15 +29,15 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:143
#: core/admin.py:147
msgid "No owner"
msgstr "No owner"
#: core/admin.py:146
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:63
#: core/api/serializers.py:67
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."
@@ -361,9 +361,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
@@ -389,33 +389,42 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
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
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
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
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -424,18 +433,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "English"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "French"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
#: meet/settings.py:166
msgid "German"
msgstr "German"
Binary file not shown.
+36 -28
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,15 +29,15 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:143
#: core/admin.py:147
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:146
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:63
#: core/api/serializers.py:67
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 "
@@ -138,8 +138,8 @@ msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:144
msgid "sub"
@@ -252,7 +252,8 @@ msgstr "Code PIN de la salle"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
msgid "Room"
@@ -271,9 +272,8 @@ 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 Worker. Cet identifiant "
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
"facile."
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:573
msgid "Recording mode"
@@ -367,9 +367,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
@@ -395,33 +395,41 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
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
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
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
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -430,18 +438,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "Anglais"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "Français"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
#: meet/settings.py:166
msgid "German"
msgstr "Allemand"
Binary file not shown.
+32 -23
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
"POT-Creation-Date: 2025-07-11 11:33+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"
@@ -29,15 +29,15 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:143
#: core/admin.py:147
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:146
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:63
#: core/api/serializers.py:67
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."
@@ -247,7 +247,8 @@ msgstr "Pincode van de kamer"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
msgid "Room"
@@ -361,9 +362,9 @@ 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/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
@@ -389,33 +390,41 @@ msgstr ""
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
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
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
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
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
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
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
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
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
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -424,18 +433,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:162
#: meet/settings.py:163
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
#: meet/settings.py:164
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
#: meet/settings.py:165
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:165
#: meet/settings.py:166
msgid "German"
msgstr "Duits"
+39 -2
View File
@@ -11,11 +11,12 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
from os import path
from os import environ, path
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -86,7 +87,9 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if environ.get("DATABASE_URL")
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -430,6 +433,17 @@ class Base(Configuration):
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
OIDC_USE_PKCE = values.BooleanValue(
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
)
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
default="S256",
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
environ_prefix=None,
)
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
@@ -481,6 +495,14 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
default=False,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -525,6 +547,11 @@ class Base(Configuration):
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
# Recording max duration in milliseconds - must be synced with LiveKit Egress configuration
# Set to None for no max duration
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
@@ -609,6 +636,16 @@ class Base(Configuration):
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
ROOM_TELEPHONY_PHONE_NUMBER = values.Value(
None,
environ_name="ROOM_TELEPHONY_PHONE_NUMBER",
environ_prefix=None,
)
ROOM_TELEPHONY_DEFAULT_COUNTRY = values.Value(
"US",
environ_name="ROOM_TELEPHONY_DEFAULT_COUNTRY",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
+2 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.27"
version = "0.1.33"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -29,6 +29,7 @@ dependencies = [
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.5.3",
"dj-database-url==2.3.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python
"""Setup file for the impress module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
+1463 -1355
View File
File diff suppressed because it is too large Load Diff
+24 -21
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.27",
"version": "0.1.33",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,49 +13,52 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.9.12",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.53.6",
"@react-aria/toast": "3.0.2",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.76.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "25.1.2",
"i18next-browser-languagedetector": "8.1.0",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.13.8",
"posthog-js": "1.240.6",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.8.0",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.0"
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "0.53.6",
"@tanstack/eslint-plugin-query": "5.74.7",
"@tanstack/react-query-devtools": "5.76.0",
"@types/node": "22.15.17",
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "4.6.0",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.3",
"prettier": "3.5.3",
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "6.2.7",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
}
+11
View File
@@ -29,8 +29,19 @@ export interface ApiConfig {
is_enabled?: boolean
available_modes?: RecordingMode[]
expiration_days?: number
max_duration?: number
}
telephony: {
enabled: boolean
phone_number?: string
default_country?: string
}
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
const fetchConfig = (): Promise<ApiConfig> => {
@@ -1,100 +1,187 @@
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ApiAccessLevel, ApiRoom } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
roomId,
room,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false)
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && room?.pin_code
}, [telephony?.enabled, room?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined)
return (
<Dialog
isOpen={!!roomId}
{...dialogProps}
title={t('laterMeetingDialog.heading')}
>
<P>{t('laterMeetingDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('laterMeetingDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('laterMeetingDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('laterMeetingDialog.copy')
) : (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
<P>{t('description')}</P>
{!!roomUrl && (
<>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
backgroundColor: 'gray.50',
borderRadius: '0.75rem',
display: 'flex',
flexDirection: 'column',
padding: '1.75rem 1.5rem',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
})}
>
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
)}
</>
)}
</Button>
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('laterMeetingDialog.permissions')}
</Text>
</HStack>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(room?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'tertiaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl?.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
)}
{room?.access_level == ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</>
)}
</Dialog>
)
}
@@ -18,6 +18,7 @@ import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -153,7 +154,7 @@ export const Home = () => {
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const { data } = useConfig()
@@ -202,7 +203,7 @@ export const Home = () => {
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
setLaterRoom(data)
)
}}
data-attr="create-option-later"
@@ -251,8 +252,8 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
roomId={laterRoomId ?? ''}
onOpenChange={() => setLaterRoomId(null)}
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
/>
</Screen>
</UserAware>
@@ -18,7 +18,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -72,29 +71,33 @@ export const MainNotificationToast = () => {
) => {
const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return
if (!notification) return
switch (notification.type) {
case NotificationType.ParticipantMuted:
toastQueue.add(
{
participant,
type: NotificationType.ParticipantMuted,
},
{ timeout: NotificationDuration.ALERT }
)
if (participant) {
toastQueue.add(
{
participant,
type: NotificationType.ParticipantMuted,
},
{ timeout: NotificationDuration.ALERT }
)
}
break
case NotificationType.ReactionReceived:
if (notification.data?.emoji)
if (notification.data?.emoji && participant)
handleEmoji(notification.data.emoji, participant)
break
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
@@ -151,17 +154,14 @@ export const MainNotificationToast = () => {
useEffect(() => {
const handleNotificationReceived = (
prevMetadataStr: string | undefined,
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
const prevMetadata = safeParseMetadata(prevMetadataStr)
const metadata = safeParseMetadata(participant.metadata)
if (prevMetadata?.raised == metadata?.raised) return
if (!('handRaisedAt' in changedAttributes)) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
@@ -169,12 +169,12 @@ export const MainNotificationToast = () => {
toast.content.type === NotificationType.HandRaised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
if (existingToast && !changedAttributes?.handRaisedAt) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
if (!existingToast && !!changedAttributes?.handRaisedAt) {
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
@@ -186,10 +186,13 @@ export const MainNotificationToast = () => {
}
}
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
}
}, [room, triggerNotificationSound])
@@ -8,7 +8,9 @@ export enum NotificationType {
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
}
@@ -19,10 +19,14 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
return 'transcript.started'
case NotificationType.TranscriptionStopped:
return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
default:
return
}
@@ -40,7 +44,7 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
gap={0}
>
{t(key, {
name: participant.name,
name: participant?.name,
})}
</HStack>
</StyledToastContainer>
@@ -29,6 +29,9 @@ export function ToastJoined({ state, ...props }: ToastProps) {
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
if (!participant) return
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
@@ -27,7 +27,7 @@ export function ToastMessageReceived({ state, ...props }: ToastProps) {
}
}, [isChatOpen, toast, state])
if (isChatOpen) return null
if (isChatOpen || !participant) return null
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -10,6 +10,9 @@ export function ToastMuted({ state, ...props }: ToastProps) {
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
if (!participant) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
@@ -5,7 +5,7 @@ import { Participant } from 'livekit-client'
import { NotificationType } from '../NotificationType'
export interface ToastData {
participant: Participant
participant?: Participant
type: NotificationType
message?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -20,6 +20,8 @@ export function ToastRaised({ state, ...props }: ToastProps) {
const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
if (!participant) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
@@ -40,8 +40,10 @@ const renderToast = (
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.RecordingSaving:
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
export const LimitReachedAlertDialog = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t, i18n } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast.limitReachedAlert',
})
const { data } = useConfig()
return (
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
})
: '',
})}
</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
{t('button')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,11 +1,11 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio/index'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Text } from '@/primitives'
import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -18,14 +18,18 @@ import {
import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
})
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
@@ -53,13 +57,10 @@ export const RecordingStateToast = () => {
}, [room.isRecording])
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const handleDataReceived = (payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return
if (!notification) return
switch (notification.type) {
case NotificationType.TranscriptionStarted:
@@ -68,12 +69,20 @@ export const RecordingStateToast = () => {
case NotificationType.TranscriptionStopped:
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.TranscriptionLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
case NotificationType.ScreenRecordingLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
}
@@ -100,7 +109,7 @@ export const RecordingStateToast = () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room, recordingSnap])
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
const key = useMemo(() => {
switch (recordingSnap.status) {
@@ -119,7 +128,14 @@ export const RecordingStateToast = () => {
}
}, [recordingSnap])
if (!key) return
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
const isStarted = key?.includes('started')
@@ -14,6 +14,28 @@ import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording'
import { useConfig } from '@/api/useConfig'
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: 'primary.100',
color: '#0063CB',
fontSize: '14px',
fontWeight: 500,
margin: '0 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
})}
>
Beta
</span>
)
export const RecordingDownload = () => {
const { t } = useTranslation('recording')
const { data: configData } = useConfig()
@@ -27,11 +49,11 @@ export const RecordingDownload = () => {
enabled: !!recordingId,
})
if (isLoading || !data || isAuthLoading) {
if (isLoggedIn === undefined || isAuthLoading) {
return <LoadingScreen />
}
if (!isLoggedIn) {
if (isLoggedIn === false && !isAuthLoading) {
return (
<ErrorScreen
title={t('authentication.title')}
@@ -40,7 +62,11 @@ export const RecordingDownload = () => {
)
}
if (isError) {
if (isLoading) {
return <LoadingScreen />
}
if (isError || !data) {
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
}
@@ -103,6 +129,28 @@ export const RecordingDownload = () => {
>
{t('success.button')}
</LinkButton>
<div
className={css({
backgroundColor: 'greyscale.50',
borderRadius: '5px',
paddingY: '1rem',
paddingX: '1rem',
maxWidth: '80%',
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
})}
>
<Text
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{t('success.warning.title')} <BetaBadge />
</Text>
<Text variant="smNote">{t('success.warning.body')}</Text>
</div>
</VStack>
</Center>
</Screen>
@@ -14,6 +14,7 @@ export type ApiRoom = {
id: string
name: string
slug: string
pin_code: string
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
@@ -1,8 +1,18 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -13,27 +23,38 @@ import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices
}
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
}, [roomId, posthog])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
const {
mutateAsync: createRoom,
status: createStatus,
@@ -65,10 +86,13 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -80,11 +104,82 @@ export const Conference = ({
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
* This prefetch helps reduce initial connection latency by establishing
* an early HTTP connection to the WebRTC signaling server
*
* It should cache DNS and TLS keys.
*/
const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return
await room.prepareConnection(apiConfig.livekit.url)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
apiConfig.livekit.url
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND:
*
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
*
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
*
* Note: This issue is reproducible on LiveKit's demo app.
* Reference: livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
// 401 unauthorized response is expected
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (isCreateError) {
@@ -109,9 +204,9 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={true}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
@@ -124,16 +219,34 @@ export const Conference = ({
className={css({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
posthog.captureException(e)
}}
onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
}
}}
onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
>
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, type DialogProps, P } from '@/primitives'
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
@@ -10,8 +10,13 @@ import {
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - extract in a proper primitive this dialog without overlay
const StyledRACDialog = styled(Dialog, {
@@ -34,24 +39,27 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const [isCopied, setIsCopied] = useState(false)
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
return (
<StyledRACDialog {...dialogProps}>
<StyledRACDialog {...props}>
{({ close }) => (
<VStack
alignItems="left"
@@ -60,7 +68,7 @@ export const InviteDialog = ({
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('shareDialog.heading')}
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -68,7 +76,7 @@ export const InviteDialog = ({
variant="tertiaryText"
size="xs"
onPress={() => {
dialogProps.onClose?.()
props.onClose?.()
close()
}}
aria-label={t('closeDialog')}
@@ -76,49 +84,125 @@ export const InviteDialog = ({
<RiCloseLine />
</Button>
</Div>
<P>{t('shareDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('shareDialog.copy')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copyButton')}
</>
)}
</Button>
<HStack>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
})}
>
<RiSpam2Fill
size={22}
<div
className={css({
fill: 'primary.500',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
/>
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('shareDialog.permissions')}
</Text>
</HStack>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
)}
</StyledRACDialog>
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
@@ -27,7 +27,6 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -107,10 +106,10 @@ const Effects = ({
}
export const Join = ({
onSubmit,
enterRoom,
roomId,
}: {
onSubmit: (choices: LocalUserChoices) => void
enterRoom: () => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -132,23 +131,14 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
@@ -192,25 +182,6 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
const enterRoom = useCallback(() => {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}, [
onSubmit,
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processor,
])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
@@ -269,16 +240,6 @@ export const Join = ({
enterRoom()
}
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
useEffect(() => {
if (processor && videoTrack && !videoTrack.getProcessor()) {
videoTrack.setProcessor(processor)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
const renderWaitingState = () => {
switch (status) {
case ApiLobbyStatus.TIMEOUT:
@@ -453,7 +414,12 @@ export const Join = ({
</p>
</div>
)}
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
@@ -0,0 +1,33 @@
import { MediaDeviceFailure } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
export type MediaDeviceErrorAlertProps = {
error?: MediaDeviceFailure | null
kind?: MediaDeviceKind | null
onClose: () => void
}
export const MediaDeviceErrorAlert = ({
error,
kind,
onClose,
}: MediaDeviceErrorAlertProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'mediaErrorDialog' })
if (!error || !kind) return
return (
<Dialog
role="alertdialog"
isOpen={!!error}
onClose={onClose}
title={t(`${error}.title.${kind}`)}
>
<P>{t(`${error}.body.${kind}`)}</P>
<Button variant="text" size="sm" onPress={onClose}>
{t('close')}
</Button>
</Dialog>
)
}
@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const useLowerHandParticipant = () => {
const data = useRoomData()
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newMetadata = safeParseMetadata(participant.metadata) || {}
newMetadata.raised = !newMetadata.raised
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
metadata: JSON.stringify(newMetadata),
attributes: newAttributes,
permission: participant.permissions,
}),
}
@@ -1,27 +1,29 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { useMemo } 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 { Bold, Button, Div, Text } from '@/primitives'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
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)
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && data?.pin_code
}, [telephony?.enabled, data?.pin_code])
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
return (
<Div
display="flex"
@@ -41,17 +43,34 @@ export const Info = () => {
>
{t('roomInformation.title')}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
</Text>
<div
className={css({
gap: '0.15rem',
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
<>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.call')}</Bold> (
{telephony?.country}) {telephony?.internationalPhoneNumber}
</Text>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.pinCode')}</Bold>{' '}
{formatPinCode(data?.pin_code)}
</Text>
</>
)}
</div>
<Button
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onPress={copyRoomToClipboard}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
@@ -22,7 +22,7 @@ import {
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '../hooks/useRaisedHand'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -97,6 +97,10 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
@@ -141,24 +145,32 @@ export const ParticipantTile: (
style={{
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare ? 'white' : undefined,
isHandRaised && !isScreenShare
? firstInQueue
? '#fde047'
: 'white'
: undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{isHandRaised && !isScreenShare && (
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
marginLeft: '0.1rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
</>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -1,6 +1,5 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client'
@@ -21,9 +20,6 @@ export const LeaveButton = () => {
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
.finally(() => {
navigateTo('feedback')
})
}}
data-attr="controls-leave"
>
@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata)
return data.raised
})
const raisedHandParticipants = participants
.filter((participant) => !!participant.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
return timeA - timeB
})
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
@@ -0,0 +1,79 @@
import { useTelephony } from './useTelephony'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
const [isCopied, setIsCopied] = useState(false)
const [isRoomUrlCopied, setIsRoomUrlCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), COPY_SUCCESS_TIMEOUT)
return () => clearTimeout(timeout)
}
}, [isCopied])
useEffect(() => {
if (isRoomUrlCopied) {
const timeout = setTimeout(
() => setIsRoomUrlCopied(false),
COPY_SUCCESS_TIMEOUT
)
return () => clearTimeout(timeout)
}
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
const hasTelephonyInfo = useMemo(() => {
return telephony.enabled && room?.pin_code
}, [telephony.enabled, room?.pin_code])
const content = useMemo(() => {
if (!roomUrl || !room) return ''
if (!hasTelephonyInfo) return roomUrl
return [
t('url', { roomUrl }),
t('numberAndPin', {
phoneNumber: telephony?.internationalPhoneNumber,
pinCode: formatPinCode(room.pin_code),
}),
].join('\n')
}, [roomUrl, hasTelephonyInfo, telephony, room, t])
const copyRoomToClipboard = async () => {
try {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
console.error(error)
}
}
const copyRoomUrlToClipboard = async () => {
try {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
console.error(error)
}
}
return {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
}
}
@@ -4,7 +4,7 @@ import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalytics
import { isMobileBrowser } from '@livekit/components-core'
export const useNoiseReductionAvailable = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.noiseReduction)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isMobile = isMobileBrowser()
@@ -1,23 +1,58 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react'
import {
useParticipantAttribute,
useParticipants,
} from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata ?? '{}')
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
const { isHandRaised } = useRaisedHand({ participant })
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
const participants = useParticipants()
const positionInQueue = useMemo(() => {
if (!isHandRaised) return
return (
participants
.filter((p) => !!p.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
return dateA.getTime() - dateB.getTime()
})
.findIndex((p) => p.identity === participant.identity) + 1
)
}, [participants, participant, isHandRaised])
return {
positionInQueue,
firstInQueue: positionInQueue == 1,
}
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
participant,
})
const isHandRaised = !!handRaisedAtAttribute
const toggleRaisedHand = async () => {
if (!isLocal(participant)) return
const localParticipant = participant as LocalParticipant
const attributes: Record<string, string> = {
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
}
await localParticipant.setAttributes(attributes)
}
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
return { isHandRaised, toggleRaisedHand }
}
@@ -0,0 +1,20 @@
import { useConfig } from '@/api/useConfig.ts'
import { useMemo } from 'react'
import { parseConfigPhoneNumber } from '../../utils/telephony'
export const useTelephony = () => {
const { data } = useConfig()
const parsedPhoneNumber = useMemo(() => {
return parseConfigPhoneNumber(
data?.telephony?.phone_number,
data?.telephony?.default_country
)
}, [data?.telephony])
return {
enabled: data?.telephony?.enabled && parsedPhoneNumber,
country: parsedPhoneNumber?.country,
internationalPhoneNumber: parsedPhoneNumber?.formatInternational(),
}
}
@@ -3,7 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useLocation, useSearchParams } from 'wouter'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -16,17 +16,25 @@ const Heading = styled('h1', {
lineHeight: '2.5rem',
letterSpacing: '0',
paddingBottom: '2rem',
textAlign: 'center',
},
})
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t('feedback.heading')}</Heading>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
@@ -10,12 +9,10 @@ import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -48,10 +45,10 @@ export const Room = () => {
return <ErrorScreen />
}
if (!userConfig && !skipJoinScreen) {
if (!hasSubmittedEntry && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</UserAware>
)
}
@@ -62,10 +59,6 @@ export const Room = () => {
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
)
@@ -1,22 +0,0 @@
export const safeParseMetadata = (
metadataStr: string | null | undefined
): Record<string, unknown> => {
if (!metadataStr) {
return {}
}
try {
const parsed = JSON.parse(metadataStr)
// Ensure the result is an object
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Metadata parsed to non-object value:', parsed)
return {}
}
return parsed as Record<string, unknown>
} catch (error) {
console.error('Failed to parse metadata:', error)
return {}
}
}
@@ -0,0 +1,23 @@
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js'
export const parseConfigPhoneNumber = (
rawPhoneNumber?: string,
defaultCountry?: string
) => {
if (!rawPhoneNumber || !defaultCountry) {
return null
}
try {
return parsePhoneNumberWithError(
rawPhoneNumber,
defaultCountry as CountryCode
)
} catch (error) {
console.warn('Invalid phone number format:', rawPhoneNumber, error)
return null
}
}
export function formatPinCode(pinCode?: string) {
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#`
}
@@ -10,6 +10,10 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
const { t, i18n } = useTranslation('settings')
const { user, isLoggedIn, logout } = useUser()
const { languagesList, currentLanguage } = useLanguageLabels()
const userDisplay =
user?.full_name && user?.email
? `${user.full_name} (${user.email})`
: user?.email
return (
<Dialog title={t('dialog.heading')} {...props}>
<H lvl={2}>{t('account.heading')}</H>
@@ -18,7 +22,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name ?? user?.email }}
values={{ user: userDisplay }}
components={[<Badge />]}
/>
</P>
@@ -14,8 +14,8 @@ import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -51,8 +51,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null)
const { width } = useSize(dialogEl)
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
@@ -18,6 +18,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const room = useRoomContext()
const { user, isLoggedIn, logout } = useUser()
const [name, setName] = useState(room?.localParticipant.name ?? '')
const userDisplay =
user?.full_name && user?.email
? `${user.full_name} (${user.email})`
: user?.email
const handleOnSubmit = () => {
if (room) room.localParticipant.setName(name)
@@ -37,7 +41,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
value={name}
onChange={setName}
validate={(value) => {
return !value ? <p>{'Votre Nom ne peut pas être vide'}</p> : null
return !value ? <p>{t('account.nameError')}</p> : null
}}
/>
<H lvl={2}>{t('account.authentication')}</H>
@@ -46,7 +50,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name || user?.email }}
values={{ user: userDisplay }}
components={[<Badge />]}
/>
</P>
@@ -1,4 +1,4 @@
import { DialogProps, Field, H, Switch } from '@/primitives'
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -174,7 +174,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
</RowWrapper>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() && (
{!isSafari() ? (
<RowWrapper heading={t('audio.speakers.heading')}>
<Field
type="select"
@@ -193,6 +193,13 @@ export const AudioTab = ({ id }: AudioTabProps) => {
/>
<SoundTester />
</RowWrapper>
) : (
<RowWrapper heading={t('audio.speakers.heading')}>
<Text variant="warning" margin="md">
{t('audio.speakers.safariWarning')}
</Text>
<div />
</RowWrapper>
)}
{noiseReductionAvailable && (
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
@@ -12,6 +12,7 @@ export const useKeyboardShortcuts = () => {
// Issues might occur. First draft.
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
@@ -7,7 +7,7 @@ export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
const { id, email } = user
Crisp.setTokenId(`meet-${id}`)
Crisp.user.setEmail(email)
if (email) Crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
+4
View File
@@ -51,5 +51,9 @@
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Ihre Zugangsdaten",
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
"copy": "Meeting-Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert",
"copyUrl": "Meeting-Link kopieren",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
@@ -24,11 +24,13 @@
},
"transcript": {
"started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt."
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
},
"screenRecording": {
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt."
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
},
"recordingSave": {
"transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Aufzeichnung nicht verfügbar",
"body": "Diese Aufzeichnung konnte nicht gefunden werden oder wurde gelöscht."
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Organisator der Besprechung hat Zugriff darauf. Wenden Sie sich bei Bedarf gerne an ihn."
},
"expired": {
"title": "Aufzeichnung abgelaufen",
@@ -19,6 +19,10 @@
"title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen"
"button": "Herunterladen",
"warning": {
"title": "Linkfreigabe deaktiviert",
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
}
}
}
+38 -8
View File
@@ -1,6 +1,9 @@
{
"feedback": {
"heading": "Sie haben das Meeting verlassen",
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
@@ -50,12 +53,29 @@
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "Meeting-Link kopieren",
"copyButton": "Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"copy": "Informationen kopieren",
"copyUrl": "Link kopieren",
"copied": "Informationen kopiert",
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Mikrofon konnte nicht aktiviert werden",
"videoinput": "Kamera konnte nicht aktiviert werden"
},
"body": {
"audioinput": "Ihr Mikrofon wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um es hier zu verwenden.",
"videoinput": "Ihre Kamera wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um sie hier zu verwenden."
}
},
"close": "OK"
},
"error": {
"createRoom": {
@@ -210,9 +230,13 @@
"roomInformation": {
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert"
},
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
}
},
@@ -375,6 +399,12 @@
},
"any": {
"started": "Aufzeichnung läuft"
},
"limitReachedAlert": {
"title": "Aufnahmelimit überschritten",
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
"durationMessage": " von {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Konto",
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
"nameLabel": "Ihr Name",
"authentication": "Authentifizierung"
"authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
},
"audio": {
"microphone": {
@@ -24,7 +25,8 @@
"heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe",
"test": "Testen",
"ongoingTest": "Soundtest läuft…"
"ongoingTest": "Soundtest läuft…",
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
},
"permissionsRequired": "Berechtigungen erforderlich"
},
+4
View File
@@ -51,5 +51,9 @@
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without ProConnect.",
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
"permissions": "People with this link do not need your permission to join this meeting.",
"copy": "Copy information",
"copied": "Information copied to clipboard",
"copyUrl": "Copy the meeting link",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
@@ -24,11 +24,13 @@
},
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription."
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording."
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
},
"recordingSave": {
"transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Recording unavailable",
"body": "This recording could not be found or was deleted."
"body": "The recording is unavailable or may have been deleted. Only the meeting organizer can access it. Feel free to contact them if needed."
},
"expired": {
"title": "Recording expired",
@@ -19,6 +19,10 @@
"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"
"button": "Download",
"warning": {
"title": "Link sharing disabled",
"body": "Sharing the recording via link is not yet available. Only organizers can download it."
}
}
}
+38 -8
View File
@@ -1,6 +1,9 @@
{
"feedback": {
"heading": "You have left the meeting",
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device"
},
"home": "Return to home",
"back": "Rejoin the meeting"
},
@@ -50,12 +53,29 @@
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy the meeting link",
"copyButton": "Copy link",
"copied": "Link copied to clipboard",
"copy": "Copy information",
"copyUrl": "Copy link",
"copied": "Information copied to clipboard",
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting."
"permissions": "People with this link do not need your permission to join this meeting.",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Microphone Activation Failed",
"videoinput": "Camera Activation Failed"
},
"body": {
"audioinput": "Your microphone is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here.",
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
}
},
"close": "OK"
},
"error": {
"createRoom": {
@@ -210,9 +230,13 @@
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
"ariaLabel": "Copy the information from your meeting",
"copy": "Copy information",
"copied": "Information copied"
},
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
}
},
@@ -375,6 +399,12 @@
},
"any": {
"started": "Recording in progress"
},
"limitReachedAlert": {
"title": "Recording limit exceeded",
"description": "The recording has exceeded the maximum allowed duration{{duration_message}}. It will now be saved automatically.",
"durationMessage": " of {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Account",
"youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name",
"authentication": "Authentication"
"authentication": "Authentication",
"nameError": "Your name cannot be empty"
},
"audio": {
"microphone": {
@@ -24,7 +25,8 @@
"heading": "Speakers",
"label": "Select your audio output",
"test": "Test",
"ongoingTest": "Testing sound…"
"ongoingTest": "Testing sound…",
"safariWarning": "Speaker selection is not available yet on Safari due to browser limitations."
},
"permissionsRequired": "Permissions required"
},
+5 -1
View File
@@ -34,7 +34,7 @@
"datagouv": "data.gouv.fr",
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilités : non conforme",
"accessibility": "Accessibilité : non conforme",
"ariaLabel": "nouvelle fenêtre",
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
"code": "dépôt de code Open Source",
@@ -51,5 +51,9 @@
"ariaLabel": "Fermer la suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans ProConnect.",
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"copy": "Copier les informations",
"copied": "Copiées dans le presse-papiers",
"copyUrl": "Copier le lien de la réunion",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
},
"introSlider": {
"previous": {
@@ -24,11 +24,13 @@
},
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion."
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
},
"recordingSave": {
"transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Enregistrement indisponible",
"body": "Cet enregistrement est introuvable ou a été supprimé."
"body": "Lenregistrement est introuvable. Seul lorganisateur de la réunion peut y accéder. Nhésitez pas à le contacter si besoin."
},
"expired": {
"title": "Enregistrement expiré",
@@ -19,6 +19,10 @@
"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"
"button": "Télécharger",
"warning": {
"title": "Partage via lien désactivé",
"body": "Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
}
}
}
+39 -9
View File
@@ -1,6 +1,9 @@
{
"feedback": {
"heading": "Vous avez quitté la réunion",
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
},
@@ -50,12 +53,29 @@
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier le lien de la réunion",
"copyButton": "Copier le lien",
"copied": "Lien copié dans le presse-papiers",
"copy": "Copier les informations",
"copyUrl": "Copier le lien",
"copied": "Copiées dans le presse-papiers",
"heading": "Votre réunion est prête",
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Activation microphone impossible",
"videoinput": "Activation caméra impossible"
},
"body": {
"audioinput": "Votre microphone est déjà utilisé dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici.",
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
}
},
"close": "OK"
},
"error": {
"createRoom": {
@@ -210,9 +230,13 @@
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
"ariaLabel": "Copier les informations de votre réunion",
"copy": "Copier les informations",
"copied": "Informations copiées"
},
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
}
},
@@ -375,6 +399,12 @@
},
"any": {
"started": "Enregistrement en cours"
},
"limitReachedAlert": {
"title": "Limite d'enregistrement dépassée",
"description": "L'enregistrement a dépassé la durée maximale autorisée{{duration_message}}. Il va maintenant être automatiquement sauvegardé.",
"durationMessage": " de {{duration}}",
"button": "OK"
}
},
"participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Compte",
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
"nameLabel": "Votre Nom",
"authentication": "Authentification"
"authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
},
"audio": {
"microphone": {
@@ -24,7 +25,8 @@
"heading": "Haut-parleurs",
"label": "Sélectionner votre sortie audio",
"test": "Tester",
"ongoingTest": "Test du son…"
"ongoingTest": "Test du son…",
"safariWarning": "La sélection du haut-parleur n'est pas encore disponible sur Safari en raison de limitations du navigateur."
},
"permissionsRequired": "Autorisations nécessaires"
},
+4
View File
@@ -50,5 +50,9 @@
"ariaLabel": "Sluit de suggestie",
"label": "OK"
}
},
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Uw verbindingsgegevens",
"description": "Stuur deze link naar de mensen die u wilt uitnodigen voor de vergadering. Zij kunnen deelnemen zonder ProConnect.",
"copy": "Kopieer de vergaderlink",
"copied": "Link gekopieerd naar klembord",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
"description": "Deel deze informatie met de genodigden. Zij kunnen deelnemen aan de vergadering zonder zich aan te melden. Deze vergadering is permanent en kan hergebruikt worden.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd",
"copyUrl": "Kopieer de vergaderlink",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
@@ -24,11 +24,13 @@
},
"transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
},
"recordingSave": {
"transcript": {

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