Compare commits

...

94 Commits

Author SHA1 Message Date
Nathan Vasse 2071ef4961 (front) add blurring feature on join
This adds a button that opens a modal that allow user to enable
video effects on join screen.
2025-01-31 11:29:33 +01:00
Nathan Vasse c3c9a3e53c ♻️(front) refactor Effects
We need to have an agnostic component to apply effects on any video
track, enters EffectsConfiguration. It takes in input the video
track and outputs the processor applied. This is gonna be used in
order to use the same component on the pre join screen and the in
room customization side panel.
2025-01-29 17:33:19 +01:00
Nathan Vasse d46ed9412e (front) add button white variant
This variant is gonna be used on overlay over a video element.
2025-01-29 17:33:19 +01:00
Nathan Vasse 5a75c09144 ♻️(front) enable custom size on Dialog
We want to have a Dialog that is constrained in width. Thus,
introducing this variant enables this possibility and many more
in the future.
2025-01-29 17:03:09 +01:00
Nathan Vasse 3f6c717418 ♻️(front) add clone method to processors
We need to make new processor instance between pre join and room
in order to apply effects.
2025-01-29 17:03:09 +01:00
lebaudantoine 4ae3d965f9 🚸(frontend) show support toggle only if support is enabled
Avoid misleading the user, if support is not enabled, as it's optional,
avoid displaying a useless control button.

Requested by Dutch counterparts.
2025-01-29 16:12:47 +01:00
lebaudantoine 8eab45b6d5 🩹(frontend) prevent runtime error when Crisp is not initialized
Prevents runtime error when Crisp chat hasn't initialized before component mount
Previously caused crash since we assumed $crisp global was always available.
2025-01-29 16:12:47 +01:00
lebaudantoine 4347d87f33 🚸(frontend) improve prejoin UX
- Always enable camera/mic by default (like Google Meet)
- Fix video state transitions and add visual feedback
- Simplify form using React Aria components
- Reduce shadow intensity for better visual balance
2025-01-29 14:14:11 +01:00
lebaudantoine 1b52d76168 ♻️(frontend) extract menu items into individual components
Each menu item is now a standalone component, improving:
- Code organization & reusability
- Maintainability by reducing OptionsMenuItems complexity

This breaks down large components.
2025-01-27 23:02:31 +01:00
lebaudantoine a44b6e8e34 (frontend) add fullscreen mode on desktop
Added an option allowing users to trigger the fullscreen mode while on desktop.
Heavily inspired by the PR #279 from @sylvinus.

Yet, this option allow user to enable/disable the fullscreen mode on the whole
ui, in the next iteration I'll add the same feature but for a given video track.

This is on purpose that the feature is available on desktop only.
The hook code has been partially written by Claude and inspired by @sylvinus
first suggestion.
2025-01-27 23:02:31 +01:00
lebaudantoine 126de638cd 🩹(frontend) fix allowed hosts by Vite using the Tilt stack
When using nip.io for local development DNS mapping (which allows hostname-based
access to localhost), we need to explicitly allow these domains in Vite's server
configuration to prevent host security violations.

This check should be ignored when using https.
2025-01-27 22:57:51 +01:00
lebaudantoine bf28c5cb84 📱(frontend) enhance control bar responsiveness for better UX
Implemented collapsible advanced options to maintain usability on narrow screens
following GMeet's UX pattern. Dialog and popover components were chosen
based on GMeet choices, though this introduces potential accessibility concerns
that should be addressed in future iterations.

Current implementation uses JS for breakpoint handling due to challenges with
Panda CSS's pure CSS approach. This workaround was necessary to resolve a
persistent issue where the popover remained open after window expansion beyond
750px, even after the lateral menu trigger was removed from view.

Technical debt note: Code needs refinement, particularly around breakpoint
management and component architecture. Prioritized shipping over perfection to
meet immediate responsive design needs.
2025-01-27 22:57:24 +01:00
renovate[bot] 4f0e7d2c52 ⬆️(dependencies) update python dependencies 2025-01-27 22:18:32 +01:00
renovate[bot] 3ab5c48296 ⬆️(dependencies) update js dependencies 2025-01-27 18:12:44 +01:00
Nathan Vasse 7f1f573af8 (front) revamp join screen
We want this screen to have a better ux, the join button was invisible on
some small screen sizes, also we want to align the style of this screen with
the ui of the video conference previously made.
2025-01-27 15:31:19 +01:00
Nathan Vasse d48a18b36b (front) make ToggleDevice work outside of a room
This component is needed on the join screen thus out of a room context.
2025-01-27 15:31:19 +01:00
Nathan Vasse eac9158734 (front) add customization to Field
We want to be able to provide some custom attributes to the FieldWrapper
and its label too to manage width, alignment.
2025-01-27 15:31:19 +01:00
Nathan Vasse 994f335266 (front) add customization to SelectToggleDevice
We want to be able to change the variant of this component.
2025-01-27 15:31:19 +01:00
lebaudantoine 30bde2fe66 ♻️(frontend) use pandas utilities
Forgot how panda css is amazing. They provide all kinds of utilities,
and few for accessibility. Amazing!
2025-01-24 13:56:32 +01:00
Nathan Vasse 96c18fc627 (front) add switch camera on mobile
We want to have a more simple control bar and give the user to switch
its back / front camera from the responsive menu.
2025-01-21 15:37:59 +01:00
Nathan Vasse c380ff5e1d (front) option to hide menu on SelectToggleDevice
On mobile we want to be able to hide the chevron and menu.
2025-01-21 15:37:59 +01:00
lebaudantoine 6587863afb 💄(frontend) use dark theme for menus in the videoconference
Inspired by GMeet. Actually, these menus are horrible to work with.
This is clearly some technical debt. I fixed the styles, but not the
code, we should refactor them to make easy to chose between two
variant, a light and a dark one.
2025-01-21 11:53:12 +01:00
lebaudantoine a06daad33d 💄(frontend) fix screen-share control alignment
Oopsie, due to its (too) big icon, the height of this control was 4px bigger
than other buttons. Fixed that minor issue.
2025-01-21 11:53:12 +01:00
renovate[bot] 10c3c67d53 ⬆️(dependencies) update js dependencies 2025-01-21 11:20:29 +01:00
lebaudantoine fa85ef5690 📌(frontend) block upgrade to react v19
Many dependencies are not supported react v19 yet.
Avoid renovate to bump react to its latest major version.
2025-01-21 11:20:29 +01:00
lebaudantoine cc676cf71b ⬆️(frontend) bump react-aria-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90df8fe5b4 ⬆️(frontend) bump react-query-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
lebaudantoine 90cfcd31c8 ⬆️(frontend) bump LiveKit-related dependencies
No major / breaking change in these few releases.
Quickly tested all features, everything seems alright
2025-01-20 19:31:55 +01:00
renovate[bot] 0ed3416ce2 ⬆️(dependencies) update python dependencies 2025-01-20 09:35:06 +01:00
lebaudantoine e47401246f ✏️(frontend) fix feedback-s
Feedback is a mass noun.
Spotted by @bvhme
2025-01-18 22:11:35 +01:00
lebaudantoine a518179efb ⬆️(frontend) bump vite to v6.0.7
Updates build configuration to support new major version.
This might introduce some breaking changes.

I've read their migration guide, everything seems okay.
2025-01-17 18:23:48 +01:00
lebaudantoine f17471dae5 🚨(frontend) resolve key prop warning in ParticipantListItem
Key prop was incorrectly passed down as a regular prop to ParticipantListItem
instead of being used at the array mapping level. Key is a special React prop
for list rendering and cannot be accessed as a component prop.
2025-01-16 18:04:20 +01:00
lebaudantoine 36ea44befc 🚨(frontend) update deprecated react-aria Section components
Section was deprecated in Novembre 2024 release, It has been replaced
by specific components for each parent collection component,
e.g. MenuSection.
2025-01-16 18:04:20 +01:00
renovate[bot] 047ef83f17 ⬆️(dependencies) update django to v5.1.5 [SECURITY] 2025-01-16 11:10:51 +01:00
Nathan Vasse e1ece8b5af (front) add posthog log for Firefox blurring
We want to be able to track device settings and versions when a user
enables this feature.
2025-01-16 10:03:18 +01:00
Nathan Vasse 56379f2d6e (front) add blurring support on Firefox
We cannot use track-processor-js for Firefox because it uses various
approaches not compatible with Firefox. Firefox cannot run inference
on GPU, so we must use CPU, we could also not use MediaStreamTrackProcessor nor
MediaStreamTrackGenerator. So I had to make a new implementation from
the ground up, using canvas filters and CPU inferences.
2025-01-16 10:03:18 +01:00
lebaudantoine 341be37fd3 🩹(backend) remove a non-existing field on BooleanValue
I used a LLM to review my code, it introduced an hallucinated field.
Remove it.
2025-01-14 15:52:57 +01:00
lebaudantoine 4cce2e0b72 🐛(backend) fix brevo-related setting name mismatch
Error introduced while rebasing. Fixed it.
2025-01-14 15:52:57 +01:00
lebaudantoine f14536dd93 🔐(helm) configure Brevo marketing tool
Using VaultWarden, added a dev Brevo API key.
In the "dev" stack, enable Brevo to validate new users are signed-up
to the marketing emails.
2025-01-14 15:52:57 +01:00
Jacques ROUSSEL dc0e2eefb3 📝(doc) upgrade README.md
Upgrade the README.md of the helm chart meet
2025-01-14 10:15:27 +01:00
lebaudantoine a61b34400b ♻️(scripts) improve script portability
Improved script portability by switching to `/usr/bin/env bash`, ensuring
better support across environments where `bash` may not be the default shell.
2025-01-14 10:15:27 +01:00
lebaudantoine 3c8e3b9e29 (tilt) add missing certificate authority for Python
Avoid disabling SSL verification in development environment,
simply mount in the right folder, an extra volume, that declares
the certificate authority necessary to validate nip.io domains.
2025-01-14 10:15:27 +01:00
lebaudantoine 89f2ae548e ♻️(helm) simplify generate-readme script
Spotted by @rouja, replace complex file handling with a streamlined
approach.
2025-01-14 10:15:27 +01:00
lebaudantoine 1f23bbf96e (feature) support annotations in deployment to handle external secrets
Updated deployments metadata to include configurable annotations using
`.Values.xxx.dpAnnotations`. This change supports the new approach of
storing secrets in an external Vault, allowing annotations to be added in
staging/prod to trigger refreshes when external secrets change.

Will be configured accordingly in La Suite deploiement repository.
2025-01-14 10:15:27 +01:00
lebaudantoine 9972692dac ♻️(helm) refactor clusterSecretStore and ExternalSecret deployments
Refactored ClusterSecretStore and ExternalSecret deployment to support
VaultWarden custom fields beyond login/password, including multi-line
values via file input. Also made the secret template name configurable
for added flexibility.

ClusterSecretStore are supposed to be cluster-wide objects, it's useless
to precise any namespace.
2025-01-14 10:15:27 +01:00
lebaudantoine 6d08e318a7 🐛(ci) align helm chart release process with people repository
Previous merge of helm chart refactoring was incomplete. Currently,
linting only occurs during chart publication rather than on each PR.
This temporary solution will be improved in a future update.
2025-01-14 10:15:27 +01:00
lebaudantoine 203f1762e7 ♻️(helm) extract make command in a script
Existing make command wasn't working on Mac. Fixed it, plus
refactored it in a proper script, so we can share it among
projects, as for the build kind cluster one.

External secrets are created in a dedicated namespace, to avoid
duplicating them if we spawn several LaSuite applications on the
same local stack.
2025-01-14 10:15:27 +01:00
renovate[bot] 470390fc59 ⬆️(dependencies) update python dependencies 2025-01-13 20:30:38 +01:00
lebaudantoine fe9fe4dd90 🗃️(backend) add missing ordering migrations
While fixing some backend warnings, I forgot to generate
the associated migrations. Fixed my error.
Non-critical migrations about default ordering.
2025-01-13 13:36:23 +01:00
Jacques ROUSSEL 7ad9015a6b 👷(helm) fix typo in the ci
Fix the following issue :
```
The workflow is not valid. .github/workflows/release-helmchart.yml
(Line: 25, Col: 12): Job 'release' depends on unknown job
'helmfile-lint'.
```
2025-01-09 18:14:41 +01:00
lebaudantoine 3e4a7058d2 ♻️(ci) merge helmfile-lint and release chart workflows
Merge both flows related to the Helmfile.
Also, make sure lint job is run before the release one.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 25a4e2dfc6 👷(helm) improve local stack
Use the common create_cluster.sh in order to improve cooperation
between teams.

Also, mount extra volume, to avoid setting ssl_verify to false,
while using request module in Python.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 14e83ecaff 📝(doc) add a self-host tutorial
Add a documentation to deploy a self-hosted visio instance in a
standalone way (without AI features)
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL 8bd90bd2ff 👷(helm) add a github workflow to publish a chart
We have a dedicated deployment repository, also containing
the Helm chart. To avoid duplicating and maintaining twice
a chart, we decided to publish our Helm chart.

At first we tried the official chart releaser action, however,
this ended in creating a new release on Github for each chart
update, which wasn't acceptable.
2025-01-06 10:19:09 +01:00
Jacques ROUSSEL b51f127872 🔧(helm) offer a standalone dev environment
Offer a standalone dev environment or a dinum specific dev
environment with ProConnect authentication.

Needed to refactor the way secrets are managed in the project,
and also re-organize the Helm chart to make it totally standalone.

Particulary useful for external wanting to run the project.
Work done by @rouja.
2025-01-06 10:19:09 +01:00
lebaudantoine 4c0230d537 (backend) post email to marketing tools while signing up new users
Submitting new users to the marketing service is currently handled
during signup and is performed only once.

This is a pragmatic first implementation, yet imperfect.

In the future, this should be improved by delegating the call to a Celery
worker or an async task.
2024-12-31 15:09:51 +01:00
lebaudantoine 7309df4115 ♻️(backend) add MarketingService protocol and Brevo implementation
Introduced a MarketingService protocol for typed marketing operations,
allowing easy integration of alternative services.

Implemented a Brevo wrapper following the protocol to decouple
the codebase from the sdk. These implementations are simple and pragmatic.
Feel free to refactor them.
2024-12-31 15:09:51 +01:00
lebaudantoine 18b2dfc497 (backend) install brevo client
Brevo (ex-sendinblue) is a common solution used for marketing and
communications.
2024-12-31 15:09:51 +01:00
lebaudantoine 3282da7c56 🚨(backend) fix Django UnorderedObjectListWarning on models
Found this solution googling on Stack Overflow.

Without a default ordering on a model, Django raises a warning, that
pagination may yield inconsistent results.
2024-12-31 15:09:35 +01:00
renovate[bot] 7f8a6e8685 ⬆️(dependencies) update python dependencies 2024-12-30 13:32:37 +01:00
lebaudantoine 4232c0a303 🔖(patch) bump release to 0.1.12
Release a fix while calling Docs backend API.
2024-12-18 22:54:57 +01:00
lebaudantoine a5454e48b7 🔥(frontend) remove redundant yarn.lock file
We decided to stick to npm for dependencies management.
Having both package-lock.json and yarn.lock isn't clear for
developers, simplify the stack.
2024-12-17 18:53:55 +01:00
dependabot[bot] 591706f363 🔒️(frontend) bump nanoid from 3.3.7 to 3.3.8 in /src/frontend
3.3.8 fixed a way to break Nano ID by passing non-integer size.
Raised as a moderate severity issue by Dependabot.
2024-12-17 18:53:55 +01:00
dependabot[bot] 1ea84b6e6c 🔒️(mail) bump cross-spawn to fix a high severity issue
Bump cross-spawn from 7.0.3 to 7.0.6 in /src/mail
Alert triggered by DependaBot.
2024-12-17 16:43:54 +01:00
renovate[bot] c0d101a326 ⬆️(dependencies) update PyJWT to v2.10.1 [SECURITY] 2024-12-17 16:23:05 +01:00
lebaudantoine 64cfcb6c0f 💩(summary) fix temporarily the summary microservice
The new endpoint requires title and content, breaking
the original implementation. This hotfix ensures staging works
immediately while I plan an LLM-based solution
for title generation.
2024-12-17 11:34:46 +01:00
lebaudantoine cdd69b741a 🚑️(mail) bump package version
Oopsie, I forgot to bump mail package version.
Fix it
2024-12-17 11:34:46 +01:00
Jacques ROUSSEL f5a87cc210 🔐(helm) bump secret in staging
We add a typo in secret so we bump secrets to fix it.
2024-12-17 10:19:40 +01:00
lebaudantoine 4d4ddb9ee8 🔖(patch) bump release to 0.1.11
Weekly release, responsiveness is still under construction.
2024-12-17 00:26:29 +01:00
lebaudantoine 70dbf94f7b 🩹(frontend) avoid duplicating Grist form's URL
It led to an error, with two occurrences of the form's URL being
desynchronized. Fix this minor issue, and refactor the constant
in a constant file to be shared across the app.
2024-12-17 00:08:53 +01:00
Jacques ROUSSEL 0ad37ee6de 🔨(tilt) improve local stack
Improve the local tilt file in order to be abble to start all thing
without any dependencies to DINUM environment
2024-12-16 23:41:09 +01:00
Jacques ROUSSEL ed4f7dcf6c 🔐(helm) add production secrets for recording and transcription
Added all missing secrets for the recording feature
and transcription microservice.
2024-12-16 23:08:04 +01:00
lebaudantoine c54773008c 🚨(backend) fix backend linter
I wrongly merged a backend pr, and broke the linter.
2024-12-16 23:08:04 +01:00
lebaudantoine 6848321bcc 🔧(helm) configure transcription microservice in production
Added one replica for the summary and Celery deployments.
Secret will be added in the upcoming commits.
2024-12-16 23:08:04 +01:00
lebaudantoine f161a5cf6a 🔧(helm) configure recording in production
Enabled recording feature in production. MinIO needs to trigger a webhook
when a new recording is saved. Secret will be updated in the upcoming commits.
2024-12-16 23:08:04 +01:00
lebaudantoine 51270571bc 🔧(helm) update transcription microservice in staging
Updated the webhook URL to the definitive version in docs.

Jacques also updated the webhook secret for authentication
against Impress API. Not tested locally.
2024-12-16 23:08:04 +01:00
Nathan Vasse c6fdeaf1e9 📱(front) add responsive menu on mobile
We want to have a specific responsive menu on mobile browsers.
It also implied to refactor a bit the way the settings modals is opened
because it could be opened from this responsive menu, so in order to achive
that a specific context has been created in order to allow its opening
from any sub component of the control bar.
2024-12-13 15:41:54 +01:00
Nathan Vasse f1959cbb3a ♻️(front) add more customization and event handler to toggles
We want to be able to customize the variant which those toggle uses as
well as being able to trigger an event when the toggle is pressed. This
is going to be useful to close the responsive menu after each clic as
react-aria prevent click event propgation.
2024-12-13 15:41:54 +01:00
Nathan Vasse 84cea2f658 (front) add description to Buttons
We want to display full description below buttons for mobile specific
displays.
2024-12-13 15:41:54 +01:00
Nathan Vasse db1fdb9871 (front) add useIsMobile hook
This hook tells whether the current browser is a mobile one or not.
2024-12-13 15:41:54 +01:00
lebaudantoine a8618239d1 🔧(frontend) enable dynacast optimizations
According to the documentation:

Dynacast dynamically pauses video layers that are
not being consumed by any subscribers, significantly
reducing publishing CPU and bandwidth usage.

Dynacast will be enabled if SVC codecs (VP9/AV1) are used.
Multi-codec simulcast requires dynacast

My goal is to reduce CPU and bandwidth usage for clients.

Dynacast is enabled both in OpenTalk and LiveKit demo app!
2024-12-12 11:25:06 +01:00
lebaudantoine 0eb283b75a 🔧(frontend) enable adaptive stream optimizations
Adaptive stream is a key optimization in large room.

Enabled adaptive stream to automatically manage
the quality of subscribed video tracks, optimizing
for bandwidth and CPU usage.

When video elements are visible, it adjusts
the resolution based on the size of the largest visible
element. If no video elements are visible, it
temporarily pauses the track until they are visible again.

Additionally, introduced support for custom pixel density,
which defaults to `2` for high-density screens
(devicePixelRatio ≥ 3) or `1` for standard screens.
Setting it to `screen` allows the pixel density to match
the actual screen's devicePixelRatio, optimizing video
clarity on ultra-high-definition displays.

This ensures a balance between streaming quality
and resource consumption.

This might also significantly increase the bandwidth
consumed by people streaming on high definition screens.
It needs to be battle tested.

OpenTalk uses a adaptiveStream equals true, while LiveKit
demo app uses 'screen' value. I followed OpenTalk choice,
I was scared 'screen' value creates performance issues
for user with high resolution screen in poor network conditions.
2024-12-12 11:25:06 +01:00
lebaudantoine 0104cabc5e 🔧(frontend) default to vp9 for video codec
As a mandatory codec in WebRTC specifications, VP8 serves as
the baseline for compatibility, making it the default choice
in LiveKit client configuration.

There is room for optimization.

Newer codecs like VP9 offer significant efficiency gains
compared to VP8, with a 23-33% improvement in compression
efficiency. This translates to better video quality at
the same bitrate or reduced bandwidth usage for the same quality.

VP9 is supported in Safari starting from version 15.0+,
and Firefox offers partial support. However, Firefox lacks
support for VP9's Scalable Video Coding (SVC).

With SVC, participants can send a single VP9 stream
with multiple spatial or temporal layers. This allows receivers
to dynamically adjust video quality by using lower layers when
resolution or bandwidth needs to be reduced, improving
adaptability in heterogeneous network conditions.

Simulcast, by contrast, sends multiple separate streams
at different resolutions. While widely supported in VP8 and VP9,
it consumes more bandwidth compared to SVC.

The configuration added here is based on the LiveKit demo app,
which defaults to VP9 when supported. OpenTalk’s configuration
also recommends VP9.

If a browser does not support VP9, LiveKit falls back to VP8 or
other codecs as needed. Notably, LiveKit disables VP9 encoding for
Firefox due to longstanding issues, but it can still decode VP9
streams and encode VP8 for outgoing streams. This ensures
compatibility with other participants, even in mixed environments
where some browsers use VP9 and others fallback to VP8.

In theory, participants do not all need to switch to a single codec,
as both LiveKit and browsers intelligently handle codec negotiation
on a per-participant basis. This dynamic adaptation ensures seamless
communication without manual intervention.

A similar challenge with codec compatibility was raised
in Jitsi two years ago, check issue #10657.

Before any release, this needs to be battle tested
with Firefox 115 browsers.
2024-12-12 11:25:06 +01:00
lebaudantoine dbfba564c5 🔧(backend) fix sentry deprecated scope
`sentry_sdk.configure_scope` is deprecated and will
be removed in the next major version.

(commit taken from people by @qbey)
2024-12-10 12:00:41 +01:00
renovate[bot] 4830206bb2 ⬆️(dependencies) update python dependencies 2024-12-09 18:20:12 +01:00
lebaudantoine e4f30f926c 🩹(summary) fix configuration in staging
@rouja forgot to add the right environment variable for the celery
workers. Fixed it.
2024-12-09 18:15:47 +01:00
lebaudantoine fc61f58596 🔧(summary) update LLM and whisper endpoints
@rouja fixed the issue from OutScale load balancer.
He found the right parameter in OutScale documentation,
that was causing a request timeout.
2024-12-09 18:15:47 +01:00
lebaudantoine e0021dbb80 (frontend) introduce a notification settings
I extended Room settings with a new tab, allowing users to toggle
the sound notification they want.

Their choices will be persisted.
2024-12-09 18:14:46 +01:00
lebaudantoine 83914f8307 (frontend) add notification store for managing user preferences
Introduced a global state to handle user preferences related to notifications.
The first use case is sound notifications, allowing users to disable them
based on feedback.

Additionally, the sound volume is now stored globally, making it easy to
configure in the future if needed. I've lowered the volume of
the notifications to make them more discreet.

Preferences are persisted in local storage, ensuring they are retained
between meetings.
2024-12-09 18:14:46 +01:00
lebaudantoine be54709598 (frontend) create STORAGE_KEYS constant
Add STORAGE_KEYS object to centralize localStorage keys,
ensuring no key overlaps by maintaining a single source
of truth for key declarations across the app.

Might be premature, as only the notification store will be persisted.
2024-12-09 18:14:46 +01:00
lebaudantoine 5b76ea492b (frontend) create utils function for valtio
Valtio allows state persistence in local storage, which is
necessary for the notification store. In this case, I'll need
to persist a `proxyMap`—a utility provided by Valtio to create
an observable map.

However, since `proxyMap` isn't natively serializable,
I'll need to implement two custom functions: one for serialization
and another for deserialization (revival).

Regarding the file structure, I've named the file `utils/valtio`,
but this can be discussed further. The purpose of this file is
to centralize common utility functions related to Valtio
for better organization and reuse.
2024-12-09 18:14:46 +01:00
lebaudantoine 46934a84d1 🚚(frontend) rename NotificationType items
I found the item names unclear, so I updated them for better clarity.

I also removed the unnecessary 'lowered' item and added
a TODO comment about handling the message received notification,
which is not yet implemented in the code.
2024-12-09 18:14:46 +01:00
lebaudantoine deb9ab1a1d (frontend) create a new primitive Switch
Inspired by Robin's design, I've styled a React Aria
Switch component using our DSFR theme.

This is an initial draft and isn't yet pixel-perfect compared
to Robin's design. It also hasn't been integrated into
the form inputs yet.
2024-12-09 18:14:46 +01:00
renovate[bot] d0fd16d7d2 ⬆️(dependencies) update django to v5.1.4 [SECURITY] 2024-12-08 22:21:18 +01:00
142 changed files with 9416 additions and 9813 deletions
-22
View File
@@ -1,22 +0,0 @@
name: Helmfile lint
run-name: Helmfile lint
on:
pull_request:
branches:
- 'main'
jobs:
helmfile-lint:
runs-on: ubuntu-latest
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "meet,secrets"
+35
View File
@@ -0,0 +1,35 @@
name: Release Chart
run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- ./src/helm/meet/**
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
token: ${{ secrets.GITHUB_TOKEN }}
+7
View File
@@ -301,7 +301,14 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
.PHONY: build-k8s-cluster
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+2
View File
@@ -118,6 +118,8 @@ $ make build-k8s-cluster
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
or
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
+1 -1
View File
@@ -38,7 +38,7 @@ docker_build(
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
migration = '''
set -eu
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -o errexit
CURRENT_DIR=$(pwd)
NAMESPACE=${1:-meet}
SECRET_NAME=${2:-bitwarden-cli-meet}
TEMP_SECRET_FILE=$(mktemp)
cleanup() {
rm -f "${TEMP_SECRET_FILE}"
}
trap cleanup EXIT
# Check if kubectl is available
check_prerequisites() {
if ! command -v kubectl &> /dev/null; then
echo "Error: kubectl is not installed or not in PATH"
exit 1
fi
}
# Check if secret already exists
check_secret_exists() {
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
}
# Collect user input securely
get_user_input() {
echo "Please provide the following information:"
read -p "Enter your Vaultwarden email login: " LOGIN
read -s -p "Enter your Vaultwarden password: " PASSWORD
echo
read -p "Enter your Vaultwarden server url: " URL
}
# Create and apply the secret
create_secret() {
cat > "${TEMP_SECRET_FILE}" << EOF
apiVersion: v1
kind: Secret
metadata:
name: ${SECRET_NAME}
namespace: ${NAMESPACE}
type: Opaque
stringData:
BW_HOST: ${URL}
BW_PASSWORD: ${PASSWORD}
BW_USERNAME: ${LOGIN}
EOF
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
}
# Install external-secrets using Helm
install_external_secrets() {
if ! kubectl get ns external-secrets &>/dev/null; then
echo "Installing external-secrets…"
helm repo add external-secrets https://charts.external-secrets.io
helm upgrade --install external-secrets \
external-secrets/external-secrets \
-n external-secrets \
--create-namespace \
--set installCRDs=true
else
echo "External secrets already deployed"
fi
}
main() {
check_prerequisites
if check_secret_exists; then
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
exit 0
fi
echo -e ${TEMP_SECRET_FILE}
get_user_input
echo -e "\nCreating Vaultwarden secret…"
create_secret
install_external_secrets
echo "Secret installation completed successfully"
}
main "$@"
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
+2 -138
View File
@@ -1,139 +1,3 @@
#!/bin/sh
set -o errexit
#!/usr/bin/env bash
CURRENT_DIR=$(pwd)
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --name visio --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
EOF
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes --name visio); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
rewrite stop {
name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto
}
cache 30
loop
reload
loadbalance
}
EOF
kubectl -n kube-system rollout restart deployments/coredns
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
echo "7. Setup namespace"
kubectl create ns meet
kubectl config set-context --current --namespace=meet
kubectl -n meet create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem"
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
git submodule update --init --recursive
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
#!/usr/bin/env bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
+405 -405
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
livekit:
keys:
devkey: secret
log_level: debug
rtc:
use_external_ip: false
port_range_start: 50000
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
password: pass
turn:
enabled: true
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
tls:
- hosts:
- livekit.127.0.0.1.nip.io
secretName: livekit-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 60
nodeSelector: {}
resources: {}
+120
View File
@@ -0,0 +1,120 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
DJANGO_SECRET_KEY: ThisCouldBeAReallyGoodOrPerhapsABadKeyToUseSometimes
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: meet
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgresql
DB_NAME: meet
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
LIVEKIT_API_KEY: devkey
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/meet.py"
- "meet.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
VITE_PORT: 8080
VITE_HOST: 0.0.0.0
VITE_API_BASE_URL: https://meet.127.0.0.1.nip.io/
replicas: 1
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "latest"
ingress:
enabled: true
host: meet.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
summary:
replicas: 0
celery:
replicas: 0
+7
View File
@@ -0,0 +1,7 @@
auth:
username: dinum
password: pass
database: meet
tls:
enabled: true
autoGenerated: true
+3
View File
@@ -0,0 +1,3 @@
auth:
password: pass
architecture: standalone
+233
View File
@@ -0,0 +1,233 @@
# 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.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
- a LiveKit server (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
$ ./bin/start-kind.sh
0. Create ca
The local CA is already installed in the system trust store! 👍
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
Created a new certificate valid for the following names 📜
- "127.0.0.1.nip.io"
- "*.127.0.0.1.nip.io"
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
It will expire on 23 March 2027 🗓
1. Create registry container unless it already exists
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "visio" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
kubectl cluster-info --context kind-visio
Thanks for using kind! 😊
3. Add the registry config to the nodes
4. Connect the registry to the cluster network if not already connected
5. Document the local registry
configmap/local-registry-hosting created
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/coredns configured
deployment.apps/coredns restarted
6. Install ingress-nginx
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
secret/mkcert created
deployment.apps/ingress-nginx-controller patched
7. Setup namespace
namespace/meet created
Context "kind-visio" modified.
secret/mkcert created
$ kind get clusters
visio
$ kubectl -n ingress-nginx get po
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
```
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### 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).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
```
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction.
```
$ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important information you will need are :
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: meet
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
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:
Livekit need a redis (and meet too) so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
When the redis is ready we can deploy livekit-server.
```
$ helm repo add livekit https://helm.livekit.io
$ helm repo update
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m
keycloak-postgresql-0 1/1 Running 0 30m
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
redis-master-0 1/1 Running 0 4m30s
$ curl https://livekit.127.0.0.1.nip.io
OK
```
From here important information you will need are :
```
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
REDIS_URL: redis://default:pass@redis-master:6379/1
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
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:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m
keycloak-postgresql-0 1/1 Running 0 45m
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
postgresql-0 1/1 Running 0 50s
redis-master-0 1/1 Running 0 19
```
From here important information you will need are :
```
DB_HOST: postgres-postgresql
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.
```
$ helm repo add meet https://numerique-gouv.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
```
## Test your deployment
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
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. The provisioning user in keycloak is meet/meet.
+1 -1
View File
@@ -14,7 +14,7 @@
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"eslint"
"eslint", "react", "react-dom", "@types/react-dom", "@types/react", "react-i18next"
]
}
]
+1 -1
Submodule secrets updated: f5aea0c251...2ba12db71d
+30 -1
View File
@@ -1,7 +1,7 @@
"""Authentication Backends for the Meet core app."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
@@ -10,6 +10,11 @@ from mozilla_django_oidc.auth import (
)
from core.models import User
from core.services.marketing_service import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -86,6 +91,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
password="!", # noqa: S106
**claims,
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
elif not user:
return None
@@ -96,6 +105,26 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return user
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
@@ -0,0 +1,21 @@
# Generated by Django 5.1.4 on 2025-01-13 12:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_alter_recording_status'),
]
operations = [
migrations.AlterModelOptions(
name='resourceaccess',
options={'ordering': ('-created_at',), 'verbose_name': 'Resource access', 'verbose_name_plural': 'Resource accesses'},
),
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]
+2
View File
@@ -189,6 +189,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "meet_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -304,6 +305,7 @@ class ResourceAccess(BaseModel):
class Meta:
db_table = "meet_resource_access"
ordering = ("-created_at",)
verbose_name = _("Resource access")
verbose_name_plural = _("Resource accesses")
constraints = [
@@ -0,0 +1,134 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -1,12 +1,15 @@
"""Unit tests for the Authentication Backends."""
from django.core.exceptions import SuspiciousOperation
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing_service
pytestmark = pytest.mark.django_db
@@ -412,3 +415,139 @@ def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
user.refresh_from_db()
assert user.email == "john.doe@example.com"
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
"""Test marketing signup for new user with settings enabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_called_once_with(email)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
"""Test no marketing signup for new user with settings disabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
"""Test no marketing signup for new user with settings by default disabled."""
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@pytest.mark.parametrize(
"is_signup_enabled",
[True, False],
)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_existing_user(
mock_signup, monkeypatch, settings, is_signup_enabled
):
"""Test no marketing signup for existing user regardless of settings."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="test@example.com")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub, "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user == db_user
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
"""Test successful marketing signup."""
email = "test@example.com"
# Call the method
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing_service.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@@ -0,0 +1,187 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
from core.services.marketing_service import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing_service.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
+1 -1
View File
@@ -66,7 +66,7 @@ def test_api_users_list_query_email():
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(nicole.id), str(frank.id)]
assert user_ids == [str(frank.id), str(nicole.id)]
def test_api_users_retrieve_me_anonymous():
+26 -2
View File
@@ -457,6 +457,28 @@ class Base(Configuration):
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False, # When enabled, new users are automatically added to mailing list.
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
environ_prefix=None,
)
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing_service.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = values.Value(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -503,8 +525,10 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
class Build(Base):
+18 -17
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.10"
version = "0.1.12"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,20 +25,21 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.68",
"boto3==1.36.6",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.2.0",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.3",
"django==5.1.5",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
@@ -47,17 +48,17 @@ dependencies = [
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.0",
"psycopg[binary]==3.2.4",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.19.0",
"sentry-sdk==2.20.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.0",
"aiohttp==3.11.7",
"livekit-api==0.8.1",
"aiohttp==3.11.11",
]
[project.urls]
@@ -69,20 +70,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.11.1",
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.29.0",
"pyfakefs==5.7.1",
"ipython==8.31.0",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.1",
"pylint==3.3.3",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.0",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
]
+2158 -1776
View File
File diff suppressed because it is too large Load Diff
+31 -31
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.10",
"version": "0.1.12",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,48 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.6.9",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.48.0",
"@react-aria/toast": "3.0.0-beta.18",
"@remixicon/react": "4.5.0",
"@tanstack/react-query": "5.61.3",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.51.1",
"@react-aria/toast": "3.0.0-beta.19",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.64.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1",
"i18next": "24.0.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.2",
"hoofd": "1.7.3",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"i18next-parser": "9.1.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.6.3",
"posthog-js": "1.188.0",
"livekit-client": "2.8.1",
"posthog-js": "1.210.2",
"react": "18.3.1",
"react-aria-components": "1.5.0",
"react-aria-components": "1.6.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "4.0.3",
"valtio": "2.1.2",
"wouter": "3.3.5"
"valtio": "2.1.3",
"wouter": "3.4.0"
},
"devDependencies": {
"@pandacss/dev": "0.48.0",
"@tanstack/eslint-plugin-query": "5.61.3",
"@tanstack/react-query-devtools": "5.61.3",
"@types/node": "22.9.3",
"@pandacss/dev": "0.51.1",
"@tanstack/eslint-plugin-query": "5.64.2",
"@tanstack/react-query-devtools": "5.64.2",
"@types/node": "22.10.10",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.15.0",
"@typescript-eslint/parser": "8.15.0",
"@vitejs/plugin-react": "4.3.3",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@vitejs/plugin-react": "4.3.4",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-config-prettier": "10.0.1",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-refresh": "0.4.14",
"postcss": "8.4.49",
"prettier": "3.3.3",
"typescript": "5.7.2",
"vite": "5.4.11",
"vite-tsconfig-paths": "5.1.3"
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-refresh": "0.4.18",
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "6.0.11",
"vite-tsconfig-paths": "5.1.4"
}
}
@@ -2,9 +2,7 @@ import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
const GRIST_FORM =
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
import { GRIST_FORM } from '@/utils/constants'
export const FeedbackBanner = () => {
const { t } = useTranslation()
@@ -9,7 +9,6 @@ import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { ProConnectButton } from '@/components/ProConnectButton'
import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
@@ -18,6 +17,7 @@ import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -32,10 +32,10 @@ const Columns = ({ children }: { children?: ReactNode }) => {
justifyContent: 'normal',
padding: '0 1rem',
width: 'calc(100% - 2rem)',
'@media(prefers-reduced-motion: reduce)': {
_motionReduce: {
opacity: 1,
},
'@media(prefers-reduced-motion: no-preference)': {
_motionSafe: {
opacity: 0,
animation: '.5s ease-in fade 0s forwards',
},
@@ -16,11 +16,11 @@ export const MainNotificationToast = () => {
if (isMobileBrowser()) {
return
}
triggerNotificationSound(NotificationType.Joined)
triggerNotificationSound(NotificationType.ParticipantJoined)
toastQueue.add(
{
participant,
type: NotificationType.Joined,
type: NotificationType.ParticipantJoined,
},
{
timeout: 5000,
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Raised
toast.content.type === NotificationType.HandRaised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.Raised)
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
participant,
type: NotificationType.Raised,
type: NotificationType.HandRaised,
},
{ timeout: 5000 }
)
@@ -1,6 +1,5 @@
export enum NotificationType {
Joined = 'joined',
Default = 'default',
Raised = 'raised',
Lowered = 'lowered',
ParticipantJoined = 'participantJoined',
HandRaised = 'handRaised',
// todo - implement message received notification
}
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
return (
<div {...regionProps} ref={ref} className="toast-region">
{state.visibleToasts.map((toast) => {
if (toast.content?.type === NotificationType.Joined) {
if (toast.content?.type === NotificationType.ParticipantJoined) {
return <ToastJoined key={toast.key} toast={toast} state={state} />
}
if (toast.content?.type === NotificationType.Raised) {
if (toast.content?.type === NotificationType.HandRaised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
@@ -1,18 +1,24 @@
import useSound from 'use-sound'
import { useSnapshot } from 'valtio'
import { notificationsStore } from '@/stores/notifications'
import { NotificationType } from '@/features/notifications/NotificationType'
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('./sounds/notifications.mp3', {
sprite: {
joined: [0, 1150],
raised: [1400, 180],
message: [1580, 300],
participantJoined: [0, 1150],
handRaised: [1400, 180],
messageReceived: [1580, 300],
waiting: [2039, 710],
success: [2740, 1304],
},
volume: notificationsSnap.soundNotificationVolume,
})
const triggerNotificationSound = (type: string) => {
play({ id: type })
const triggerNotificationSound = (type: NotificationType) => {
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
if (isSoundEnabled) play({ id: type })
}
return { triggerNotificationSound }
}
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import { LiveKitRoom } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -12,10 +12,11 @@ import { fetchRoom } from '../api/fetchRoom'
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 { LocalUserChoices } from '../routes/Room'
import { BackgroundBlurFactory } from '../livekit/components/blur'
export const Conference = ({
roomId,
@@ -65,6 +66,11 @@ export const Conference = ({
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
},
@@ -106,7 +112,13 @@ export const Conference = ({
token={data?.livekit?.token}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
video={
userConfig.videoEnabled && {
processor: BackgroundBlurFactory.deserializeProcessor(
userConfig.processorSerialized
),
}
}
connectOptions={connectOptions}
className={css({
backgroundColor: 'primaryDark.50 !important',
@@ -1,30 +1,361 @@
import { useTranslation } from 'react-i18next'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { useUser } from '@/features/auth'
import { useMemo, useEffect, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Dialog } from '@/primitives'
import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import { EffectsConfiguration } from '../livekit/components/effects/EffectsConfiguration'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../livekit/components/blur'
const onError = (e: Error) => console.error('ERROR', e)
export const Join = ({
onSubmit,
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
const { user } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const {
userChoices: initialUserChoices,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices({})
const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId
)
const [videoDeviceId, setVideoDeviceId] = useState<string>(
initialUserChoices.videoDeviceId
)
const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState(
BackgroundBlurFactory.deserializeProcessor(
initialUserChoices.processorSerialized
)
)
useEffect(() => {
saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId])
useEffect(() => {
saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId])
useEffect(() => {
saveUsername(username)
}, [username, saveUsername])
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [processor, saveProcessorSerialized])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const tracks = usePreviewTracks(
{
audio: { deviceId: initialUserChoices.audioDeviceId },
video: { deviceId: initialUserChoices.videoDeviceId },
},
onError
)
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalVideoTrack,
[tracks]
)
const videoEl = useRef(null)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
videoElement.style.opacity = '1'
}
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [videoTrack, videoEnabled])
function handleSubmit() {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}
const [isEffectsOpen, setEffectsOpen] = useState(false)
const openEffects = () => {
setEffectsOpen(true)
}
// 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])
return (
<Screen layout="centered" footer={false}>
<CenteredContent title={t('join.heading')}>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.usernameLabel')}
defaults={{ username: user?.full_name }}
<Screen footer={false}>
<Dialog
isOpen={isEffectsOpen}
onOpenChange={setEffectsOpen}
role="dialog"
type="flex"
size="large"
>
<Heading
slot="title"
level={1}
className={css({
textStyle: 'h1',
marginBottom: '1.5rem',
})}
>
{t('effects.title')}
</Heading>
<EffectsConfiguration
videoTrack={videoTrack}
onSubmit={(processor) => {
setProcessor(processor)
}}
/>
</CenteredContent>
</Dialog>
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
flexGrow: 1,
lg: {
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
width: '100%',
lg: {
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
overflow: 'hidden',
position: 'relative',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})}
>
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
display: 'block',
width: '102%',
height: '102%',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
})}
/>
) : (
<div
className={css({
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
)}
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(255,255,255,0) 100%)',
})}
></div>
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
})}
>
<Button
variant="whiteCircle"
onPress={openEffects}
tooltip={t('effects.description')}
aria-label={t('effects.description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={initialUserChoices.audioDeviceId}
onChange={(enabled) => setAudioEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setAudioDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={initialUserChoices.videoDeviceId}
onChange={(enabled) => setVideoEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin={false}>
{t('heading')}
</H>
<Field
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
</Form>
</div>
</div>
</div>
</Screen>
)
}
@@ -1,189 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { Text, P, ToggleButton, Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import {
BackgroundBlur,
BackgroundOptions,
ProcessorWrapper,
BackgroundTransformer,
} from '@livekit/track-processors'
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
enum BlurRadius {
NONE = 0,
LIGHT = 5,
NORMAL = 10,
}
export const Effects = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { isCameraEnabled, cameraTrack, localParticipant } =
useLocalParticipant()
const videoRef = useRef<HTMLVideoElement>(null)
const [processorPending, setProcessorPending] = useState(false)
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const getProcessor = () => {
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
}
const getBlurRadius = (): BlurRadius => {
const processor = getProcessor()
return (
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
BlurRadius.NONE
)
}
const toggleBlur = async (blurRadius: number) => {
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
if (!localCameraTrack) return
setProcessorPending(true)
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
await localCameraTrack.stopProcessor()
} else if (!processor) {
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
} else {
await processor?.updateTransformerOptions({ blurRadius })
}
} catch (error) {
console.error('Error applying blur:', error)
} finally {
setProcessorPending(false)
}
}
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
attachVideoTrack()
return () => {
if (!videoElement) return
localCameraTrack.detach(videoElement)
}
}, [localCameraTrack, isCameraEnabled])
const isSelected = (blurRadius: BlurRadius) => {
return isCameraEnabled && getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
}
return (
<VStack padding="0 1.5rem" overflowY="scroll">
{localCameraTrack && isCameraEnabled ? (
<video
ref={videoRef}
width="100%"
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '174px',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<P
style={{
color: 'white',
textAlign: 'center',
textWrap: 'balance',
marginBottom: 0,
}}
>
{t('activateCamera')}
</P>
</div>
)}
<Div
alignItems={'left'}
width={'100%'}
style={{
border: '1px solid #dadce0',
borderRadius: '8px',
margin: '0 .625rem',
padding: '0.5rem 1rem',
}}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
}}
>
{t('heading')}
</H>
{ProcessorWrapper.isSupported ? (
<HStack>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.LIGHT)}
tooltip={tooltipLabel(BlurRadius.LIGHT)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
isSelected={isSelected(BlurRadius.LIGHT)}
>
{t('blur.light')}
</ToggleButton>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.NORMAL)}
tooltip={tooltipLabel(BlurRadius.NORMAL)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
isSelected={isSelected(BlurRadius.NORMAL)}
>
{t('blur.normal')}
</ToggleButton>
</HStack>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
)}
<Information>
<Text
variant="sm"
style={{
textWrap: 'balance',
}}
>
{t('experimental')}
</Text>
</Information>
</Div>
</VStack>
)
}
@@ -8,9 +8,9 @@ import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
import { Effects } from './effects/Effects'
type StyledSidePanelProps = {
title: string
@@ -0,0 +1,296 @@
import { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
ImageSegmenter,
ImageSegmenterResult,
} from '@mediapipe/tasks-vision'
import {
CLEAR_TIMEOUT,
SET_TIMEOUT,
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundBlurProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
const SEGMENTATION_MASK_CANVAS_ID = 'background-blur-local-segmentation'
const BLUR_CANVAS_ID = 'background-blur-local'
const DEFAULT_BLUR = '10'
/**
* This implementation of video blurring is made to be run on CPU for browser that are
* not compatible with track-processor-js.
*
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
* MediaStreamTrackProcessor.
*/
export class BackgroundBlurCustomProcessor
implements BackgroundBlurProcessorInterface
{
options: BackgroundOptions
name: string
processedTrack?: MediaStreamTrack | undefined
source?: MediaStreamTrack
sourceSettings?: MediaTrackSettings
videoElement?: HTMLVideoElement
videoElementLoaded?: boolean
// Canvas containg the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D
imageSegmenter?: ImageSegmenter
imageSegmenterResult?: ImageSegmenterResult
// Canvas used for resizing video source and projecting mask.
segmentationMaskCanvas?: HTMLCanvasElement
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
// Mask containg the inference result.
segmentationMask?: ImageData
// The resized image of the video source.
sourceImageData?: ImageData
timerWorker?: Worker
constructor(opts: BackgroundOptions) {
this.name = 'blur'
this.options = opts
}
static get isSupported() {
return navigator.userAgent.toLowerCase().includes('firefox')
}
async init(opts: ProcessorOptions<Track.Kind>) {
if (!opts.element) {
throw new Error('Element is required for processing')
}
this.source = opts.track as MediaStreamTrack
this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement
this._createMainCanvas()
this._createMaskCanvas()
const stream = this.outputCanvas!.captureStream()
const tracks = stream.getVideoTracks()
if (tracks.length == 0) {
throw new Error('No tracks found for processing')
}
this.processedTrack = tracks[0]
this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT)
await this.initSegmenter()
this._initWorker()
posthog.capture('firefox-blurring-init')
}
update(opts: BackgroundOptions): void {
this.options = opts
}
_initWorker() {
this.timerWorker = new Worker(timerWorkerScript, {
name: 'Blurring',
})
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
// When hiding camera then showing it again, the onloadeddata callback is not fired again.
if (this.videoElementLoaded) {
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
} else {
this.videoElement!.onloadeddata = () => {
this.videoElementLoaded = true
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
}
}
}
onTimerMessage(response: { data: { id: number } }) {
if (response.data.id === TIMEOUT_TICK) {
this.process()
}
}
async initSegmenter() {
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
)
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
delegate: 'CPU', // Use CPU for Firefox.
},
runningMode: 'VIDEO',
outputCategoryMask: true,
outputConfidenceMasks: false,
})
}
/**
* Resize the source video to the processing resolution.
*/
async sizeSource() {
this.segmentationMaskCanvasCtx?.drawImage(
this.videoElement!,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
this.sourceImageData = this.segmentationMaskCanvasCtx?.getImageData(
0,
0,
PROCESSING_WIDTH,
PROCESSING_WIDTH
)
}
/**
* Run the segmentation.
*/
async segment() {
const startTimeMs = performance.now()
return new Promise<void>((resolve) => {
this.imageSegmenter!.segmentForVideo(
this.sourceImageData!,
startTimeMs,
(result: ImageSegmenterResult) => {
this.imageSegmenterResult = result
resolve()
}
)
})
}
/**
* TODO: future improvement with WebGL.
*/
async blur() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
}
async process() {
await this.sizeSource()
await this.segment()
await this.blur()
this.timerWorker!.postMessage({
id: SET_TIMEOUT,
timeMs: 1000 / 30,
})
}
_createMainCanvas() {
this.outputCanvas = document.querySelector(
'canvas#background-blur-local'
) as HTMLCanvasElement
if (!this.outputCanvas) {
this.outputCanvas = this._createCanvas(
BLUR_CANVAS_ID,
this.sourceSettings!.width!,
this.sourceSettings!.height!
)
}
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
}
_createMaskCanvas() {
this.segmentationMaskCanvas = document.querySelector(
`#${SEGMENTATION_MASK_CANVAS_ID}`
) as HTMLCanvasElement
if (!this.segmentationMaskCanvas) {
this.segmentationMaskCanvas = this._createCanvas(
SEGMENTATION_MASK_CANVAS_ID,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
)
}
this.segmentationMaskCanvasCtx =
this.segmentationMaskCanvas.getContext('2d')!
}
_createCanvas(id: string, width: number, height: number) {
const element = document.createElement('canvas')
element.setAttribute('id', id)
element.setAttribute('width', '' + width)
element.setAttribute('height', '' + height)
return element
}
async restart(opts: ProcessorOptions<Track.Kind>) {
await this.destroy()
return this.init(opts)
}
async destroy() {
this.timerWorker?.postMessage({
id: CLEAR_TIMEOUT,
})
this.timerWorker?.terminate()
this.imageSegmenter?.close()
}
clone() {
return new BackgroundBlurCustomProcessor(this.options)
}
serialize() {
return {
type: ProcessorType.BLUR,
options: this.options,
}
}
}
@@ -0,0 +1,68 @@
import {
BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper,
} from '@livekit/track-processors'
import { ProcessorOptions, Track } from 'livekit-client'
import {
BackgroundBlurProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
/**
* This is simply a wrapper around track-processor-js Processor
* in order to be compatible with a common interface BackgroundBlurProcessorInterface
* used accross the project.
*/
export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundBlurProcessorInterface
{
name: string = 'Blur'
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
constructor(opts: BackgroundOptions) {
this.processor = BackgroundBlur(opts.blurRadius)
this.opts = opts
}
async init(opts: ProcessorOptions<Track.Kind>) {
return this.processor.init(opts)
}
async restart(opts: ProcessorOptions<Track.Kind>) {
return this.processor.restart(opts)
}
async destroy() {
return this.processor.destroy()
}
update(opts: BackgroundOptions): void {
this.processor.updateTransformerOptions(opts)
}
get processedTrack() {
return this.processor.processedTrack
}
get options() {
return (this.processor.transformer as BackgroundTransformer).options
}
clone() {
return new BackgroundBlurTrackProcessorJsWrapper({
blurRadius: this.options!.blurRadius,
})
}
serialize() {
return {
type: ProcessorType.BLUR,
options: this.options,
}
}
}
@@ -0,0 +1,71 @@
/**
* From https://github.com/jitsi/jitsi-meet
*/
/**
* SET_TIMEOUT constant is used to set interval and it is set in
* the id property of the request.data property. TimeMs property must
* also be set.
*
* ```
* //Request.data example:
* {
* id: SET_TIMEOUT,
* timeMs: 33
* }
* ```
*/
export const SET_TIMEOUT = 1
/**
* CLEAR_TIMEOUT constant is used to clear the interval and it is set in
* the id property of the request.data property.
*
* ```
* {
* id: CLEAR_TIMEOUT
* }
* ```
*/
export const CLEAR_TIMEOUT = 2
/**
* TIMEOUT_TICK constant is used as response and it is set in the id property.
*
* ```
* {
* id: TIMEOUT_TICK
* }
* ```
*/
export const TIMEOUT_TICK = 3
/**
* The following code is needed as string to create a URL from a Blob.
* The URL is then passed to a WebWorker. Reason for this is to enable
* use of setInterval that is not throttled when tab is inactive.
*/
const code = `
var timer;
onmessage = function(request) {
switch (request.data.id) {
case ${SET_TIMEOUT}: {
timer = setTimeout(() => {
postMessage({ id: ${TIMEOUT_TICK} });
}, request.data.timeMs);
break;
}
case ${CLEAR_TIMEOUT}: {
if (timer) {
clearTimeout(timer);
}
break;
}
}
};
`
export const timerWorkerScript = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' })
)
@@ -0,0 +1,52 @@
import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor'
export type BackgroundOptions = {
blurRadius?: number
}
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export interface BackgroundBlurProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void
options: BackgroundOptions
clone(): BackgroundBlurProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType {
BLUR = 'blur',
}
export class BackgroundBlurFactory {
static isSupported() {
return (
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
)
}
static getProcessor(
opts: BackgroundOptions
): BackgroundBlurProcessorInterface | undefined {
if (ProcessorWrapper.isSupported) {
return new BackgroundBlurTrackProcessorJsWrapper(opts)
}
if (BackgroundBlurCustomProcessor.isSupported) {
return new BackgroundBlurCustomProcessor(opts)
}
return undefined
}
static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type === ProcessorType.BLUR) {
return BackgroundBlurFactory.getProcessor(data?.options)
}
return undefined
}
}
@@ -0,0 +1,123 @@
import { Button } from '@/primitives'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { RiCameraSwitchLine } from '@remixicon/react'
import { useEffect, useState } from 'react'
import { ButtonProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
enum FacingMode {
USER = 'user',
ENVIRONMENT = 'environment',
}
export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
const { t } = useTranslation('rooms')
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({
kind: 'videoinput',
requestPermissions: true,
})
// getCapabilities type is not available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getDeviceFacingMode = (device: any): string[] => {
if (!device.getCapabilities) {
return []
}
const capabilities = device.getCapabilities()
if (!capabilities) {
return []
}
if (typeof capabilities.facingMode !== 'object') {
return []
}
return capabilities.facingMode
}
const detectCurrentFacingMode = (): FacingMode | null => {
if (!activeDeviceId) {
return null
}
const activeDevice = devices.find(
(device) => device.deviceId === activeDeviceId
)
if (!activeDevice) {
return null
}
const facingMode = getDeviceFacingMode(activeDevice)
if (facingMode.indexOf(FacingMode.USER) >= 0) {
return FacingMode.USER
}
if (facingMode.indexOf(FacingMode.ENVIRONMENT) >= 0) {
return FacingMode.ENVIRONMENT
}
return null
}
const guessCurrentFacingMode = () => {
const facingMode = detectCurrentFacingMode()
if (facingMode) {
return facingMode
}
// We consider by default if we have no clue that the user camera is used.
return FacingMode.USER
}
const [facingMode, setFacingMode] = useState<FacingMode | null>()
/**
* Before setting the initial value of facingMode we need to wait for devices to
* be loaded ( because in detectCurrentFacingMode we need to find the active device
* in the devices list ), which is not the case at first render.
*/
useEffect(() => {
if (devices.length == 0) {
return
}
if (facingMode) {
return
}
setFacingMode(guessCurrentFacingMode())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [devices])
const getUserDevice = (
facingMode: FacingMode
): MediaDeviceInfo | undefined => {
return devices.find((device) => {
return getDeviceFacingMode(device).indexOf(facingMode) >= 0
})
}
const toggle = () => {
let target: FacingMode
if (facingMode === FacingMode.USER) {
target = FacingMode.ENVIRONMENT
} else {
target = FacingMode.USER
}
const device = getUserDevice(target)
if (device) {
setActiveMediaDevice(device.deviceId)
setFacingMode(target)
} else {
console.error('Cannot get user device with facingMode ' + target)
}
}
return (
<Button
onPress={(e) => {
toggle()
props.onPress?.(e)
}}
variant="primaryTextDark"
aria-label={t('options.items.switchCamera')}
tooltip={t('options.items.switchCamera')}
description={true}
>
<RiCameraSwitchLine size={20} />
</Button>
)
}
@@ -5,8 +5,12 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const ChatToggle = () => {
export const ChatToggle = ({
onPress,
...props
}: Partial<ToggleButtonProps>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const chatSnap = useSnapshot(chatStore)
@@ -27,8 +31,12 @@ export const ChatToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
onPress={() => toggleChat()}
onPress={(e) => {
toggleChat()
onPress?.(e)
}}
data-attr={`controls-chat-${tooltipLabel}`}
{...props}
>
<RiChat1Line />
</ToggleButton>
@@ -0,0 +1,20 @@
import { RiAccountBoxLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '../../../hooks/useSidePanel'
export const EffectsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
return (
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
)
}
@@ -0,0 +1,20 @@
import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FORM } from '@/utils/constants'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
return (
<MenuItem
href={GRIST_FORM}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiMegaphoneLine size={20} />
{t('feedback')}
</MenuItem>
)
}
@@ -0,0 +1,34 @@
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useFullScreen } from '../../../hooks/useFullScreen'
export const FullScreenMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleFullScreen, isCurrentlyFullscreen, isFullscreenAvailable } =
useFullScreen()
if (!isFullscreenAvailable) {
return null
}
return (
<MenuItem
onAction={() => toggleFullScreen()}
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
{isCurrentlyFullscreen ? (
<>
<RiFullscreenExitLine size={20} />
{t('fullscreen.exit')}
</>
) : (
<>
<RiFullscreenLine size={20} />
{t('fullscreen.enter')}
</>
)}
</MenuItem>
)
}
@@ -1,19 +1,14 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { useState } from 'react'
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
export type DialogState = 'username' | 'settings' | null
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<Menu>
<Menu variant="dark">
<Button
square
variant="primaryDark"
@@ -22,12 +17,8 @@ export const OptionsButton = () => {
>
<RiMore2Line />
</Button>
<OptionsMenuItems onOpenDialog={setDialogOpen} />
<OptionsMenuItems />
</Menu>
<SettingsDialogExtended
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}
@@ -1,24 +1,12 @@
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { FullScreenMenuItem } from './FullScreenMenuItem'
import { SettingsMenuItem } from './SettingsMenuItem'
import { FeedbackMenuItem } from './FeedbackMenuItem'
import { EffectsMenuItem } from './EffectsMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
onOpenDialog,
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
export const OptionsMenuItems = () => {
return (
<RACMenu
style={{
@@ -26,33 +14,15 @@ export const OptionsMenuItems = ({
width: '300px',
}}
>
<Section>
<MenuItem
onAction={() => toggleEffects()}
className={menuRecipe({ icon: true }).item}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
</Section>
<MenuSection>
<FullScreenMenuItem />
<EffectsMenuItem />
</MenuSection>
<Separator />
<Section>
<MenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuRecipe({ icon: true }).item}
>
<RiMegaphoneLine size={20} />
{t('feedbacks')}
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true }).item}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
</Section>
<MenuSection>
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
}
@@ -0,0 +1,20 @@
import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '../SettingsDialogContext'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { setDialogOpen } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
)
}
@@ -118,7 +118,6 @@ export const ParticipantListItem = ({
<HStack
role="listitem"
justify="space-between"
key={participant.identity}
id={participant.identity}
className={css({
padding: '0.25rem 0',
@@ -56,7 +56,10 @@ export const ParticipantsList = () => {
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem participant={participant} />
<HandRaisedListItem
key={participant.identity}
participant={participant}
/>
)}
action={() => (
<LowerAllHandsButton participants={raisedHandParticipants} />
@@ -68,7 +71,10 @@ export const ParticipantsList = () => {
heading={t('contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem participant={participant} />
<ParticipantListItem
key={participant.identity}
participant={participant}
/>
)}
/>
</Div>
@@ -4,8 +4,12 @@ import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const ParticipantsToggle = () => {
export const ParticipantsToggle = ({
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
/**
@@ -33,8 +37,12 @@ export const ParticipantsToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
@@ -4,13 +4,21 @@ import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Track } from 'livekit-client'
import React from 'react'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const ScreenShareToggle = (
props: Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
>
) => {
type Props = Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
> &
Pick<NonNullable<ButtonRecipeProps>, 'variant'> &
ToggleButtonProps
export const ScreenShareToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
const { buttonProps, enabled } = useTrackToggle({
...props,
@@ -26,23 +34,21 @@ export const ScreenShareToggle = (
<ToggleButton
isSelected={enabled}
square
variant="primaryDark"
variant={variant}
tooltip={t(tooltipLabel)}
onPress={(e) =>
onPress={(e) => {
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
)
}
style={{
maxWidth: '46px',
maxHeight: '46px',
onPress?.(e)
}}
data-attr={`controls-screenshare-${tooltipLabel}`}
{...props}
>
<Div position="relative">
<RiRectangleLine size={28} />
<RiRectangleLine size={24} />
<Icon
size={16}
size={14}
style={{
position: 'absolute',
top: '50%',
@@ -13,12 +13,21 @@ import {
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../blur'
export type ToggleSource = Exclude<
Track.Source,
@@ -65,12 +74,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
@@ -80,8 +99,53 @@ export const SelectToggleDevice = <T extends ToggleSource>({
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices({})
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundBlurFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind })
useMediaDeviceSelect({ kind: config.kind, track })
/**
* When providing only track outside of a room context, activeDeviceId is undefined.
* So we need to initialize it with the initialDeviceId.
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
*/
useEffect(() => {
if (initialDeviceId !== undefined) {
setActiveMediaDevice(initialDeviceId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActiveMediaDevice])
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
@@ -92,29 +156,44 @@ export const SelectToggleDevice = <T extends ToggleSource>({
gap: '1px',
})}
>
<ToggleDevice {...trackProps} config={config} />
<Menu>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
/>
</Menu>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}
@@ -0,0 +1,36 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}
@@ -3,10 +3,15 @@ import { RiQuestionLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
export const SupportToggle = () => {
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
const [isOpened, setIsOpened] = useState(() => {
return window?.$crisp?.is?.('chat:opened') || false
})
useEffect(() => {
if (!Crisp) {
@@ -25,6 +30,12 @@ export const SupportToggle = () => {
}
}, [])
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled) {
return
}
return (
<ToggleButton
square
@@ -32,8 +43,16 @@ export const SupportToggle = () => {
tooltip={t('support')}
aria-label={t('support')}
isSelected={isOpened}
onPress={() => (isOpened ? Crisp.chat.close() : Crisp.chat.open())}
onPress={(e) => {
if (isOpened) {
Crisp.chat.close()
} else {
Crisp.chat.open()
}
onPress?.(e)
}}
data-attr="controls-support"
{...props}
>
<RiQuestionLine />
</ToggleButton>
@@ -6,18 +6,28 @@ import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -48,24 +58,29 @@ export const ToggleDevice = ({
const Icon = enabled ? iconOn : iconOff
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
if (kind === 'audioinput' && pushToTalk) {
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
return (
<ToggleButton
isSelected={!enabled}
variant={enabled ? 'primaryDark' : 'error2'}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -4,8 +4,13 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = () => {
export const TranscriptToggle = ({
variant = 'primaryTextDark',
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
@@ -24,11 +29,15 @@ export const TranscriptToggle = () => {
>
<ToggleButton
square
variant="primaryTextDark"
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
onPress={() => toggleTranscript()}
onPress={(e) => {
toggleTranscript()
onPress?.(e)
}}
{...props}
>
<RiBardLine />
</ToggleButton>
@@ -0,0 +1,27 @@
import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
return (
<div
className={css({
padding: '0 1.5rem',
})}
>
<EffectsConfiguration
videoTrack={localCameraTrack}
layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
)
}
@@ -0,0 +1,211 @@
import { LocalVideoTrack } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
BackgroundBlurFactory,
BackgroundBlurProcessorInterface,
} from '../blur'
import { css } from '@/styled-system/css'
import { Text, P, ToggleButton, H } from '@/primitives'
import { HStack, styled } from '@/styled-system/jsx'
enum BlurRadius {
NONE = 0,
LIGHT = 5,
NORMAL = 10,
}
const isSupported = BackgroundBlurFactory.isSupported()
const Information = styled('div', {
base: {
backgroundColor: 'orange.50',
borderRadius: '4px',
padding: '0.75rem 0.75rem',
marginTop: '0.8rem',
alignItems: 'start',
},
})
export const EffectsConfiguration = ({
videoTrack,
onSubmit,
layout = 'horizontal',
}: {
videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundBlurProcessorInterface) => void
layout?: 'vertical' | 'horizontal'
}) => {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const [processorPending, setProcessorPending] = useState(false)
useEffect(() => {
const videoElement = videoRef.current
if (!videoElement) return
const attachVideoTrack = async () => videoTrack?.attach(videoElement)
attachVideoTrack()
return () => {
if (!videoElement) return
videoTrack.detach(videoElement)
}
}, [videoTrack, videoTrack?.isMuted])
const toggleBlur = async (blurRadius: number) => {
if (!videoTrack) return
setProcessorPending(true)
const processor = getProcessor()
const currentBlurRadius = getBlurRadius()
try {
if (blurRadius == currentBlurRadius && processor) {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
} else if (!processor) {
const newProcessor = BackgroundBlurFactory.getProcessor({ blurRadius })!
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
processor?.update({ blurRadius })
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
} catch (error) {
console.error('Error applying blur:', error)
} finally {
setProcessorPending(false)
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundBlurProcessorInterface
}
const getBlurRadius = (): BlurRadius => {
const processor = getProcessor()
return processor?.options.blurRadius || BlurRadius.NONE
}
const isSelected = (blurRadius: BlurRadius) => {
return getBlurRadius() == blurRadius
}
const tooltipLabel = (blurRadius: BlurRadius) => {
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
}
return (
<div
className={css(
layout === 'vertical'
? {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}
: {
display: 'flex',
gap: '1.5rem',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
}
)}
>
<div
className={css({
width: '100%',
aspectRatio: 16 / 9,
})}
>
{videoTrack && !videoTrack.isMuted ? (
<video
ref={videoRef}
width="100%"
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
borderRadius: '8px',
}}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: 'black',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<P
style={{
color: 'white',
textAlign: 'center',
textWrap: 'balance',
marginBottom: 0,
}}
>
{t('activateCamera')}
</P>
</div>
)}
</div>
<div
className={css(
layout === 'horizontal'
? {
md: {
borderLeft: '1px solid #dadce0',
paddingLeft: '1.5rem',
},
}
: {}
)}
>
<H
lvl={3}
style={{
marginBottom: '0.4rem',
fontWeight: 'bold',
}}
>
{t('heading')}
</H>
{isSupported ? (
<HStack>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.LIGHT)}
tooltip={tooltipLabel(BlurRadius.LIGHT)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
isSelected={isSelected(BlurRadius.LIGHT)}
>
{t('blur.light')}
</ToggleButton>
<ToggleButton
size={'sm'}
aria-label={tooltipLabel(BlurRadius.NORMAL)}
tooltip={tooltipLabel(BlurRadius.NORMAL)}
isDisabled={processorPending}
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
isSelected={isSelected(BlurRadius.NORMAL)}
>
{t('blur.normal')}
</ToggleButton>
</HStack>
) : (
<Text variant="sm">{t('notAvailable')}</Text>
)}
<Information>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</div>
</div>
)
}
@@ -0,0 +1,68 @@
// We use vendor prefix properties
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useState } from 'react'
export function useFullScreen() {
const getIsFullscreen = () => {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
)
}
const [isFullscreenAvailable] = useState(
() =>
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled
)
const enterFullscreen = async () => {
try {
const docEl = document.documentElement
if (docEl.requestFullscreen) {
await docEl.requestFullscreen()
} else if (docEl.webkitRequestFullscreen) {
await docEl.webkitRequestFullscreen()
} else if (docEl.msRequestFullscreen) {
await docEl.msRequestFullscreen()
}
} catch (error) {
console.error('Error entering fullscreen:', error)
}
}
const exitFullscreen = async () => {
try {
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
await document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
await document.msExitFullscreen()
}
} catch (error) {
console.error('Error exiting fullscreen:', error)
}
}
const toggleFullScreen = async () => {
const isCurrentlyFullscreen = getIsFullscreen()
if (isCurrentlyFullscreen) {
await exitFullscreen()
} else {
await enterFullscreen()
}
}
return {
isCurrentlyFullscreen: getIsFullscreen(),
isFullscreenAvailable,
toggleFullScreen,
}
}
@@ -0,0 +1,56 @@
import { UsePersistentUserChoicesOptions } from '@livekit/components-react'
import React from 'react'
import { LocalUserChoices } from '../../routes/Room'
import { saveUserChoices, loadUserChoices } from '@livekit/components-core'
import { ProcessorSerialized } from '../components/blur'
/**
* From @livekit/component-react
*
* A hook that provides access to user choices stored in local storage, such as
* selected media devices and their current state (on or off), as well as the user name.
* @alpha
*/
export function usePersistentUserChoices(
options: UsePersistentUserChoicesOptions = {}
) {
const [userChoices, setSettings] = React.useState<LocalUserChoices>(
loadUserChoices(options.defaults, options.preventLoad ?? false)
)
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }))
}, [])
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }))
}, [])
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }))
}, [])
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }))
}, [])
const saveUsername = React.useCallback((username: string) => {
setSettings((prev) => ({ ...prev, username: username }))
}, [])
const saveProcessorSerialized = React.useCallback(
(processorSerialized?: ProcessorSerialized) => {
setSettings((prev) => ({ ...prev, processorSerialized }))
},
[]
)
React.useEffect(() => {
saveUserChoices(userChoices, options.preventSave ?? false)
}, [userChoices, options.preventSave])
return {
userChoices,
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
saveUsername,
saveProcessorSerialized,
}
}
@@ -1,166 +0,0 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import { usePersistentUserChoices } from '@livekit/components-react'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '../components/controls/ChatToggle'
import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { SupportToggle } from '../components/controls/SupportToggle'
import { TranscriptToggle } from '../components/controls/TranscriptToggle'
import { css } from '@/styled-system/css'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
return (
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
</div>
)
}
@@ -0,0 +1,103 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
}
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
@@ -0,0 +1,94 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
export function DesktopControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
<>
<div
ref={desktopControlBarEl}
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<MoreOptions parentElement={desktopControlBarEl} />
</div>
</>
)
}
@@ -0,0 +1,186 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
export function MobileControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
justifyContent: 'center',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '330px',
})}
>
<LeaveButton />
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</div>
<ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
})}
>
<div
className={css({
flexGrow: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gridGap: '1rem',
'& > *': {
alignSelf: 'center',
justifySelf: 'center',
},
})}
>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
variant="primaryTextDark"
description={true}
onPress={() => setIsMenuOpened(false)}
/>
)}
<ChatToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<TranscriptToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<SupportToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button
onPress={() => {
toggleEffects()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.effects')}
tooltip={t('options.items.effects')}
description={true}
>
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
<Button
onPress={() => {
setDialogOpen(true)
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.settings')}
tooltip={t('options.items.settings')}
description={true}
>
<RiSettings3Line size={20} />
</Button>
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
</div>
</div>
</ResponsiveMenu>
</>
)
}
@@ -0,0 +1,87 @@
import { css } from '@/styled-system/css'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react'
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
import { Button } from '@/primitives'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const CONTROL_BAR_BREAKPOINT = 750
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
<>
<ChatToggle onPress={onPress} />
<ParticipantsToggle onPress={onPress} />
<TranscriptToggle onPress={onPress} />
<SupportToggle onPress={onPress} />
</>
)
export const LateralMenu = () => {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(false)
const handlePress = () => setIsOpen(!isOpen)
const handleClose = () => setIsOpen(false)
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
tooltip={t('controls.moreOptions')}
onPress={handlePress}
>
{isOpen ? <RiArrowDownSLine /> : <RiArrowUpSLine />}
</Button>
<Popover>
<Dialog
className={css({
width: '65px',
backgroundColor: 'primaryDark.50',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
borderRadius: '4px',
paddingTop: '10px',
gap: '0.5rem',
})}
>
<NavigationControls onPress={handleClose} />
</Dialog>
</Popover>
</DialogTrigger>
)
}
export const MoreOptions = ({
parentElement,
}: {
parentElement: RefObject<HTMLDivElement>
}) => {
const { width: parentWidth } = useSize(parentElement)
return (
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
{parentWidth > CONTROL_BAR_BREAKPOINT ? (
<NavigationControls />
) : (
<LateralMenu />
)}
</div>
)
}
@@ -0,0 +1,56 @@
import { css } from '@/styled-system/css'
import { PropsWithChildren } from 'react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
interface ResponsiveMenuProps extends PropsWithChildren {
isOpened: boolean
onClosed: () => void
}
export function ResponsiveMenu({
isOpened,
onClosed,
children,
}: ResponsiveMenuProps) {
return (
<ModalOverlay
isDismissable
isOpen={isOpened}
onOpenChange={(isOpened) => {
if (!isOpened) {
onClosed()
}
}}
className={css({
width: '100vw',
height: 'var(--visual-viewport-height)',
zIndex: 100,
justifyContent: 'center',
alignItems: 'flex-end',
display: 'flex',
position: 'fixed',
top: 0,
left: 0,
padding: '1.5rem 1.5rem 1rem 1.5rem',
boxSizing: 'border-box',
})}
>
<Modal
className={css({
backgroundColor: 'primaryDark.200',
borderRadius: '20px',
flexGrow: 1,
padding: '1.5rem',
'&[data-entering]': {
animation: 'slide-full 200ms',
},
'&[data-exiting]': {
animation: 'slide-full 200ms reverse',
},
})}
>
<Dialog>{children}</Dialog>
</Modal>
</ModalOverlay>
)
}
@@ -20,7 +20,7 @@ import {
useCreateLayoutContext,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
type LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
@@ -9,6 +9,11 @@ import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import { ProcessorSerialized } from '../livekit/components/blur'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
}
export const Room = () => {
const { isLoggedIn } = useUser()
@@ -6,12 +6,14 @@ import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
RiAccountCircleLine,
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
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'
@@ -81,12 +83,17 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id="4">
<RiNotification3Line />
{isWideScreen && t('tabs.notifications')}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
</div>
</Tabs>
</Dialog>
@@ -1,15 +1,13 @@
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
import { Trans, useTranslation } from 'react-i18next'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
import { useRoomContext } from '@livekit/components-react'
import { logoutUrl, useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { ProConnectButton } from '@/components/ProConnectButton'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -0,0 +1,41 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { H, Switch } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { notificationsStore } from '@/stores/notifications'
export type NotificationsTabProps = Pick<TabPanelProps, 'id'>
export const NotificationsTab = ({ id }: NotificationsTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'notifications' })
const notificationsSnap = useSnapshot(notificationsStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
{Array.from(notificationsSnap.soundNotifications).map(
([key, value]) => (
<li key={key}>
<Switch
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
isSelected={value}
onChange={(v) => {
notificationsStore.soundNotifications.set(key, v)
}}
>
{t(`items.${key}`)}
</Switch>
</li>
)
)}
</ul>
</TabPanel>
)
}
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
import { useConfig } from '@/api/useConfig'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
@@ -29,3 +30,8 @@ export const useSupport = ({ id }: useSupportProps) => {
return null
}
export const useIsSupportEnabled = () => {
const { data } = useConfig()
return !!data?.support?.id
}
+15 -4
View File
@@ -24,7 +24,12 @@
"toggleOff": "",
"toggleOn": "",
"usernameHint": "",
"usernameLabel": ""
"usernameLabel": "",
"errors": {
"usernameEmpty": ""
},
"cameraDisabled": "",
"cameraStarting": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
@@ -75,15 +80,21 @@
"open": "",
"closed": ""
},
"support": ""
"support": "",
"moreOptions": ""
},
"options": {
"buttonLabel": "",
"items": {
"feedbacks": "",
"feedback": "",
"settings": "",
"username": "",
"effects": ""
"effects": "",
"switchCamera": "",
"fullscreen": {
"enter": "",
"exit": ""
}
}
},
"effects": {
+15 -1
View File
@@ -19,6 +19,19 @@
},
"permissionsRequired": ""
},
"notifications": {
"heading": "",
"label": "",
"actions": {
"disable": "",
"enable": ""
},
"items": {
"participantJoined": "",
"handRaised": "",
"messageReceived": ""
}
},
"dialog": {
"heading": ""
},
@@ -30,6 +43,7 @@
"tabs": {
"account": "",
"audio": "",
"general": ""
"general": "",
"notifications": ""
}
}
+21 -6
View File
@@ -18,13 +18,22 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"heading": "Verify your settings",
"effects": {
"description": "Apply effects",
"title": "Effects"
},
"heading": "Join the meeting",
"joinLabel": "Join",
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"usernameLabel": "Your name",
"errors": {
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting."
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
@@ -74,20 +83,26 @@
"open": "Hide AI assistant",
"closed": "Show AI assistant"
},
"support": "Support"
"support": "Support",
"moreOptions": "More options"
},
"options": {
"buttonLabel": "More Options",
"items": {
"feedbacks": "Give us feedbacks",
"feedback": "Give us feedback",
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects"
"effects": "Apply effects",
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
"exit": "Exit fullscreen mode"
}
}
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome :(",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
"heading": "Blur",
"blur": {
"light": "Light blur",
+15 -1
View File
@@ -19,6 +19,19 @@
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
"actions": {
"disable": "Disable",
"enable": "Enable"
},
"items": {
"participantJoined": "Participant joined",
"handRaised": "Hand raised",
"messageReceived": "Message received"
}
},
"dialog": {
"heading": "Settings"
},
@@ -30,6 +43,7 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "General"
"general": "General",
"notifications": "Notifications"
}
}
+21 -6
View File
@@ -18,13 +18,22 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"heading": "Vérifiez vos paramètres",
"heading": "Rejoindre la réunion",
"effects": {
"description": "Appliquer des effets",
"title": "Effets"
},
"joinLabel": "Rejoindre",
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"usernameLabel": "Votre nom",
"errors": {
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer."
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
@@ -74,20 +83,26 @@
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
},
"support": "Support"
"support": "Support",
"moreOptions": "Plus d'options"
},
"options": {
"buttonLabel": "Plus d'options",
"items": {
"feedbacks": "Partager votre avis",
"feedback": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets"
"effects": "Appliquer des effets",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
"exit": "Quitter le mode plein écran"
}
}
},
"effects": {
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome :(",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
"heading": "Flou",
"blur": {
"light": "Léger flou",
+15 -1
View File
@@ -19,6 +19,19 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
"actions": {
"disable": "Désactiver",
"enable": "Activer"
},
"items": {
"participantJoined": "Un nouveau participant",
"handRaised": "Une main levée",
"messageReceived": "Un message reçu"
}
},
"dialog": {
"heading": "Paramètres"
},
@@ -30,6 +43,7 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "Général"
"general": "Général",
"notifications": "Notifications"
}
}
+6 -2
View File
@@ -30,7 +30,7 @@ const box = cva({
},
},
variant: {
default: {
light: {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'box.border',
@@ -46,6 +46,10 @@ const box = cva({
backgroundColor: 'box.bg',
color: 'control.text',
},
dark: {
backgroundColor: 'primaryDark.50',
borderColord: 'primaryDark.50',
},
},
size: {
default: {
@@ -57,7 +61,7 @@ const box = cva({
},
},
defaultVariants: {
variant: 'default',
variant: 'light',
size: 'default',
},
})
+9 -2
View File
@@ -5,10 +5,14 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
TooltipWrapperProps
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
export const Button = ({
tooltip,
@@ -22,7 +26,10 @@ export const Button = ({
<RACButton
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
+31 -3
View File
@@ -11,6 +11,7 @@ import {
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
import { text } from './Text'
import { MutableRefObject } from 'react'
import { css } from '@/styled-system/css'
const StyledModalOverlay = styled(ModalOverlay, {
base: {
@@ -48,6 +49,24 @@ const StyledRACDialog = styled(RACDialog, {
},
})
const ModalContent = styled('div', {
base: {
margin: 'auto',
},
variants: {
size: {
full: {
width: 'fit-content',
maxWidth: '100%',
},
large: {
width: '100%',
xl: { width: '1200px' },
},
},
},
})
export type DialogProps = RACDialogProps & {
title?: string
onClose?: () => void
@@ -63,6 +82,7 @@ export type DialogProps = RACDialogProps & {
onOpenChange?: (isOpen: boolean) => void
type?: 'flex'
innerRef?: MutableRefObject<HTMLDivElement | null>
size?: 'full' | 'large'
}
export const Dialog = ({
@@ -72,6 +92,7 @@ export const Dialog = ({
isOpen,
onOpenChange,
innerRef,
size = 'full',
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
@@ -94,9 +115,16 @@ export const Dialog = ({
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VerticallyOffCenter>
<Div margin="auto" width="fit-content" maxWidth="full">
<ModalContent size={size}>
<Div margin="1rem" pointerEvents="auto">
<Box size="sm" type={boxType} ref={innerRef}>
<Box
size="sm"
type={boxType}
ref={innerRef}
className={css({
padding: '1.5rem',
})}
>
{!!title && (
<Heading
slot="title"
@@ -124,7 +152,7 @@ export const Dialog = ({
)}
</Box>
</Div>
</Div>
</ModalContent>
</VerticallyOffCenter>
)}
</StyledRACDialog>
+28 -6
View File
@@ -25,11 +25,31 @@ const FieldWrapper = styled('div', {
marginBottom: 'textfield',
minWidth: 0,
},
variants: {
noMargin: {
true: {
marginBottom: 0,
},
},
fullWidth: {
true: {
width: '100%',
},
},
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
fontSize: '0.75rem',
},
variants: {
center: {
true: {
textAlign: 'center',
},
},
},
})
@@ -80,6 +100,8 @@ type FieldProps<T extends object> = (
) & {
label: string
description?: string
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
labelProps?: React.ComponentProps<typeof StyledLabel>
}
/**
@@ -104,7 +126,7 @@ export const Field = <T extends object>({
}: FieldProps<T>) => {
const LabelAndDescription = (
<>
<StyledLabel>{label}</StyledLabel>
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
@@ -118,7 +140,7 @@ export const Field = <T extends object>({
if (type === 'text') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RACTextField
validate={validate as unknown as TextFieldProps['validate']}
{...(props as PartialTextFieldProps)}
@@ -133,7 +155,7 @@ export const Field = <T extends object>({
if (type === 'checkbox') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Checkbox
validate={validate as unknown as CheckboxProps['validate']}
description={description}
@@ -147,7 +169,7 @@ export const Field = <T extends object>({
if (type === 'checkboxGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<CheckboxGroup
validate={validate as unknown as CheckboxGroupProps['validate']}
{...(props as PartialCheckboxGroupProps)}
@@ -170,7 +192,7 @@ export const Field = <T extends object>({
if (type === 'radioGroup') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<RadioGroup
validate={validate as unknown as RadioGroupProps['validate']}
{...(props as PartialRadioGroupProps)}
@@ -189,7 +211,7 @@ export const Field = <T extends object>({
if (type === 'select') {
return (
<FieldWrapper>
<FieldWrapper {...props.wrapperProps}>
<Select
validate={validate as unknown as SelectProps<T>['validate']}
{...(props as PartialSelectProps<T>)}
+4 -1
View File
@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { HStack } from '@/styled-system/jsx'
import { Button, useCloseDialog } from '@/primitives'
import { ButtonProps } from '@/primitives/Button'
/**
* From wrapper that exposes form data on submit and adds submit/cancel buttons
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({
onSubmit,
submitLabel,
submitButtonProps,
withCancelButton = true,
onCancelButtonPress,
children,
@@ -25,6 +27,7 @@ export const Form = ({
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
submitButtonProps?: ButtonProps
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
@@ -47,7 +50,7 @@ export const Form = ({
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
<Button type="submit" variant="primary" {...submitButtonProps}>
{submitLabel}
</Button>
{!!onCancel && (
+4 -2
View File
@@ -1,10 +1,12 @@
import { Text } from './Text'
import { Text, TextProps } from './Text'
export const H = ({
children,
lvl,
...props
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
}: React.HTMLAttributes<HTMLHeadingElement> & {
lvl: 1 | 2 | 3
} & TextProps) => {
const tag = `h${lvl}` as const
return (
<Text as={tag} variant={tag} {...props}>
+11 -2
View File
@@ -2,10 +2,14 @@ import { Link, LinkProps } from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
export const LinkButton = ({
tooltip,
@@ -16,7 +20,12 @@ export const LinkButton = ({
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
<Link className={buttonRecipe(variantProps)} {...componentProps}>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</Link>
</TooltipWrapper>
)
}
+3 -1
View File
@@ -8,15 +8,17 @@ import { Box } from './Box'
*/
export const Menu = ({
children,
variant = 'light',
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<Box size="sm" type="popover">
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
</StyledPopover>
+6 -1
View File
@@ -10,6 +10,7 @@ export const MenuList = <T extends string | number = string>({
onAction,
selectedItem,
items = [],
variant = 'light',
...menuProps
}: {
onAction: (key: T) => void
@@ -18,7 +19,11 @@ export const MenuList = <T extends string | number = string>({
} & MenuProps<unknown> &
RecipeVariantProps<typeof menuRecipe>) => {
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
const classes = menuRecipe({ extraPadding: true, ...variantProps })
const classes = menuRecipe({
extraPadding: true,
variant: variant,
...variantProps,
})
return (
<Menu
selectionMode={selectedItem !== undefined ? 'single' : undefined}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Separator as RACSeparator } from 'react-aria-components'
export const Separator = styled(RACSeparator, {
base: {
height: '1px',
background: 'gray.400',
background: 'primaryDark.200',
margin: '4px 0',
},
})
+69
View File
@@ -0,0 +1,69 @@
import {
Switch as RACSwitch,
SwitchProps as RACSwitchProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { StyledVariantProps } from '@/styled-system/types'
import { ReactNode } from 'react'
export const StyledSwitch = styled(RACSwitch, {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.571rem',
color: 'black',
forcedColorAdjust: 'none',
'& .indicator': {
width: '2.6rem',
height: '1.563rem',
border: '0.125rem solid',
borderColor: 'primary.800',
borderRadius: '1.143rem',
transition: 'all 200ms, outline 200ms',
_before: {
content: '""',
display: 'block',
margin: '0.125rem',
width: '1.063rem',
height: '1.063rem',
borderRadius: '1.063rem',
background: 'primary.800',
transition: 'all 200ms',
},
},
'&[data-selected] .indicator': {
borderColor: 'primary.800',
background: 'primary.800',
_before: {
background: 'white',
transform: 'translateX(100%)',
},
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
_before: {
background: 'primary.200',
},
},
'&[data-focus-visible] .indicator': {
outline: '2px solid!',
outlineColor: 'focusRing!',
outlineOffset: '2px!',
},
},
variants: {},
})
export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
RACSwitchProps & { children: ReactNode }
/**
* Styled RAC Switch.
*/
export const Switch = ({ children, ...props }: SwitchProps) => (
<StyledSwitch {...props}>
<div className="indicator" />
{children}
</StyledSwitch>
)
+17 -3
View File
@@ -1,9 +1,17 @@
import {
ToggleButton as RACToggleButton,
ToggleButtonProps,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
@@ -12,14 +20,20 @@ export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
/>
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
+42 -4
View File
@@ -1,9 +1,5 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
export const buttonRecipe = cva({
base: {
display: 'flex',
@@ -77,6 +73,19 @@ export const buttonRecipe = cva({
color: 'greyscale.400',
},
},
whiteCircle: {
color: 'white',
border: '1px white solid',
width: '56px',
height: '56px',
borderRadius: '100%',
'&[data-hovered]': {
backgroundColor: 'greyscale.100/20',
},
'&[data-pressed]': {
backgroundColor: 'greyscale.100/50',
},
},
tertiary: {
backgroundColor: 'primary.100',
color: 'primary.800',
@@ -113,6 +122,21 @@ export const buttonRecipe = cva({
color: 'primaryDark.100 !important',
},
},
secondaryDark: {
backgroundColor: 'primaryDark.50',
color: 'white',
'&[data-pressed]': {
backgroundColor: 'primaryDark.200',
},
'&[data-hovered]': {
backgroundColor: 'primaryDark.100',
color: 'white',
},
'&[data-selected]': {
backgroundColor: 'primaryDark.700 !important',
color: 'primaryDark.100 !important',
},
},
primaryTextDark: {
backgroundColor: 'transparent',
color: 'white',
@@ -222,6 +246,16 @@ export const buttonRecipe = cva({
shySelected: {
true: {},
},
description: {
true: {
flexDirection: 'column',
gap: '0.5rem',
'& span': {
fontSize: '13px',
textAlign: 'center',
},
},
},
// if the button is next to other ones to make a "button group", tell where the button is to handle radius
groupPosition: {
left: {
@@ -255,3 +289,7 @@ export const buttonRecipe = cva({
variant: 'primary',
},
})
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
+1
View File
@@ -29,3 +29,4 @@ export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'

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