Compare commits

...

208 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM 51a1725c47 wip 2024-10-12 22:49:35 +02:00
lebaudantoine 1875a394c6 ♻️(frontend) simplify button primitive
In object-oriented terms, the previous implementation violated the Liskov
Substitution Principle. Props between these two components (Button and Link)
were not substitutable.

This led to TypeScript errors and increased overall complexity without
significant DX gains. To address this, the LinkButton has been extracted
into a dedicated component.
2024-10-09 16:40:59 +02:00
renovate[bot] 211ba332dc ⬆️(dependencies) update js dependencies 2024-10-09 16:40:59 +02:00
renovate[bot] 7333c21632 ⬆️(dependencies) update vite to v5.4.6 [SECURITY] 2024-10-09 15:09:59 +02:00
lebaudantoine 78ebd1a8fd 👷(ci) update build push action to v6
Update the build push action.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 682b69fc11 🔒️(backend) migrate backend image to alpine
Enhancement made by @rouja while working on the vulnerabilities
found by Trivy scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 7a73bf8fc2 💚(frontend) fix frontend image vulnerabilities
Fixed vulnerabilities found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 1e934957f5 💚(backend) fix backend image vulnerabilities
Fixed vulnerabilities with setup tools found by the Trivy Scan.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL 5a7584a3ad 👷(ci) scan for vulnerabilities on Docker images
Configure Trivy Scan in the CI to detect vulnerabilities on our
Docker image. Enhance stack security.
2024-10-09 14:58:39 +02:00
Jacques ROUSSEL fb9bf6b08e 🔐(tilt) add Samuel's key
Add Samuel's key to handle secret.
2024-10-09 11:14:11 +02:00
Jacques ROUSSEL 11b8541005 🔧(backend) fix configuration to avoid different ssl warning
Fix following warning messages :
- You have not set a value for the SECURE_HSTS_SECONDS setting.
- Your SECURE_SSL_REDIRECT setting is not set to True.
2024-09-27 18:46:26 +02:00
lebaudantoine 64607ae8d0 (frontend) introduce push to talk on microphone
I am not a huge fan of changing the component's behavior based on
a if, but that the only way I found to have something quickly.

Actually, the push to talk feature will always only applies to the
microphone. No other devices will be concerned.

Reuse the active speaker indicator to quickly bootstrap the feature.
Some details should be improved in terms of UI. The UX is quite
decent.
2024-09-27 16:34:32 +02:00
lebaudantoine c403bbc347 ♻️(frontend) extract toggle device component
Encapsulate toggle logic in a dedicated component.
Prepare the introduction of the push to talk feature.
2024-09-27 16:34:32 +02:00
lebaudantoine 62855fe12c (frontend) introduce active speaker pushToTalk variant
Will be used to indicate when the push to talk is open.
It gives the user a feedback wether or not her mic is active.
2024-09-27 16:34:32 +02:00
lebaudantoine f27d451eb6 ♻️(frontend) make active speaker keyframe relative to height
Keyframe can now be reused in various context, when the children
size are not fixed to 4px.
2024-09-27 16:34:32 +02:00
lebaudantoine 68792d8019 ✏️(frontend) fix typo in active speaker keyframe
Found a typo and fixed it.
2024-09-27 16:34:32 +02:00
lebaudantoine ebb8c14eeb ♻️(frontend) register shortcut using a hook
Encapsulate the logic for shortcuts registering in a proper hook.
It makes the code reusable and easier to read.
2024-09-27 16:34:32 +02:00
lebaudantoine 15d43b8d5e ♻️(frontend) handle proper shortcut object
Created a type to properly manipulate any data related to a
shortcut. Make the code more concise.
2024-09-27 16:34:32 +02:00
lebaudantoine 1faae96ccd (frontend) introduce long press keyboard shortcut
Needed to support push to talk using the spacebar.
Introduce a utility hook. Will be called by the mic toggle
in the upcoming commits.

Inspired by Jitsi code.
2024-09-27 16:34:32 +02:00
lebaudantoine 651cc0e5bd (frontend) introduce keyboard shortcut
Inspired by Gmeet for the UX and by Jitsi for the code.
Naive implementation of Keyboard shortcuts listener.

Will be enhanced in the upcoming commits.
2024-09-27 16:34:32 +02:00
lebaudantoine 0dadd472ff 🔖(patch) bump release to 0.1.7
Bump release to 0.1.7
2024-09-25 20:05:29 +02:00
lebaudantoine be35ee0258 🐛(frontend) prevent side panel and chat from opening simultaneously
Fixed issue where side panel could open while chat was already open.
Resolved recent bug introduced in a previous commit.
2024-09-25 19:53:48 +02:00
lebaudantoine bece79f47b ♻️(frontend) increase silent login attempts to every 5 minutes
Maximize logged-in users. This ensures that users remain authenticated
more consistently across sessions without manual intervention.
2024-09-25 14:45:09 +02:00
lebaudantoine a499331960 🐛(frontend) prevent silent login during active calls
React Query refetches stale data on window focus, which triggers
silent login attempts for logged-out users.

If user data becomes stale (after 1 hour), silent login redirects
the user to the auth domain, potentially disconnecting them during calls.

To prevent this, user data is now considered non-stale
during active sessions.

If the user logs in via another tab, a manual page
reload will be required to refresh their session.
2024-09-25 14:45:09 +02:00
lebaudantoine d50d167d0a 🐛(helm) fix certificates configuration
Newly introduced ingress for PostHog were misconfigured, oopsie.
Here is the fix.
2024-09-25 11:58:34 +02:00
lebaudantoine e4c7bc0826 ♻️(helm) separate PostHog ingress
Based on feedback from @rouja, I've updated the Helm configuration for PostHog
to use separate ingress resources for each service. Although the documentation
suggests sharing the same ingress, the services have different externalName
values, which conflicts with the use of a vhost in the ingress annotations.
This change ensures proper service redirection by aligning each service with
its own ingress.
2024-09-25 11:40:44 +02:00
lebaudantoine b5244a5ec0 🔧(helm) configure support and analytics
Declare the expected support and analytics env variables
expected by the frontend for each environment. To avoid consuming
too much credits from our PostHog free tier plan.
2024-09-25 11:40:44 +02:00
lebaudantoine 8cc2cc83c6 (frontend) initialize application from remote configs
Query API to get the App's configs.
Replacement for env variables, thus with a single image,
we can dynamically update frontend's behavior.

Code inspired by Marsha. Not sure having a single component
returning null is a good idea, to be discussed.
2024-09-25 11:40:44 +02:00
lebaudantoine e591d09b00 (backend) add endpoint for frontend's configuration
Inspired by Joanie.

In Frontend context, env variables are only available at build time,
not runtime. This is one of the easiest way to pass frontend dynamic
configurations while running.

This commit only exposes the view already existing.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL eeb4dae12d 💚(ci) fix argocd job which handles sync
Fixed by @rouja. ArgoCD should be more robust than ever, while
syncing with our code.
2024-09-25 11:40:44 +02:00
lebaudantoine 271389d459 🚨(helm) fix linter errors
Initial set-up errors. Fixed them. Removed unused Preprod environment,
as secrets are not configured yet, it raises an error.
2024-09-25 11:40:44 +02:00
Jacques ROUSSEL fe6eefa1f0 👷(ci) lint helmfile
Introduced by @rouja. Added a new linter to ensure helm and yaml
files can be properly parsed into templates.
ArgoCD can not break anymore.
2024-09-25 11:40:44 +02:00
lebaudantoine a276517278 📈(frontend) setup a reverse proxy for analytics
Proxy analytics requests through our backend to minimize
ad-blockers impact. I configured the Helm Charts following
PostHog official documentation.
2024-09-25 11:40:44 +02:00
renovate[bot] 9e9b9015f4 ⬆️(dependencies) update js dependencies 2024-09-24 10:49:28 +02:00
lebaudantoine 3f0378aada ♻️(frontend) remove Tchap support
Tchap is no longer relevant due to the integration of Crisp.
The Tchap channel will be deleted.
2024-09-24 10:49:14 +02:00
Jacques ROUSSEL 90c88a8bd3 🔒️(helm) change domainon production
Add ingress in order to migrate from meet.numerique.gouv.fr to
visio.numerique.gouv.fr
2024-09-23 20:20:56 +02:00
lebaudantoine 1103902c12 🔖(minor) bump release to 0.1.6
Release to 0.1.6
2024-09-23 20:10:16 +02:00
lebaudantoine 812016d09c (frontend) add user support feature using Crisp
Implement a chat feature that allows users to communicate directly with an
engineer for assistance with issues. We use Crisp for consistency, as it’s
already utilized in all other LaSuite products. Additionally, we need to
configure an environment variable in the frontend for better flexibility.

This is the initial implementation, and session handling will be refined
in future updates.
2024-09-23 20:06:29 +02:00
lebaudantoine cadc5c0034 (frontend) add crisp-sdk-web dependency
La Suite uses Crisp for Chat support.
Added their react sdk.
2024-09-23 20:06:29 +02:00
lebaudantoine b083d837f8 ♻️(frontend) encapsulate PostHog logic in dedicated functions
Decouple the code from the PostHog dependency by wrapping it in a custom hook
and several utility functions. This enhances code maintainability and separation
of concerns.
2024-09-23 18:58:38 +02:00
lebaudantoine db8445f4ab 🐛(backend) add missing Django settings
Fixed an issue where a setting cloned from Joanie was missing
in the  Django configuration. Although its value was provided, it was
not applied due to the missing reference in the settings file.
2024-09-23 18:12:44 +02:00
lebaudantoine 0e5c2be445 (frontend) replace login link by ProConnect button
Following their documentation, replace our generic login button,
by a more specific one. Asked by several users.
2024-09-23 17:31:07 +02:00
lebaudantoine 0131a65509 ✏️(frontend) fix minors typos in i18n
Found and fixed minor typos in our copywritting.
2024-09-23 16:34:11 +02:00
lebaudantoine fb3727e73e 👔(frontend) rename 'meet' to 'visio'
In alignment with French government guidelines,
ensuring no English terms slip through.
2024-09-23 16:34:11 +02:00
Jacques ROUSSEL 3391165e4b 🔒️(helm) change domain
Change the domain to visio-staging.beta.numerique.gouv.fr
2024-09-23 12:07:13 +02:00
Jacques ROUSSEL 0be94aa572 🔒️(helm) setup temporary redirect
Add a specific certificate to prepare redirect
2024-09-23 12:07:13 +02:00
lebaudantoine b309f91095 💡(frontend) highlight technical debt
We should use participant attributes, rather than metadata, to store hand
status (raised/lowered). However, the useParticipantInfo hook is currently
not suited for this, as it does not refresh when attributes change.
2024-09-22 20:02:33 +02:00
lebaudantoine c50a749293 ♻️(frontend) simplify raised hand notification
Listen to metadata changed events instead of messaging the whole
room. Less code, might be less clean, to be discussed.
2024-09-22 20:02:33 +02:00
lebaudantoine cd9e7c8a1f 🚸(frontend) inform user Blurring effects are experimental
Be transparent with users: this version lacks quality and is currently
limited to Chrome, which may lead to frustration.

In addition to providing information, we should implement a method to
collect user feedback and reactions.
2024-09-22 20:02:33 +02:00
lebaudantoine 9d0767ccfe ♻️(frontend) refactor useParticipants to streamline updatesOnlyOn logic
Simplified the logic to avoid redundancy. Removed unnecessary duplication of
eventGroups, as it's already exported from the core. My bad for the initial
complication — code is now cleaner.
2024-09-22 20:02:33 +02:00
lebaudantoine 021e52d9eb 💄(frontend) organize blurring options
Add visual container and a heading to indicate buttons are
blurring options.
2024-09-22 20:02:33 +02:00
lebaudantoine 607a5fc99d (frontend) introduce blurring effects for Chromium-based users
Implemented using MediaPipe, which is not supported on Firefox due to
limitations outlined in issue #38 of the track-processors-js repo.

We decided to release this first version exclusively for Chrome to quickly
offer a solution to users. Future iterations will address broader compatibility.

An informational message will be added to notify users that the feature is
experimental. For now, a text label will be used in place of an icon.
2024-09-22 20:02:33 +02:00
lebaudantoine 756be17cc7 (frontend) introduce a side panel for effects
It simply renders the video track if enabled. It's a basis
for building the 'blur your screen' feature.

More in the upcoming commits.
2024-09-22 20:02:33 +02:00
lebaudantoine 00fa7beebd ♻️(frontend) introduce a side panel
Refactor side panel into a reusable component to display any interactive
content like menus, messages, participant lists, or effects. Establish it
as a core feature of the videoconference tool.

Improve extensibility and better share responsibilities. The next step is to
refactor the chat to render inside the side panel.
2024-09-22 20:02:33 +02:00
lebaudantoine b9d13de591 (frontend) introduce 'effects' menu item
Increased all icons size, as they looked a bit small on screen.
Introduce a new component separator, to organize menu items
in section.

Work in progress, added the 'effects' item, which triggers no
action yet.
2024-09-22 20:02:33 +02:00
lebaudantoine 7896890ffc ♻️(frontend) refactor translation keys to use prefix
Code cleaning. Avoid duplication.
2024-09-22 20:02:33 +02:00
lebaudantoine a2043abb51 (frontend) install track-processors-js from LiveKit
Recommended dependency to process video tracks, to add a virtual
background, or a blurring effect. Created and maintained by the
core LiveKit's team.

Please note, few issues are known. track-processors-js relies on
MediaPipe (by Google), which is not supported on Firefox …

Quick-win to ship a first draft.
2024-09-22 20:02:33 +02:00
rouja 64bb1b3bb5 Merge pull request #161 from numerique-gouv/fix-secret-mep-issue
🔒️(helm) fix secret sync precedence
2024-09-20 15:04:29 +02:00
Jacques ROUSSEL a1a56402d1 🔒️(helm) fix secret sync precedence
When new secret is added to backend secret, it's not sync at the
beginning of argocd synchronisation and jobs are blocked. Theses new
annotations fix this issue.
2024-09-20 14:55:15 +02:00
lebaudantoine 582d3774a4 ⬆️(frontend) fix persistent choices nesting issue
Upgraded to livekit/components-react v2.5.4, fixing a bug in v2.5.3 that
caused repeated nesting of persistent user choices, exhausting the quota.
Thanks to Lukas for the quick fix.
2024-09-17 19:43:40 +02:00
lebaudantoine fab08cfaf8 🚑️(frontend) fix posthog user's ID
Quotes were misplaced, leading to the same ID being generated for every user.
Fixed the issue, which was a trivial error.
2024-09-17 18:26:53 +02:00
lebaudantoine 550c48f29e 📈(frontend) track room join events with room slug
Focus on join events as a core metric for user room participation.
Added properties to differentiate events by room slug.
2024-09-17 15:28:52 +02:00
lebaudantoine 0c64580abf 📈(frontend) track page view events in SPA
Implement page view tracking in the SPA following PostHog’s guidelines.
Manually capture page's location when it changes.
2024-09-17 15:28:52 +02:00
lebaudantoine 0ad9b7e233 📈(frontend) limit event tracking to stay within free tier limits
Optimize event tracking to be frugal and reduce event volume,
even though filtering can be done later if needed.
2024-09-17 15:28:52 +02:00
lebaudantoine b9bcc45cce 📈(frontend) add explicit data attributes for key event tracking
PostHog's autocapture is useful, but explicit tracking ensures cleaner, more
focused data. This initial pass targets high-impact actions; we'll iterate as
needed.
2024-09-17 15:28:52 +02:00
lebaudantoine bcd285e368 ♻️(frontend) refactor tooltip on screen share toggle
Reorganize tooltip to follow others toggle buttons.
2024-09-17 15:28:52 +02:00
lebaudantoine c0ad98eb34 ♻️(frontend) refactor keyprefix in translation
Factorized translations' prefix.
2024-09-17 15:28:52 +02:00
lebaudantoine 178278235e (frontend) login and logout from Posthog
Following Posthog documentation, login and logout users directly
from the client. Note: Logout function should be encapsulate in
a proper function.
2024-09-17 15:28:52 +02:00
lebaudantoine 11664956c7 (frontend) initialize posthog
Initialize Posthog script with our project's keys.
This key is public, it's not a secret.

Our data is hosted in Europe, followed Tchap integration,
which was reviewed by the ANSI.
2024-09-17 15:28:52 +02:00
lebaudantoine f020979188 (frontend) add posthog dependency
Posthog is a product analysis tool.
Installed the js SDK to track product usage.
2024-09-17 15:28:52 +02:00
renovate[bot] da5c8d6773 ⬆️(dependencies) update python dependencies 2024-09-16 18:08:35 +02:00
lebaudantoine 31051cd6c4 🚸(frontend) improve share screen UX
Enhanced the share screen button by adding a tooltip and improving contrast for
better accessibility. Created a temporary icon by combining two from Remix, but
it’s bulky and will need refactoring soon.
2024-09-16 18:02:53 +02:00
lebaudantoine c51058e6ac ✏️(frontend) fix a minor typo
Minor typo copied/pasted from LiveKit sources. Fixed it.
2024-09-16 18:02:53 +02:00
lebaudantoine 3ba913bb21 ♻️(frontend) use keyprefix args
My bad, avoid repeating the same prefix.
2024-09-16 18:02:53 +02:00
lebaudantoine 95116f70e8 🚸(frontend) enhance quit button UX
Improved handling of the disconnect call by properly managing the returned
promise. Simplified the approach by skipping the useDisconnectButton hook.
Updated the button to display only an icon for a cleaner interface.
2024-09-16 18:02:53 +02:00
lebaudantoine 5b282b62e7 🩹(frontend) dismiss notification toasts on room disconnect
Ensure notification toasts are cleared when a participant disconnects,
preventing stale notifications from showing if the user quickly rejoins.
This resolves issues with duplicate or outdated notifications appearing.
2024-09-16 18:02:53 +02:00
lebaudantoine 5e74fce6e2 🚸(frontend) simplify audio and video select inputs
Based on @manuhabitela's works.
Align UX with common tools as Gmeet or Jitsi.
Enhanced accessibility.
2024-09-16 18:02:53 +02:00
lebaudantoine 96b279b350 🚨(frontend) fix eslint errors
Recent dependencies' updates triggered new
eslint errors. Fixed them.
2024-09-15 17:57:21 +02:00
lebaudantoine fc4ad562ae 📌(frontend) pin eslint dependency
Conflicts with eslint-plugin-react-hooks dependency,
which doesn't support the latest v9 of eslint.
2024-09-15 17:57:21 +02:00
renovate[bot] 480a8df31a ⬆️(dependencies) update js dependencies 2024-09-15 17:57:21 +02:00
lebaudantoine f76e7f0e51 🔖(minor) bump release to 0.1.5
Minor release pre-hackathon.
2024-09-12 01:34:44 +02:00
lebaudantoine b9ffbd179c 🌐(frontend) enhance few English traductions
Found few issues, fixed them.
2024-09-12 01:10:19 +02:00
lebaudantoine a3ef1d4a26 🩹(frontend) enable React Query for token management
Previously, I prevented clients from fetching new tokens when data is stale.
It seems totally wrong. Enabling data refresh addresses potential issues
with random user disconnections.

Please note the data refresh when stale was only disabled for people
who created the room.
2024-09-12 00:55:13 +02:00
lebaudantoine d11bcc5de9 🩹(frontend) remove undesired console.log
Oupsy, I forgot to remove this one. My bad.
2024-09-12 00:44:35 +02:00
lebaudantoine 412914cf01 💄(frontend) force a minimum left column width
While in focus, left tiles should be large enough to render
long name and still have a big enough size to get who is in the
tile. Temporary, but functional.
2024-09-12 00:44:35 +02:00
lebaudantoine d511aedd39 🚸(frontend) draft sound design on notifications
More a brainsto idea inspired from Gmeet than a polished feature.
Try adding sound notification to add a new dimension on the user
experience.
2024-09-12 00:44:35 +02:00
lebaudantoine 7edf7d194b 🚸(frontend) disable toast notifications on mobile
Due to responsiveness issues and awkward display on mobile screens.

Note: Chrome inspector's responsive mode inaccurately triggers mobile
behavior. It uses a temporary function imported from LiveKit.
2024-09-12 00:44:35 +02:00
lebaudantoine 6324608a4a 🩹(frontend) improve join notification handling
Notifications now close if the designated user leaves the room before
the toaster duration ends. This prevents bugs and ensures cleaner UX.
2024-09-12 00:44:35 +02:00
lebaudantoine a402c2f46f (frontend) notify user when hands are raised
Using toast, first draft of a toast notification inspired by Gmeet.
Users can open the waiting list to see all the raised hands.
2024-09-12 00:44:35 +02:00
lebaudantoine e21858febe ♻️(frontend) extract duplicated logic into reusable hook
This hook manages interactions with the right side widget,
improving maintainability.
2024-09-12 00:44:35 +02:00
lebaudantoine 3d615fa582 (frontend) introduce in-app toast notification
Toast-related components are reusable. They follow React Aria recommendations.

The UI is heavily inspired by GMeet, we should iterate on it.
Why toast? It gives additional information and quick actions when an event
occurs in the room.

The ToastRegion design should be improved to be more extensible, to move away
from poor practices with conditional rendering.

Added initial toast display when users join, with room for further improvement.
Please read the fixme items.
2024-09-12 00:44:35 +02:00
lebaudantoine 4293444d3e 🚨(frontend) clean unused imports
Found unused imports that were breaking the build. Fixed them.
2024-09-12 00:44:35 +02:00
lebaudantoine a55a4d5b5d ♻️(frontend) disable participant's tile metadata
Inspired by the props disableSpeakingIndicator, used the same
logic for all metadata displayed on hover on a participant's tile.

Why? I'll need to render the joining participant tile without any
metadata, so the participant see who is joining the room.
2024-09-12 00:44:35 +02:00
lebaudantoine dd9a87a33b (frontend) install react-toast in beta
Absolutely needed some toasts to handle in app notifications,
or feedbacks. React Aria is working on it, and has released a
first beta. No component is available, only hooks.
2024-09-12 00:44:35 +02:00
lebaudantoine 24242ef01a 💄(frontend) introduce a new button's variant
'text' is reserved for discreet buttons. Inspired by Gmeet,
styles need to be refined. WIP, but mergeable as it is.
2024-09-12 00:44:35 +02:00
lebaudantoine b537cdfd93 🚸(frontend) align later meeting modal
Duplicated sources, bad practice, should be a unique and reusable
component. Debt, needs to be refactored.
2024-09-12 00:44:35 +02:00
lebaudantoine 80cc7c723f 🚸(frontend) enhance accessibility of 'copy link' button
Added user feedback for actions with explicit text indicating
URL will be copied to clipboard. Modeled after Jitsi's button
behavior for clarity and consistency in user experience.
2024-09-12 00:44:35 +02:00
lebaudantoine e9210213b1 💄(frontend) enhance invitation modal position
Based on User's feedback.

Having the dialog position visually in the participant tile is
confusing for the user.

Position the dialog to be explicitly outside of a participant
tile container.
2024-09-12 00:44:35 +02:00
lebaudantoine 634f1924be 🚸(frontend) enhance muted mic indicator
Users' feedbacks. Design is inspired from Whereby, a webinars
product. I am not a huge fan of having the indicator on the
left of the participant's name. It causes a shift of the name
when the participant unmute herself.

This should be discussed with the UX designer.
2024-09-12 00:44:35 +02:00
lebaudantoine 4e175a8361 🚸(frontend) highlight raised hands in participant's tile
Users's feedbacks. Raised hands in participants' tiles were not
highlighted enough.

It was discussed adding a colorful border around the participant's
tile (idea inspired by Jitsi). Nonetheless, it conflicts with the
current blue border, indicating when a participant is speaking.

Until this blue border is present, it's not a great idea having a
second border indicating a new level of informations.

@sampacoud requested a yellow-ish/colorful indicator of when a
participant raised her hand, inspired by Jitsi. However, I think
yellow color should be reserved for warning or dangerous action.

I chose to use a white background around the participant's name,
to reinforce the visual indication a participant has her hand
raised, while still being discreet. It's inspired by Gmeet.
2024-09-12 00:44:35 +02:00
lebaudantoine c5ce32ef79 🚸(frontend) enhance mic indicator in participant list
The Gmeet-inspired indicator was misleading for users,
at least those not used to GSuite products.

They didn't understand how to mute a participant.

Plus, having the button disabled for the local participant,
was creating confusion. To simplify the UX, have all
the buttons enabled is simpler to understand.

Empirical observations with a few number of users, should be
enhance and challenge in the long run.
2024-09-12 00:44:35 +02:00
lebaudantoine aaf6b03a25 🚸(frontend) reduce delay for instant tooltip
Instant tooltips weren't really instant. Reduced the delay
for instant tooltip, it was empirically tuned, can be enhanced.
2024-09-12 00:44:35 +02:00
lebaudantoine 66bc739411 🩹(frontend) fix cursor on disabled button
Having a pointer cursor on a disabled button was misleading
fixed it ! Minor issue.
2024-09-12 00:44:35 +02:00
lebaudantoine 1b48fa256e ♻️(frontend) get rid of the ListItemActionButton
Use the primitive Button component, avoid maintaining two button
components.
2024-09-12 00:44:35 +02:00
lebaudantoine 053c4a40e9 🩹(backend) fix identity hash randomness
'hash' built-in function is randomly seed by Python process.
In staging or production, our backend runs over 3 pods, thus 3
Python processes. For a given identity, it was not prompting
the same hash across all pods.

Why 'hash' is randomly seed? For security reasons, there was
a vulnerability disclosure exploiting key collision. Since Python 3.2,
'hash' is by default randomly seed.

Fixed it! Thx @jonathanperret for your help.
2024-09-04 14:15:38 +02:00
lebaudantoine 53d732d802 💫(frontend) wave icon when a participant raises her hand
Minor enhancement, add some CSS animation to wave a newly
raised hand. Inspired by GMeet.
2024-09-03 16:01:49 +02:00
lebaudantoine 26ca81db40 🩹(frontend) fix color contrast when toggling a control
Copied legacy style from the screen share control.
Still imperfect, but at least it's visually more "comfy".
2024-09-03 16:01:49 +02:00
lebaudantoine e6e6a3bde7 💩(frontend) indicate when a participant raises her hand
Height was varying when muting/unmuting the mic, so I added a minimum height to
avoid layout shift.

Proof of concept for the future UX: a raised hand would be placed on
the left side of the participant's name.

Having the mic indicator and raised hand side by side is temporary and totally
undesired (it feels super weird). I'll refactor the whole UX of the participant
tile in a dedicated PR.
2024-09-03 16:01:49 +02:00
lebaudantoine 26fdaac589 ♻️(frontend) extract raised hand logic in a reusable hook
Encapsulate code responsible for toggling hand,
and determining the hand state based on participant metadata.

This gonna be reused across the codebase.
2024-09-03 16:01:49 +02:00
lebaudantoine 1fd1cb71ba 📱(frontend) enable Y-scroll on participants menu
Responsiveness issue. Fixed it, to allow user scrolling the
participants list.
2024-09-03 16:01:49 +02:00
lebaudantoine c8023573c6 (frontend) allow lowering all hands at once
Inspired from Gmeet. Add a button to lower all hands at once.
It should be quite useful for room moderator/admin.

The UX might feel strange, having this action button at the top of
the list, but I could not figure out a better layout. In terms of
UX I feel this is the best we can provide. However, the UI should
definitely be improved.
2024-09-03 16:01:49 +02:00
lebaudantoine 584be7e65b (frontend) add raised hands waiting room
Show the raised hand waiting list, allow any participant to
lower other participants' hands.

Inspired from Gmeet. I found it quite clear to have a dedicated
waiting list for raised hands.
2024-09-03 16:01:49 +02:00
lebaudantoine 59ec88e84a ♻️(frontend) extract action icon button in a component
Will need it for lower hand action.
Actually, this piece of code should be enhanced. It'll act
as a corner stone of all actions dispatched in the interface.

I'll continue refactoring it later on.
2024-09-03 16:01:49 +02:00
lebaudantoine 1b2e0ad431 🚸(frontend) introduce sections in participants menu
Why? This layout is extensible. This menu will have two sections,
to separate in-room participants from the waiting room ones.

It's copied from Gmeet User Experience.

In-room participants will be divided in sub-lists based on their
states (ex: hand raised, …). This User Experience is extensible
if in the future with support subroom in a room or whatever.
2024-09-03 16:01:49 +02:00
lebaudantoine 20464a2845 (frontend) introduce raise hand control
Inspired by Magnify. Rely on participant metadata to determine,
if the local user has raised its hand.

There is a todo item in LiveKit code, useLocalParticipant is not
subscribed to metadata updates events.

The contrast of the toggled button in legacy Style is poor,
it needs to be urgently improved. User can barely distinguish if
their hands are raised.
2024-09-03 16:01:49 +02:00
renovate[bot] ece6284de2 ⬆️(dependencies) update python dependencies 2024-09-02 11:31:49 +02:00
lebaudantoine 82e994e5b1 (frontend) add active speaker indicator to audio tab
Aligned layout and features with Gmeet. The layout is functional,
though the code needs cleanup.

Will open an issue for V3 enhancements.
2024-08-30 16:32:41 +02:00
lebaudantoine 85aa7a7251 ♻️(frontend) wrap text for long selected value
These changes should be discussed. Needed for the audio selects,
that render super long text values.

By default, centered texts are sometime hard to read.
2024-08-30 16:32:41 +02:00
lebaudantoine c4ececd03a ♻️(frontend) collapse tabs when modal is not wide
Inspired by Gmeet UX. Styling of the icon-only tabs should be
enhanced.

Heavily rely on JS, which is quite sad for this simple
responsive layout.
2024-08-30 16:32:41 +02:00
lebaudantoine 57bba04cf3 (frontend) pass ref to the dialog inner overlay
Needed a ref to determine the size of the opened overlay.
2024-08-30 16:32:41 +02:00
lebaudantoine 11e162dbd4 🚸(frontend) avoid blocking user with invitation dialog
Removed annoying overlay that blocked the user. (feedback from @spaccoud)

Previous design required one click too many. As this dialog doesn't need
an overlay, I couldn't use the default primitive.

It's a hacky but functional, creating technical debt here.

Closer to the Gmeet design. Added a warning about permissions
since the rooms are public in beta.

Apologies @manuhabitela, this may impact accessibility for  users unfamiliar
with the copy icon. Let's conduct user testing.
2024-08-30 11:11:53 +02:00
lebaudantoine e06e9d1496 💩(frontend) mute remote participants
Messy code file organization, but functions are okay. Functional, but not
well-designed and likely hard to maintain. Shipping it, creating technical
debt here. Will be reused for interactions with LiveKit server API.
2024-08-29 23:15:11 +02:00
lebaudantoine c8cc9909ba (backend) give every participant room admin permission
Participants need to be room admin to interact with LiveKit server
api. Until we offer private room, with moderation, all participants
will be considered as room admin.

note: room admin doesn't offer permission to record a room. please,
refer to LiveKit documentation to learn more.
2024-08-29 23:15:11 +02:00
lebaudantoine c218a1f7a2 💩(frontend) add confirmation before muting participant
This commit focuses on the UX more than the code quality. In facts,
we should design a centralized component/alert service, that we
can trigger anywhere in the codebase, when a user is prompted for
confirmation.

The alert modal with cancel/submit buttons and a description is generic.
Creating technical debt here.
2024-08-29 23:15:11 +02:00
lebaudantoine 490aaba30a ♻️(frontend) make row layout extensible
Rapidly, a 'more option' button should be added to the row. Thus,
participants will have access to few more actions (ex: remove from
the room, pin camera, etc…).

This flex layout should be definitive, except the code which is dirty,
but not creating much technical debt here.
2024-08-29 23:15:11 +02:00
lebaudantoine 790d37569c (frontend) draft a Mic indicator in participants list
First raw iteration.

Tried replicating Gmeet UX and layout. However, in their ux, even if
action button are disabled, some tooltips explain to the user why they
cannot perform the action. With the default Trigger, it's not easily
feasible. As soon as the button is disabled, there is no way to trigger
the tooltip in an uncontrolled manner.

In upcoming commits I'll arrange layout, and call the remote LiveKit
ServerApi, to perform the mute action.
2024-08-29 23:15:11 +02:00
lebaudantoine 1e140a01b5 ♻️(frontend) create a ParticipantListItem component
Main update: create a component, which has the responsability of
rendering a Participant in the list. I tried gettig closer to a
cleaner codebase, with small and well-scoped components. WIP.

Formatting participants before rendering the list was way to over
complicated, iterating twice on the list was kinda useless. Fixed!

Few updates:
- Removed capitalizing Participant's name to be consistent with the
avatar. This choices mimics GMeet UX.
- Duplicated isLocal utils function from LiveKit, which is not
exported by their package.
2024-08-29 23:15:11 +02:00
lebaudantoine 4ecb7202ec 💄(frontend) fix avatar font size
Avatar font size was way too small in the participant list context.
I tried improve it. Few font sizing are visually uncomfortable in
the participant list.
2024-08-29 23:15:11 +02:00
lebaudantoine 4b7419fe4a (frontend) create a reusable active speaker component
oupsy, heavily copied from Gmeet.
Thx Nathan Vasse for his css animation skills, he crushed it.

This component will be core in the app, and reused in many places.
It was necessary for the participants list.
2024-08-29 23:15:11 +02:00
lebaudantoine 44d3c738d7 🐛(backend) fix dependencies conflicts
Upgrading Django to 5.1 created a severe issue.
Migrate and create-superuser jobs were failing.

The issue originated from the third party easy_thumbnail.
Please refer to the issue #641 on their repo.

This is the suggested workaround by @Miketsukami.
2024-08-28 11:42:44 +02:00
lebaudantoine 888c66a218 💡(frontend) document issue with the participant placeholder
It explains why participant initials doesn't update when a participant
changes its name.

Similar issue as the one faced while developing on the participant list.
It needs to be fixed at the root, in the LiveKit repo.
2024-08-28 10:24:20 +02:00
lebaudantoine 86641bd160 💩(frontend) enhance participant's placeholder responsiveness
Duplicated source from LiveKit internal hooks (not ideal, but necessary)
to ensure the inner content of the participant placeholder consistently
fits its parent container. Unfortunately, I couldn't find
a simpler solution.

This might lead to some performances issues, as for each tile, some js
would be computing avatar's size, which is much more costly than pure
css…

Note: Gmeet achieves this by generating a temporary placeholder image
with a colored  background and initials, allowing for a perfectly round,
responsive avatar without relying on JavaScript.
2024-08-28 10:24:20 +02:00
lebaudantoine 3d91af23cc ♻️(frontend) revert uppercasing initials
Rolled back the uppercasing of initials to align with Gmeet behavior.
This change is made purely for consistency.
2024-08-28 10:24:20 +02:00
lebaudantoine 2d0d30ce01 ♻️(frontend) allow responsive avatar
Until this commit, avatar got a fixed size. Make them
responsive, to fit their parent container.

It's necessary in a placeholder context.
2024-08-28 10:24:20 +02:00
lebaudantoine 98ae9af84a (frontend) customize avatar's color to each participant
Default 'transparent' value avoid showing the avatar when
participant's info are not yet available. Might be a bad design
if we follow the single responsability pattern. Please feel free
to refactor it.
2024-08-28 10:24:20 +02:00
lebaudantoine 3dda12ada6 🩹(frontend) update initials fallback
The previous value was temporary. Replaced it with an empty
string, that would be more discreet.
2024-08-28 10:24:20 +02:00
lebaudantoine 8b2750c413 (backend) attribute a color to each participant
Color is stored in participant's metadata.
Using a hash of some participant's info allow to persist the color
for logged-in participant without storing the color in db.

Please feel free to tune the saturation and lightness range.
Code is inspired by a tutorial found online.
2024-08-28 10:24:20 +02:00
lebaudantoine c16c27fa50 (frontend) add a pulsing effect when a participant is speaking
Inspired by Gmeet animation (mine is far from perfect).

Some details should be improved, especially to have a smooth
animation stop when the user stops speaking.
2024-08-28 10:24:20 +02:00
lebaudantoine b3ef57e1b6 (frontend) introduce a custom placeholder
Based on Gmeet and Jitsi UI, display the Avatar component as
placeholder.
2024-08-28 10:24:20 +02:00
lebaudantoine b554a6a542 💩(frontend) duplicate elements related to the participant tile
Duplicated LiveKit sources to customize the participant tile. It’s needed
to enhance the design and color of the participant placeholder.

More in the next commits.
2024-08-28 10:24:20 +02:00
renovate[bot] 574fd6dc89 ⬆️(dependencies) update python dependencies 2024-08-26 17:34:25 +02:00
lebaudantoine 8d77332a0a (frontend) create meeting for later
Inspired by the Google Meet user experience.
A simple UX for scheduling meetings was requested by some users.
This is a quick and basic poc to validate the UX in a production
environment. If it gains traction, it will be refined and consolidated.
2024-08-22 11:13:40 +02:00
lebaudantoine 0e2c805b41 💄(frontend) reserve left padding for select options
Extra padding should be reserved for select options that
display a checkmark when an option is selected.
2024-08-21 20:07:49 +02:00
lebaudantoine 9782eb8c7c (frontend) allow user to test audio output
Inspired by GMeet user experience. Offer a sound tester button
to our users while configuring their audio output.

uprise.mp3 has a free license. We should credit them in our legal
terms asap.
2024-08-21 19:09:10 +02:00
Jacques ROUSSEL f6bc57ba91 🔒️(helm) configure staging to use livekit-staging
Reconfigure staging environment to use
livekit-staging.beta.numerique.gouv.fr
2024-08-21 10:54:49 +02:00
lebaudantoine 4d5aec9a49 (frontend) add the AudioTab component
First draft, it lacks important features, as a visual indicator when
the mic is active, and a trigger to test the audio output.

I made some heuristics (e.g. default output/input, etc..) to ship a
first version of this setting tabs that should work good enough to
create value for our current users.

Please refer to the inline comments.
2024-08-20 16:05:06 +02:00
lebaudantoine 74b296aa37 (frontend) add utils to determine user's browser
Copied from LiveKit utils functions.
Using LiveKit client, determine whether the user's browser is
Safari, chromium based, or firefox.
2024-08-20 16:05:06 +02:00
lebaudantoine 88fadd1d61 💄(frontend) add margin to the close Dialog button
When hovered, button's borders were touching the Dialog borders,
which did feel great.
2024-08-20 16:05:06 +02:00
lebaudantoine cac58f49d3 (frontend) add GeneralTab
Introduced a General settings tab, currently allowing language configuration.
Although this commit introduces some duplication, the default settings dialog
will be refactored in future updates.

The General tab is designed to accommodate additional features over time.
2024-08-20 16:05:06 +02:00
lebaudantoine 12c27eedac ♻️(frontend) move name update to AccountTab
User can now update their name in a dedicated Account tab,
inspired by Jitsi design. Removed the username dialog.

Account-related updates will be operated through the advanced
settings dialog, in this dedicated tab.
2024-08-20 16:05:06 +02:00
lebaudantoine 6a7ec95493 💄(frontend) create padding variants for TabPanel
Inspired by other videoconferencing tools' designs.
Sizes are mixing pixels and rem, it should be harmonized.
Will be used by the advanced settings panels.
2024-08-20 16:05:06 +02:00
lebaudantoine e41656a760 ♻️(frontend) extract Avatar in dedicated component
Refactored it in a proper component. Initially planned to
use it in the Account Tab, but I changed my mind.

This refactoring is an enhancement, the Avatar props are better
encapsulated.
2024-08-20 16:05:06 +02:00
lebaudantoine eb90c0f28c (frontend) add icons in SettingsDialogExtended tabs
Made it user friendly by adding icons.
It follows other videconference tools designs.
2024-08-20 16:05:06 +02:00
lebaudantoine f3c4b0ac40 (frontend) introduce SettingsDialogExtended
This component should be the definitive layout for settings.
Why extended? I kept the original one, which is still in-use in the
homepage, and considered this settings dialos as an extended version,
or advanced one.

I hope in a close future, we merge these two dialogs to avoid
maintaining too much code. Not the top prio.

Relevant TabPanel components will be introduced in the upcomming commits.
2024-08-20 16:05:06 +02:00
lebaudantoine 038e6368e4 💄(frontend) introduce 'flex' TabPanel variant
By default, TabPanel used the flow layout alg.
Added a variant to support flex layou in TabPanel
2024-08-20 16:05:06 +02:00
lebaudantoine 028f20375f 🩹(frontend) avoid evaluating with 0 in ParticipantList
Even if the list is empty, previous assertion was returning true.
Fixed it, mybad, noob error.

In fact, the participants list should never be empty, as you are
in the meeting to access it.
2024-08-20 16:05:06 +02:00
lebaudantoine ccc23ff46a 💄(frontend) add margins to Dialog
On very small screens, Dialog were touching the screen borders.
Add a minimum margin to improve its visual appearance and ensure
better spacing.
2024-08-20 16:05:06 +02:00
lebaudantoine e3b7a1f77b 💄(frontend) style hovered or selected Tab
Introduce a new variant, by default disabled, that visually
style a hovered or selected Tab.
2024-08-20 16:05:06 +02:00
lebaudantoine 69a8eea1ce ♻️(frontend) enable flexible dialog sizing temporary
The 'Box' component has a type 'dialog' which limits the Dialog size.
The default size is insufficient for larger dialogs like
the advanced settings.

To address this temporarily, I'm allowing the wrapper component to set the
Dialog size directly. This isn't ideal, but will enable better flexibility
until we finalize the user experience and dialog requirements.

I am not sure of the new props' naming introduced.
2024-08-20 16:05:06 +02:00
lebaudantoine 20493edd07 💄(frontend) support bordeless Tabs and TabList
Added a new variant for Tabs and TabList components
to support borderless styles.

By default, both Tabs and TabList include borders.
Using React context, TabList’s children will automatically
inherit a borderless style if specified via props.

However, you can still explicitly apply borders
to individual children, even if the parent
TabList is set to be borderless.
2024-08-20 16:05:06 +02:00
lebaudantoine 79f7fcab6e (frontend) introduce Tab-related components
Style react aria components and exposed them.
Tabs are needed for the advanced settings component.

Both orientations, horizontal and vertical, are supported.
2024-08-20 16:05:06 +02:00
lebaudantoine ce3a6dab12 ♻️(frontend) make Dialog's title optional
Introduce an option to render or omit the title in the Dialog component.
This is necessary for the advanced settings Dialog, which displays its
title in the Tab menu.

This could affect existing component API created by @manuhabitela.
The component will become less opinionated, offering more flexibility
in its usage (I am not 100% convinced it's actually a great idea).

Quick fix! Let's enhance it
2024-08-20 16:05:06 +02:00
renovate[bot] 1b57dd0ecf ⬆️(dependencies) update django to v5.0.8 [SECURITY] 2024-08-20 16:01:53 +02:00
lebaudantoine c6318910c2 🚨(eslint) disable 'only-export-components' rule for Text
Text.tsx export not only a component, but also few types.
This triggers an eslint warning. I don't want refactoring it
without discussing it with @manuhabitela. Silence it.
2024-08-13 15:37:51 +02:00
lebaudantoine 03b3630611 ♻️(frontend) refactor options menu
@manuhabitela introduced Menu and MenuTrigger components. Refactor the
options menu to benefits from his components.

Few details are not perfect yet. wip
2024-08-13 15:37:51 +02:00
lebaudantoine 5b8c8d493a ✏️(frontend) fix minor typo
Found a typo and fixed it.
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier b12fb6bf48 🐛(pandacss) use recipes instead of bare style objects to fix gen issues
it seems panda-css didn't like my way of sharing css code. When sharing
bare objects matching panda-css interface, panda-css didn't understand
those were styles and didn't parse them. Now using actual recipes via
the `cva` helper, panda understands correctly those styles need to be
updated on change.

ps: cleaned a few panda imports that didn't use our "@" alias
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier a8f64f5a36 💄(buttons) new button styles ("invisible" toggled + danger)
this will be used for toggled on/off mic and camera buttons. We want
toggled-on buttons that don't look pressed as it looks a bit weird
(we'll change icons on pressed/unpressed state). And we want red buttons
for when the buttons are not pressed, so adding the "danger" variant
too.

the whole colorpalette stuff needs a bit of work to be clean but it'll
do for now
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier 6ab5b3300a 🌐(frontend) make react aria use current language
react aria has default strings for a few UI elements (like "select an
item" on an empty select), make sure it uses currently defined language.
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier 6189e6454d ♻️(frontend) extract ToggleButton in a proper component
Refactor the existing Button component to extract a new ToggleButton
component that wraps react aria ToggleButton. This will
be used to toggle cam/mic on/off, or any other controls in the control
bar.
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier a8b2c56f4b (frontend) new Menu component
ditch the "PopoverList" component that was basically a poor Menu. Now
dropdown buttons can be made with react aria Menu+MenuItems components
via the Menu+MenuList components.

The difference now is dropdown menus behave better with keyboard (they
use up/down arrows instead of tabs), like selects.

Popovers are there if we need to show any content in a big tooltip, not
for actionable list items.
2024-08-13 15:37:51 +02:00
Emmanuel Pelletier b472220151 🔥(frontend) remove languageselector code
we don't have any language selector dropdown button anymore, we select
language in the settings
2024-08-13 15:37:51 +02:00
lebaudantoine 223cee3828 🔖(minor) bump release to 0.1.4 2024-08-12 23:32:19 +02:00
lebaudantoine 101be503bf 🚸(frontend) fix widget state management
The current design is inefficient, using two separate approaches to manage
the app state and determine which widget is open. This inconsistency affects
the user experience and overall design quality.

Soon, only one container will be open/close, and its content will be toggle,
using an extensible system.

As a quick fix, I’ve added basic logic to ensure that opening one widget
automatically closes any other open widgets, such as the chat or participants
widget. This is a temporary measure to fix state management until a
more robust solution can be implemented.
2024-08-12 23:16:01 +02:00
lebaudantoine 47e2d85471 ♻️(frontend) refactor Chat toggle
This commit refactor the default chat toggle to enhance few details:

- styling (e.g. button's size, icon's style, etc)
- accessibility (e.g. tooltip, aria label, keyboard controls)
- notifications (enhanced contrast on the red dot, feedback from Arnaud)
2024-08-12 23:16:01 +02:00
lebaudantoine 16321b3cb0 🚧(frontend) introduce a participants list
I faced few challenge while using LiveKit hook, please refer to my commits.
I'll work on a PR on their repo to get it fixed. Duplicating their sources
add overhead.

This commit introduce the most minimal participants list possible. More
controls or information will be added in the upcomming commits.
Responsiveness and accessibility should be functional.

Soon, any right side panel will share the same container to avoid visual
glichts.
2024-08-12 23:16:01 +02:00
lebaudantoine 144cb56cda 💄(frontend) add spacing around dialog close icon
Feel weird having the button border touching the Dialog's one.
Add a bit of spacing to fix this visual issue.
2024-08-12 23:16:01 +02:00
lebaudantoine e6a15e36b4 (frontend) create a control button for participants list
Introduced a button to manage the visibility of the participants list.

Encountered refresh issues with the room context where the participant count
in the room metadata does not update in real-time, causing discrepancies
between the actual number of participants and the displayed count. To address
this, I utilized the participants hook, which updates more frequently and
ensures consistency.
2024-08-12 23:16:01 +02:00
lebaudantoine 3541af5992 (frontend) extend button primitive with a toggle variant
Encountered issues extending the current button due to the complexity of the
Button and Link props construction. I’ll need to redesign the component to
enhance its extensibility.

The legacy style adheres to the previous interface but suffers from poor
color
contrast. Hex codes were used to match the selected toggle color,
resulting
in a difficult color system to maintain. This approach needs refactoring
for better color handling. Legacy styles will soon disappear.

Efforts were made to minimize code for handling the toggle functionality.
Future work should focus on refactoring the button to align with the field
approach for improved developer experience and props validation.
2024-08-12 23:16:01 +02:00
lebaudantoine 68f0ea3639 🩹(frontend) prevent controls shift when opening chat
In LiveKit, opening the chat causes the entire page layout, including
participant tiles and controls, to shift left. This disrupts the user
experience, as the chat toggle button moves, requiring users to reposition
their cursor to close the chat.

This behavior also prevents effective organization of the control bar,
especially when trying to maintain a centered section for main controls and
a fixed section for quick-access buttons on the left or right bottom corners.

This fix temporarily locks the controls and participant list in place when
the chat is toggled. The styles will be refactored during the upcoming
redesign.
2024-08-12 23:16:01 +02:00
lebaudantoine 925eb92c60 🗃️(database) add missing migrations for user's language
Addressed missing migrations for the user's language field,
ensuring recent updates are correctly applied.
2024-08-09 17:25:09 +02:00
lebaudantoine d965ee7060 🔒️(helm) allow server host and whitelist pod IP for health checks
Updated Django's ALLOWED_HOSTS setting from '*' to the specific host of the
server. Setting ALLOWED_HOSTS to '*' is a security risk as it allows any host
to access the application, potentially exposing it to malicious attacks.
Restricting ALLOWED_HOSTS to the server's host ensures only legitimate
requests are processed.

In a Kubernetes environment, we also needed to whitelist the pod's IP address
to allow health checks to pass. This ensures that Kubernetes liveness and
readiness probes can access the application to verify its health.
2024-08-09 17:25:09 +02:00
lebaudantoine aef85bb1ab 🏗️(bin) merge duplicated folders scripts and bin
Few scripts were duplicated between the scripts and the bin folders.
Reorganize the scripts in a common folder, and align filenames to
follow the same rule.
2024-08-09 17:25:09 +02:00
lebaudantoine 8f59b08088 🩺(helm) update liveness and readiness probes from 10s to 30s
Updated the liveness and readiness probes interval from every 10 seconds to
every 30 seconds. This change reduces the load on the server by decreasing
the frequency of health checks.

Given the current stability of the application, a 30-second interval is
sufficient to ensure that the application remains responsive and healthy.
2024-08-09 17:25:09 +02:00
lebaudantoine 366998b3d6 🔇(backend) silence security warnings for reverse proxy setup
Silenced certain Django security warnings because the application is served
behind a reverse proxy. These warnings are not applicable in our deployment
context, where the reverse proxy handles these security concerns.

This change ensures relevant security measures are appropriately managed
while avoiding unnecessary warnings. Any question? asked @rouja.

/!\ actually, this commit is not working, and should be fixed.
2024-08-09 17:25:09 +02:00
lebaudantoine c8ad0ab24f 🔥(terraform) remove legacy terraform and OpenStack references
Some outdated references to Terraform and OpenStack were missed during
the project quickstart. These are legacy elements inherited from OpenFun.

This commit cleans up the codebase.
2024-08-09 17:25:09 +02:00
lebaudantoine d2bbcb0f51 🔇(backend) fix E010 Django warning logs
Room model uses a default value for its configuration.
However, I used a wrong default value, it should be a callable.

Align the code with Magnify.
2024-08-09 17:25:09 +02:00
lebaudantoine 40457bd68a 🐛(frontend) avoid unnecessary refetches of room's data
LiveKit access tokens are valid for 6 hours after generation. Without a
`staleTime` set, room data becomes stale immediately (0 milliseconds),
causing unnecessary refetches, particularly when users switch focus back
to the call window.

This issue led to an excessive number of "get room" events, as observed
in the analytics. For example, during a meeting, if a user switches tabs
and then returns, the room data would be refetched despite the access
token still being valid.

By adding a `staleTime` aligned with the token's validity, we avoid
unnecessary network requests.
2024-08-08 14:38:34 +02:00
lebaudantoine c21c5f604b 🩹(frontend) clear router state on page reload
(aligned with Google Meet or Jitsi ux)

This ensures participants join a room with a fresh access token.

Some participants information might be altered after the access token
was generated (e.g. their name).

I wanted the initial data to be passed only once, when you navigate
to the room from the create button, but if you reload the page, then
a new access token will be fetched, and the pre-join screen displayed.
2024-08-08 14:37:40 +02:00
lebaudantoine 66350b8fb5 🐛(frontend) pass participant's name while creating a room
Fixed an issue where users' chosen usernames were not
being included when creating a room.
2024-08-08 14:34:18 +02:00
lebaudantoine 378cc3a651 (frontend) persist local participant name on updates
Utilized local storage to persist the updated name. After careful
consideration, persisting all user choices in the backend was deemed overly
complex and unnecessary for the value it would provide.

Opted for a single responsibility approach by leveraging local storage to manage
user choices, aligning with LiveKit's default implementation.
2024-08-08 14:34:18 +02:00
lebaudantoine 28f43fb2c0 ♻️(backend) refactor LiveKit access token handling
Switched to using query parameters instead of GET requests, enabling the
inclusion of additional parameters when calling the create endpoint via POST.

Simplified the access token generation process by removing redundant calls to
`with_identity` and consolidating token generation steps. This streamlines the
method flow by preparing all necessary data before generating the access token.
2024-08-08 14:34:18 +02:00
lebaudantoine 37eea16a50 (frontend) allow local participant to update her name
The user name is initially set by the backend when generating the LiveKit access
token. By default, if the frontend has not provided a username, the backend uses
the user's email address. This approach isn't ideal, as some users prefer using
their first name.

This update allows local participant to change their username in real-time
during a session. However, these changes are not yet persisted for
future meetings. This persistence feature will be added in upcoming commits.
2024-08-08 14:34:18 +02:00
lebaudantoine fe8ed43aae (backend) allow LiveKit users to update their own metadata
This is required, if users need to update their names, or participant
information.
2024-08-08 14:34:18 +02:00
lebaudantoine 671aa68804 ♻️(frontend) restore User Information in Settings Dialog
Reintroduced user information in the settings dialog. This change allows
users to see the account they are logged into, which is important for
account management. An additional feature to update usernames will be
added soon. Apologies to @manuhabitela for the oversight.
2024-08-08 14:34:18 +02:00
lebaudantoine 6449aaf7ea ♻️(frontend) remove header from video conference
Removed the header when inside a room to maximize space for the camera
gallery. Settings are now accessible via the options menu.

It aligns the design with common video conference tools as meet,
or jitsi. @manuhabitela was aligned with this change.
2024-08-08 14:34:18 +02:00
lebaudantoine f8fff3dbdf ♻️(frontend) allow opening Settings dialog in a controlled way
I am not hundred percent sure it respects @manuhabitela's guidelines,
but for the options menu, I would need to trigger a dialog in a controlled way.

I tried to have the minimal impact on the existing settings dialog.
2024-08-08 14:34:18 +02:00
lebaudantoine 1715ec10dd (frontend) introduce a menu with more actions
Replaced the existing settings button with a new `OptionsButton` component.
This new component integrates a menu for more actions, providing an
extensible and accessible interface.

Refactoring will follow to enhance code maintainability.

A legacy style was introduced to keep visual coherence with the other controls
from the controls bar. It's temporary, until we redesign the whole bar.
It doesn't match pixel-perfect the legacy styles.

A new public Tchap chan was created. All users, and not only beta users,
can now join it using the link in the menu, and reach us.
2024-08-08 14:34:18 +02:00
lebaudantoine 8c7aed4b00 ♻️(frontend) simplify the controls component
As we duplicated the code, we don't need anymore to configure
the available controls from the video conference.
2024-08-08 14:34:18 +02:00
lebaudantoine 217b19e42a 🌐(frontend) internationalize video conference controls
Localized video conference controls to match the selected language.

Previously, controls were always in English, causing confusion for French users.
A full refactor of the controls is planned soon.
2024-08-08 14:34:18 +02:00
lebaudantoine bd26a0abc1 💩(frontend) duplicate Controls bar
Same approach as the previous commit. Few elements were not exposed
by the LiveKit package, and I had to duplicate them. Let's see if
we can asap get rid off all this complexity.
2024-08-08 14:34:18 +02:00
lebaudantoine abb708aa49 💩(frontend) duplicate VideoConference component
Basically, duplicate LiveKit code to start iterating on their
components, not sure wether it's the optimal strategy, but at least
we will be more agile, shipping small features which are lacking.
2024-08-08 14:34:18 +02:00
lebaudantoine 5d35161ae3 👷(frontend) add linting and formatting checks for frontend
Added CI job to run linting and formatting checks in the frontend
codebase. Please note, we should cache frontend dependencies,
to avoid re-installing them. Future improvement!
2024-08-06 12:25:22 +02:00
lebaudantoine 0b6f58bf9c 🚨(frontend) run Prettier on the codebase
Ran Prettier on the entire codebase to fix formatting issues. My IDE was
previously misconfigured, causing most of these errors. The IDE configuration
has been corrected.
2024-08-06 12:25:22 +02:00
lebaudantoine 79519fef26 🔧(frontend) configure Prettier and ESLint
Set up Prettier with minimal configuration. Installed eslint-config-prettier to
prevent conflicts between ESLint and Prettier.
2024-08-06 12:25:22 +02:00
lebaudantoine d7b1fbaf28 ♻️(mail) refactor mail to use npm instead of yarn
Following @manuhabitela's recommendation, we've decided to switch
from Yarn to npm for managing our Node.js dependencies. This update
aligns all remaining parts of the codebase that were still using
Yarn to now utilize npm.
2024-08-06 11:16:54 +02:00
renovate[bot] 26bc67d1b4 ⬆️(dependencies) update boto3 to v1.34.154 2024-08-06 10:42:41 +02:00
lebaudantoine b783a8bac6 📝(doc) add an example for the frontend image
I added an example in the documentation to ensure we remember to upgrade the
frontend image during the release process. This will help prevent any issues
related to outdated images when deploying new versions.

(I did this mistake while releasing)
2024-08-05 23:14:06 +02:00
196 changed files with 11651 additions and 5189 deletions
+15 -2
View File
@@ -1,4 +1,5 @@
name: Docker Hub Workflow
run-name: Docker Hub Workflow
on:
workflow_dispatch:
@@ -48,9 +49,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
target: backend-production
@@ -92,9 +99,15 @@ jobs:
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
+22
View File
@@ -0,0 +1,22 @@
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"
+18
View File
@@ -122,6 +122,9 @@ jobs:
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
@@ -160,6 +163,21 @@ jobs:
- name: Run tests
run: ~/.local/bin/pytest -n 2
lint-front:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: cd src/frontend/ && npm ci
- name: Check linting
run: cd src/frontend/ && npm run lint
- name: Check format
run: cd src/frontend/ && npm run check
i18n-crowdin:
runs-on: ubuntu-latest
steps:
+2
View File
@@ -8,3 +8,5 @@ creation_rules:
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
+16 -25
View File
@@ -1,15 +1,14 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.10-slim-bullseye as base
FROM python:3.12.6-alpine3.20 as base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
rm -rf /var/lib/apt/lists/*
RUN apk update && \
apk upgrade
# ---- Back-end builder image ----
FROM base as back-builder
@@ -38,12 +37,9 @@ RUN yarn install --frozen-lockfile && \
FROM base as link-collector
ARG MEET_STATIC_ROOT=/data/static
# Install libpangocairo & rdfind
RUN apt-get update && \
apt-get install -y \
libpangocairo-1.0-0 \
rdfind && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
pango \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
@@ -66,17 +62,14 @@ FROM base as core
ENV PYTHONUNBUFFERED=1
# Install required system libs
RUN apt-get update && \
apt-get install -y \
gettext \
libcairo2 \
libffi-dev \
libgdk-pixbuf2.0-0 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
@@ -106,9 +99,7 @@ FROM core as backend-development
USER root:root
# Install psql
RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
+5 -5
View File
@@ -48,7 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -259,19 +259,19 @@ i18n-generate-and-upload: \
# -- Mail generator
mails-build: ## Convert mjml files to html and text
@$(MAIL_YARN) build
@$(MAIL_NPM) run build
.PHONY: mails-build
mails-build-html-to-plain-text: ## Convert html files to text
@$(MAIL_YARN) build-html-to-plain-text
@$(MAIL_NPM) run build-html-to-plain-text
.PHONY: mails-build-html-to-plain-text
mails-build-mjml-to-html: ## Convert mjml files to html and text
@$(MAIL_YARN) build-mjml-to-html
@$(MAIL_NPM) run build-mjml-to-html
.PHONY: mails-build-mjml-to-html
mails-install: ## install the mail generator
@$(MAIL_YARN) install
@$(MAIL_NPM) install
.PHONY: mails-install
-64
View File
@@ -5,7 +5,6 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="meet"
@@ -92,66 +91,3 @@ function _dc_exec() {
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
}
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
# terraform directory.
#
# usage: _set_openstack_project
#
# If necessary the script will prompt the user to choose a project from those available
function _set_openstack_project() {
declare prompt
declare -a projects
declare -i default=1
declare -i choice=0
declare -i n_projects
# List projects by looking in the "./env.d/terraform" directory
# and store them in an array
read -r -a projects <<< "$(
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
xargs
)"
nb_projects=${#projects[@]}
if [[ ${nb_projects} -le 0 ]]; then
echo "There are no OpenStack projects defined..." >&2
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
"of your project and copy your \"openrc.sh\" file into it." >&2
exit 10
fi
if [[ ${nb_projects} -gt 1 ]]; then
prompt="Select an OpenStack project to target:\\n"
for (( i=0; i<nb_projects; i++ )); do
prompt+="[$((i+1))] ${projects[$i]}"
if [[ $((i+1)) -eq ${default} ]]; then
prompt+=" (default)"
fi
prompt+="\\n"
done
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
prompt+="Your choice: "
read -r -p "$(echo -e "${prompt}")" choice
if [[ ${choice} -gt nb_projects ]]; then
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
exit 11
fi
if [[ ${choice} -le 0 ]]; then
choice=${default}
fi
fi
project=${projects[$((choice-1))]}
# Check that the openrc.sh file exists for this project
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
exit 12
fi
echo "${project}"
}
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
terraform-state "$@"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
-e TF_VAR_user_name \
terraform "$@"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
echo -e "\n"
done
+26
View File
@@ -16,6 +16,30 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -40,6 +64,7 @@ services:
- mailcatcher
- redis
- nginx
- createbuckets
- livekit
celery-dev:
@@ -73,6 +98,7 @@ services:
depends_on:
- postgresql
- redis
- createbuckets
- livekit
celery:
+19 -11
View File
@@ -3,20 +3,28 @@
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
...
frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
+3
View File
@@ -18,6 +18,9 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+3 -1
View File
@@ -13,7 +13,9 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": []
"matchPackageNames": [
"eslint"
]
}
]
}
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+1 -1
Submodule secrets updated: 9da011f5c2...8ef9f4513a
+1 -1
View File
@@ -450,7 +450,7 @@ max-branches=12
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
max-parents=10
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
+29 -18
View File
@@ -39,30 +39,30 @@ class IsSelf(IsAuthenticated):
return obj == request.user
class RoomPermissions(permissions.BasePermission):
"""
Permissions applying to the room API endpoint.
"""
# class RoomPermissions(permissions.BasePermission):
# """
# Permissions applying to the room API endpoint.
# """
def has_permission(self, request, view):
"""Only allow authenticated users for unsafe methods."""
if request.method in permissions.SAFE_METHODS:
return True
# def has_permission(self, request, view):
# """Only allow authenticated users for unsafe methods."""
# if request.method in permissions.SAFE_METHODS:
# return True
return request.user.is_authenticated
# return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Object permissions are only given to administrators of the room."""
# def has_object_permission(self, request, view, obj):
# """Object permissions are only given to administrators of the room."""
if request.method in permissions.SAFE_METHODS:
return True
# if request.method in permissions.SAFE_METHODS:
# return True
user = request.user
# user = request.user
if request.method == "DELETE":
return obj.is_owner(user)
# if request.method == "DELETE":
# return obj.is_owner(user)
return obj.is_administrator(user)
# return obj.is_administrator(user)
class ResourceAccessPermission(permissions.BasePermission):
@@ -82,4 +82,15 @@ class ResourceAccessPermission(permissions.BasePermission):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator(user)
return RoleChoices.is_administrator(obj.resource.get_roles(user))
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
def has_permission(self, request, view):
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
return obj.get_abilities(request.user).get(view.action, False)
+27 -7
View File
@@ -87,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
user = UserSerializer(read_only=True)
class ListRoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for a list API endpoint."""
class Meta:
model = models.Room
fields = ["id", "name", "slug", "is_public"]
read_only_fields = ["id", "slug"]
class RoomSerializer(serializers.ModelSerializer):
"""Serialize Room model for the API."""
@@ -106,10 +115,10 @@ class RoomSerializer(serializers.ModelSerializer):
if not request:
return output
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
roles = instance.get_roles(request.user)
is_administrator = models.RoleChoices.is_administrator(roles)
if role is not None:
if roles:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -117,13 +126,13 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
if not is_admin:
if not is_administrator:
del output["configuration"]
if role is not None or instance.is_public:
if roles or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.GET.get("username", None)
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
@@ -133,6 +142,17 @@ class RoomSerializer(serializers.ModelSerializer):
),
}
output["is_administrable"] = is_admin
output["is_administrable"] = is_administrator
return output
class RecordingSerializer(serializers.ModelSerializer):
"""Serialize Recording for the API."""
room = ListRoomSerializer(read_only=True)
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "stopped_at", "status"]
read_only_fields = fields
+33
View File
@@ -0,0 +1,33 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+123 -8
View File
@@ -1,17 +1,22 @@
"""API endpoints"""
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from rest_framework import (
decorators,
exceptions,
mixins,
pagination,
status,
viewsets,
)
from rest_framework import (
@@ -25,6 +30,13 @@ from . import permissions, serializers
# pylint: disable=too-many-ancestors
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
RECORDING_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}recordings/({UUID_REGEX:s})/file.mp4/$"
)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -162,7 +174,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
permission_classes = [permissions.RoomPermissions]
permission_classes = [permissions.AccessPermission]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -186,16 +198,10 @@ class RoomViewSet(
"""
try:
instance = self.get_object()
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
except Http404:
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
@@ -209,6 +215,11 @@ class RoomViewSet(
},
}
else:
analytics.track(
user=self.request.user,
event="Get Room",
properties={"slug": instance.slug},
)
data = self.get_serializer(instance).data
return drf_response.Response(data)
@@ -249,6 +260,17 @@ class RoomViewSet(
},
)
@decorators.action(detail=True, methods=["post"], url_path="start-recording")
def start_recording(self, request, *args, **kwargs):
"""This view is used to start a recording for a room."""
# Check permission first
room = self.get_object()
recording = room.start_recording()
return drf_response.Response(
{"recording": recording.id}, status=status.HTTP_201_CREATED
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
@@ -293,3 +315,96 @@ class ResourceAccessViewSet(
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
API endpoints to access and perform actions on recordings.
"""
pagination_class = Pagination
permission_classes = [permissions.AccessPermission]
queryset = models.Recording.objects.all()
serializer_class = serializers.RecordingSerializer
def list(self, request, *args, **kwargs):
"""Restrict resources returned by the list endpoint to a user's room."""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if user.is_authenticated:
queryset = queryset.filter(room__accesses__user=user)
else:
queryset = queryset.none()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
@decorators.action(detail=True, methods=["post"], url_path="stop")
def stop(self, request, *args, **kwargs):
"""
This view is used to stop a recording. It can be called anonymously as the
recording ID will not have been communicated anywhere at the time of recording.
"""
recording = self.get_object()
if recording.stopped_at is not None:
raise exceptions.PermissionDenied()
recording.stopped_at = timezone.now()
recording.save()
# TODO: Generate summary and send to note taking app
serializer = self.get_serializer(recording)
return drf_response.Response(serializer.data, status=status.HTTP_200_OK)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
if not request.user.is_authenticated:
raise exceptions.AuthenticationFailed()
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = RECORDING_URL_PATTERN.search(original_url.path)
try:
(pk,) = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
if not models.Recording.objects.filter(
pk=pk, room__accesses__user=request.user
).exists():
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
key = models.Recording(pk=pk).key
request = utils.generate_s3_authorization_headers(key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+9
View File
@@ -65,3 +65,12 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
class RecordingFactory(factory.django.DjangoModelFactory):
"""Create fake recordings for testing."""
class Meta:
model = models.Recording
room = factory.SubFactory(RoomFactory)
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AlterField(
model_name='room',
name='configuration',
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
)
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_room_configuration'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -0,0 +1,37 @@
# Generated by Django 5.1.1 on 2024-10-12 11:52
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_user_language'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
migrations.CreateModel(
name='Recording',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('stopped_at', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
('status', models.CharField(choices=[('recording', 'Recording'), ('done', 'Done')], default='recording')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='core.room', verbose_name='Room')),
],
options={
'verbose_name': 'Recording',
'verbose_name_plural': 'Recordings',
'db_table': 'meet_recording',
'ordering': ('created_at',),
},
),
]
+83 -27
View File
@@ -28,14 +28,16 @@ class RoleChoices(models.TextChoices):
OWNER = "owner", _("Owner")
@classmethod
def check_administrator_role(cls, role):
def is_administrator(cls, roles):
"""Check if a role is administrator."""
return role in [cls.ADMIN, cls.OWNER]
return bool(set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}))
@classmethod
def check_owner_role(cls, role):
"""Check if a role is owner."""
return role == cls.OWNER
class RecordingStatusChoices(models.TextChoices):
"""Recording status choices."""
RECORDING = "recording", _("Recording")
DONE = "done", _("Done")
class BaseModel(models.Model):
@@ -193,34 +195,44 @@ class Resource(BaseModel):
except AttributeError:
return f"Resource {self.id!s}"
def get_role(self, user):
"""
Determine the role of a given user in this resource.
"""
if not user or not user.is_authenticated:
return None
role = None
for access in self.accesses.filter(user=user):
if access.role == RoleChoices.OWNER:
return RoleChoices.OWNER
if access.role == RoleChoices.ADMIN:
role = RoleChoices.ADMIN
if access.role == RoleChoices.MEMBER and role != RoleChoices.ADMIN:
role = RoleChoices.MEMBER
return role
def is_administrator(self, user):
"""
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.check_administrator_role(self.get_role(user))
return RoleChoices.is_administrator(self.get_roles(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
return RoleChoices.check_owner_role(self.get_role(user))
return RoleChoices.OWNER in self.get_roles(user)
def get_roles(self, user):
"""Compute the roles a user has in a room."""
if not user or not user.is_authenticated:
return self.accesses.none()
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = self.accesses.filter(user=user).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = self.accesses.none()
return roles
def get_abilities(self, user):
"""Compute and return abilities for a given user on the room."""
roles = self.get_roles(user)
is_owner_or_admin = RoleChoices.is_administrator(roles)
return {
"start_recording": is_owner_or_admin,
"destroy": RoleChoices.OWNER in roles,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin,
"retrieve": True,
"update": is_owner_or_admin,
}
class ResourceAccess(BaseModel):
@@ -296,7 +308,7 @@ class Room(Resource):
configuration = models.JSONField(
blank=True,
default={},
default=dict,
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
@@ -325,3 +337,47 @@ class Room(Resource):
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
def start_recording(self):
"""Create a new related recording object to which Livekit will be able to save a file."""
return Recording.objects.create(room=self)
class Recording(BaseModel):
"""Model for recording meetings that take place in a room"""
room = models.ForeignKey(
Room, on_delete=models.CASCADE, related_name="meetings", verbose_name=_("Room")
)
stopped_at = models.DateTimeField(verbose_name=_("End Time"), null=True, blank=True)
status = models.CharField(
choices=RecordingStatusChoices, default=RecordingStatusChoices.RECORDING
)
class Meta:
db_table = "meet_recording"
ordering = ("created_at",)
verbose_name = _("Recording")
verbose_name_plural = _("Recordings")
def __str__(self):
return _(
f"Recording in {self.room.name:s} on {self.created_at:%B %d, %Y at %I:%M %p}"
)
@property
def key(self):
"""Return the path where the recording file will be stored in object storage."""
return f"recordings/{self.pk!s}/file.mp4/"
def get_abilities(self, user):
"""Compute and return abilities for a given user on the recording."""
roles = self.room.get_roles(user)
return {
"destroy": RoleChoices.OWNER in roles,
"partial_update": False,
"retrieve": False,
"stop": True,
"update": False,
}
@@ -0,0 +1,102 @@
"""
Test recordings API endpoints in the Meet core app: delete.
"""
import pytest
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory
from ...models import Recording
pytestmark = pytest.mark.django_db
def test_api_recordings_delete_anonymous():
"""Anonymous users should not be allowed to destroy a recording."""
recording = RecordingFactory()
client = APIClient()
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 401
assert Recording.objects.count() == 1
def test_api_recordings_delete_authenticated():
"""
Authenticated users should not be allowed to delete a recording from a room to which
they are not related.
"""
recording = RecordingFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id!s}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_members():
"""
Authenticated users should not be allowed to delete a recording from a room of which
they are only a member.
"""
user = UserFactory()
recording = RecordingFactory(
room__users=[(user, "member")]
) # as user declared in the room but not administrator
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_administrators():
"""
Authenticated users should not be allowed to delete a recording from a room in which
they are administrator.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "administrator")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 403
assert Recording.objects.count() == 1
def test_api_recordings_delete_owners():
"""
Authenticated users should be able to delete a recording from a room in which they are
directly owner.
"""
user = UserFactory()
recording = RecordingFactory(room__users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/recordings/{recording.id}/",
)
assert response.status_code == 204
assert Recording.objects.exists() is False
@@ -0,0 +1,132 @@
"""
Test recordings API endpoints in the Meet core app: list.
"""
from unittest import mock
import pytest
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recordings_list_anonymous():
"""Anonymous users should not be able to list recordings."""
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
response = APIClient().get("/api/v1.0/recordings/")
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
def test_api_recordings_list_authenticated():
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.RecordingFactory(room__is_public=False)
factories.RecordingFactory(room__is_public=True)
factories.RecordingFactory(room__is_public=False, room__users=[other_user])
recording = factories.RecordingFactory(room__is_public=False, room__users=[user])
room = recording.room
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 1
expected_ids = {
str(recording.id),
}
result_ids = {result["id"] for result in results}
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"stopped_at": None,
"room": {
"id": str(room.id),
"is_public": room.is_public,
"name": room.name,
"slug": room.slug,
},
"status": "recording",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
}
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_recordings_list_pagination(_mock_page_size):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recordings = factories.RecordingFactory.create_batch(3, room__users=[user])
recording_ids = [str(r.id) for r in recordings]
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
recording_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/recordings/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"], "http://testserver/api/v1.0/recordings/"
assert len(content["results"]) == 1
recording_ids.remove(content["results"][0]["id"])
assert recording_ids == []
def test_api_recordings_list_authenticated_distinct():
"""A recording for a public room with several related users should only be listed once."""
user = factories.UserFactory()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(user)
recording = factories.RecordingFactory(
room__is_public=True, room__users=[user, other_user]
)
response = client.get("/api/v1.0/recordings/")
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(recording.id)
@@ -0,0 +1,128 @@
"""
Test file downloads API endpoint for users in Meet's core app.
"""
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# This is a minimal MP4 file header, which creates a 1-second empty video
VIDEO_BYTES = (
b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42mp41"
b"\x00\x00\x00\x08free\x00\x00\x02\xeemdat"
)
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_anonymous(is_public):
"""Anonymous users should not be able to retrieve recordings for a room."""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = APIClient().get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
@pytest.mark.parametrize("is_public", [True, False])
def test_api_recordings_retrieve_auth_authenticated(is_public):
"""
Authenticated users who are not related to a room should not be able to
retrieve recordings for this room.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("role", models.RoleChoices.values)
def test_api_recordings_retrieve_auth_admin_or_owner(role, is_public):
"""
Users who are administrator or owner of a room, should be able to retrieve recordings.
"""
room = factories.RoomFactory(is_public=is_public)
recording = room.start_recording()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.UserResourceAccessFactory(resource=room, user=user, role=role)
# deposit a recording in S3
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=VIDEO_BYTES,
ContentType="video/mp4",
)
# verify getting object content from url with authorization headers
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content == VIDEO_BYTES
@@ -0,0 +1,56 @@
"""
Test recordings API endpoints in the Meet core app: stop.
"""
from datetime import datetime, timezone
from unittest import mock
from uuid import uuid4
from django.utils import timezone as django_timezone
import pytest
from rest_framework.test import APIClient
from core import factories
pytestmark = pytest.mark.django_db
def test_api_recording_stop_recording_success():
"""Anonymous users can stop a recording just with its UUID."""
recording = factories.RecordingFactory()
now = datetime(2010, 1, 1, tzinfo=timezone.utc)
with mock.patch.object(django_timezone, "now", return_value=now):
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
assert recording.stopped_at == now
def test_api_recording_stop_recording_unknown():
"""Trying to stop an unknown recording should return a 404."""
response = APIClient().post(f"/api/v1.0/recordings/{uuid4()!s}/stop/")
assert response.status_code == 404
def test_api_recording_stop_recording_already_stopped():
"""
Trying to stop a recording that was already stopped should return a 403 and leave
the recording unmodified.
"""
recording = factories.RecordingFactory()
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 200
recording.refresh_from_db()
stopped_at = recording.stopped_at
response = APIClient().post(f"/api/v1.0/recordings/{recording.id!s}/stop/")
assert response.status_code == 403
recording.refresh_from_db()
assert recording.stopped_at == stopped_at
@@ -286,7 +286,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,7 +360,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -0,0 +1,88 @@
"""
Test rooms API endpoints in the Meet core app: start-recording.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_rooms_start_recording_anonymous():
"""Anonymous users should not be allowed to start a recording for a room."""
room = factories.RoomFactory()
response = APIClient().post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 401
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_authenticated():
"""
Authenticated users should not be allowed to start a recording for a room
to which they are not related.
"""
user = factories.UserFactory()
room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 403
assert models.Recording.objects.exists() is False
def test_api_rooms_start_recording_members():
"""
Users who are members of a room but not administrators should
not be allowed to start a recording in this room.
"""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, "member")])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators(role):
"""Administrators or owners of a room should be allowed to start a recording in this room."""
user = factories.UserFactory()
room = factories.RoomFactory(users=[(user, role)])
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{room.id!s}/start-recording/")
assert response.status_code == 201
assert models.Recording.objects.get().room == room
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_rooms_start_recording_administrators_of_another(role):
"""
Being administrator or owner of a room should not grant authorization
to update another room.
"""
user = factories.UserFactory()
factories.RoomFactory(users=[(user, role)])
other_room = factories.RoomFactory()
client = APIClient()
client.force_login(user)
response = client.post(f"/api/v1.0/rooms/{other_room.id!s}/start-recording/")
assert response.status_code, 403
assert models.Recording.objects.exists() is False
+21 -36
View File
@@ -86,81 +86,66 @@ def test_models_rooms_is_public_default():
assert room.is_public is True
def test_models_recordings_key():
"""Check the room key format."""
room = RoomFactory()
recording = room.start_recording()
assert recording.key == f"recordings/{recording.pk!s}/file.mp4/"
# Access rights methods
def test_models_rooms_access_rights_none(django_assert_num_queries):
"""Calling access rights methods with None should return None."""
"""The `get_roles` method should return an empty list when called with None."""
room = RoomFactory()
with django_assert_num_queries(0):
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
assert not list(room.get_roles(None))
def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
"""Check access rights methods on the room object for an anonymous user."""
"""The `get_roles` method should return an empty list for an anonymous user."""
user = AnonymousUser()
room = RoomFactory()
with django_assert_num_queries(0):
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
"""Check access rights methods on the room object for an unrelated user."""
"""
The `get_roles` method should return an empty list for a user not related to the room.
"""
user = UserFactory()
room = RoomFactory()
with django_assert_num_queries(1):
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert not list(room.get_roles(user))
def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
"""Check access rights methods on the room object for a direct member."""
"""Check `get_roles` method on the room object for a direct member."""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
with django_assert_num_queries(1):
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert list(room.get_roles(user)) == ["member"]
def test_models_rooms_access_rights_administrator_direct(django_assert_num_queries):
"""The is_administrator method should return True for a direct administrator."""
"""Check `get_roles` method on the room object for a direct administrator."""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
with django_assert_num_queries(1):
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
assert list(room.get_roles(user)) == ["administrator"]
def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
"""Check access rights methods on the room object for an owner."""
"""Check `get_roles` method on the room object for an owner."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
with django_assert_num_queries(1):
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
assert list(room.get_roles(user)) == ["owner"]
+3 -1
View File
@@ -5,12 +5,13 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.api import get_frontend_configuration, viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
@@ -23,6 +24,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
+72 -10
View File
@@ -2,16 +2,45 @@
Utils functions used in the core app
"""
# ruff: noqa:S311
import hashlib
import json
import random
from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
The function seeds the random generator with the identity's hash,
ensuring consistent color output. The HSL format allows fine-tuned control
over saturation and lightness, empirically adjusted to produce visually
appealing and distinct colors. HSL is preferred over hex to constrain the color
range and ensure predictability.
"""
# ruff: noqa:S324
identity_hash = hashlib.sha1(identity.encode("utf-8"))
# Keep only hash's last 16 bits, collisions are not a concern
seed = int(identity_hash.hexdigest(), 16) & 0xFFFF
random.seed(seed)
hue = random.randint(0, 360)
saturation = random.randint(50, 75)
lightness = random.randint(25, 60)
return f"hsl({hue}, {saturation}%, {lightness}%)"
def generate_token(room: str, user, username: Optional[str] = None) -> str:
"""Generate a Livekit access token for a user in a specific room.
"""Generate a LiveKit access token for a user in a specific room.
Args:
room (str): The name of the room.
@@ -22,10 +51,11 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
Returns:
str: The LiveKit JWT access token.
"""
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
@@ -34,18 +64,50 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
],
)
token = AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
).with_grants(video_grants)
if user.is_anonymous:
token.with_identity(str(uuid4()))
identity = str(uuid4())
default_username = "Anonymous"
else:
token.with_identity(user.sub)
identity = str(user.sub)
default_username = str(user)
token.with_name(username or default_username)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_metadata(json.dumps({"color": generate_color(identity)}))
)
return token.to_jwt()
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
Binary file not shown.
Binary file not shown.
+49 -1
View File
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
import json
import os
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -71,6 +72,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
# Application definition
ROOT_URLCONF = "meet.urls"
@@ -117,6 +119,25 @@ class Base(Configuration):
},
}
# Media
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
)
AWS_STORAGE_BUCKET_NAME = values.Value(
"meet-media-storage",
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -249,6 +270,19 @@ class Base(Configuration):
"REDOC_DIST": "SIDECAR",
}
# Frontend
FRONTEND_CONFIGURATION = {
"analytics": values.DictValue(
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
),
"support": values.DictValue(
{}, environ_name="FRONTEND_SUPPORT", environ_prefix=None
),
"silence_livekit_debug_logs": values.BooleanValue(
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
}
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
@@ -256,6 +290,7 @@ class Base(Configuration):
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
AUTH_USER_MODEL = "core.User"
@@ -273,6 +308,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -513,7 +549,11 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
@@ -528,6 +568,14 @@ class Production(Base):
#
# In other cases, you should comment the following line to avoid security issues.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [
"^__lbheartbeat__",
"^__heartbeat__",
]
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
+1 -6
View File
@@ -1,7 +1,6 @@
"""URL configuration for the Meet project"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path
@@ -18,11 +17,7 @@ urlpatterns = [
]
if settings.DEBUG:
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
urlpatterns = urlpatterns + staticfiles_urlpatterns()
if settings.USE_SWAGGER or settings.DEBUG:
+18 -18
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.3"
version = "0.1.7"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,39 +25,38 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.153",
"boto3==1.35.34",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.4.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.0.8",
"redis==5.1.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.7",
"django==5.1.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"freezegun==1.5.1",
"gunicorn==22.0.0",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.6",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.1",
"psycopg[binary]==3.2.3",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"sentry-sdk==2.15.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.7.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.6.0",
"livekit-api==0.7.0",
]
[project.urls]
@@ -70,19 +69,20 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.26.0",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.6",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest==8.3.2",
"pytest-django==4.9.0",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"types-requests==2.32.0.20240712",
"ruff==0.6.5",
"types-requests==2.32.0.20240914",
]
[tool.setuptools]
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
'plugin:jsx-a11y/recommended',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
parser: '@typescript-eslint/parser',
+1 -1
View File
@@ -32,7 +32,7 @@ WORKDIR /home/frontend
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.25 as frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine as frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/play-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meet</title>
<title>Visio</title>
</head>
<body>
<div id="root"></div>
+2632 -2828
View File
File diff suppressed because it is too large Load Diff
+38 -29
View File
@@ -1,51 +1,60 @@
{
"name": "meet",
"private": true,
"version": "0.1.3",
"version": "0.1.7",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json"
"i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src",
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@livekit/components-react": "2.6.5",
"@livekit/components-styles": "1.1.3",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.46.1",
"@react-aria/toast": "3.0.0-beta.16",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"@tanstack/react-query": "5.59.4",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1",
"i18next": "23.12.1",
"i18next": "23.15.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.0",
"i18next-parser": "9.0.2",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.3.1",
"react": "18.2.0",
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
"livekit-client": "2.5.7",
"posthog-js": "1.167.0",
"react": "18.3.1",
"react-aria-components": "1.4.0",
"react-dom": "18.3.1",
"react-i18next": "15.0.2",
"use-sound": "4.0.3",
"valtio": "2.0.0",
"wouter": "3.3.5"
},
"devDependencies": {
"@pandacss/dev": "0.41.0",
"@tanstack/eslint-plugin-query": "5.49.1",
"@tanstack/react-query-devtools": "5.49.2",
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@pandacss/dev": "0.46.1",
"@tanstack/eslint-plugin-query": "5.59.2",
"@tanstack/react-query-devtools": "5.59.4",
"@types/node": "20.16.11",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"@typescript-eslint/eslint-plugin": "8.8.1",
"@typescript-eslint/parser": "8.8.1",
"@vitejs/plugin-react": "4.3.2",
"eslint": "8.57.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.39",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
"eslint-plugin-react-refresh": "0.4.12",
"postcss": "8.4.47",
"prettier": "3.3.3",
"typescript": "5.6.3",
"vite": "5.4.8",
"vite-tsconfig-paths": "5.0.1"
}
}
+29
View File
@@ -58,6 +58,34 @@ const config: Config = {
},
},
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
pulse: {
'0%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0.7)' },
'75%': { boxShadow: '0 0 0 30px rgba(255, 255, 255, 0)' },
'100%': { boxShadow: '0 0 0 0 rgba(255, 255, 255, 0)' },
},
active_speaker: {
'0%': { height: '25%' },
'25%': { height: '45%' },
'50%': { height: '20%' },
'100%': { height: '55%' },
},
active_speaker_small: {
'0%': { height: '20%' },
'25%': { height: '25%' },
'50%': { height: '18%' },
'100%': { height: '25%' },
},
wave_hand: {
'0%': { transform: 'rotate(0deg)' },
'20%': { transform: 'rotate(-20deg)' },
'80%': { transform: 'rotate(20deg)' },
'100%': { transform: 'rotate(0)' },
},
pulse_mic: {
'0%': { color: 'primary', opacity: '1' },
'50%': { color: 'primary', opacity: '0.8' },
'100%': { color: 'primary', opacity: '1' },
},
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
@@ -199,6 +227,7 @@ const config: Config = {
text: { value: '{colors.white}' },
subtle: { value: '{colors.red.100}' },
'subtle-text': { value: '{colors.red.700}' },
...pandaPreset.theme.tokens.colors.red,
},
success: {
DEFAULT: { value: '{colors.green.700}' },
Binary file not shown.
Binary file not shown.
+15 -16
View File
@@ -6,33 +6,32 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Switch, Route } from 'wouter'
import { I18nProvider } from 'react-aria-components'
import { Layout } from './layout/Layout'
import { NotFoundScreen } from './components/NotFoundScreen'
import { routes } from './routes'
import './i18n/init'
import { silenceLiveKitLogs } from "@/utils/livekit.ts";
import { queryClient } from "@/api/queryClient";
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
<Suspense fallback={null}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
<I18nProvider locale={i18n.language}>
<Layout>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</Layout>
<ReactQueryDevtools initialIsOpen={false} />
</I18nProvider>
</Suspense>
</QueryClientProvider>
)
+1 -1
View File
@@ -1,3 +1,3 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
+1
View File
@@ -1,4 +1,5 @@
export const keys = {
user: 'user',
room: 'room',
config: 'config',
}
+26
View File
@@ -0,0 +1,26 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
export interface ApiConfig {
analytics?: {
id: string
host: string
}
support?: {
id: string
}
silence_livekit_debug_logs?: boolean
}
const fetchConfig = (): Promise<ApiConfig> => {
return fetchApi<ApiConfig>(`config/`)
}
export const useConfig = () => {
return useQuery({
queryKey: [keys.config],
queryFn: fetchConfig,
staleTime: Infinity,
})
}
@@ -0,0 +1,20 @@
import { silenceLiveKitLogs } from '@/utils/livekit'
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
export const AppInitialization = () => {
const { data } = useConfig()
const {
analytics = {},
support = {},
silence_livekit_debug_logs = false,
} = data || {}
useAnalytics(analytics)
useSupport(support)
silenceLiveKitLogs(silence_livekit_debug_logs)
return null
}
+53
View File
@@ -0,0 +1,53 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import React from 'react'
const avatar = cva({
base: {
backgroundColor: 'transparent',
color: 'white',
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
userSelect: 'none',
cursor: 'default',
flexGrow: 0,
flexShrink: 0,
},
variants: {
context: {
list: {
width: '32px',
height: '32px',
fontSize: '1.25rem',
},
placeholder: {
width: '100%',
height: '100%',
},
},
},
defaultVariants: {
context: 'list',
},
})
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
name?: string
bgColor?: string
} & RecipeVariantProps<typeof avatar>
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) || ''
return (
<div
style={{
backgroundColor: bgColor,
}}
className={avatar({ context })}
{...props}
>
{initial}
</div>
)
}
+4 -1
View File
@@ -4,7 +4,10 @@ import { useTranslation } from 'react-i18next'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
export const ErrorScreen = ({ title, body }: {
export const ErrorScreen = ({
title,
body,
}: {
title?: string
body?: string
}) => {
+3 -3
View File
@@ -1,12 +1,12 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { LinkButton } from '@/primitives'
export const Feedback = () => {
const { t } = useTranslation()
return (
<Button
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
@@ -20,6 +20,6 @@ export const Feedback = () => {
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</Button>
</LinkButton>
)
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
import { Button } from '@/primitives'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
export const SoundTester = () => {
const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
useEffect(() => {
const updateActiveId = async (deviceId: string) => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
console.error(`Error setting sinkId: ${error}`)
}
}
updateActiveId(activeDeviceId)
}, [activeDeviceId])
// prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {})
return (
<>
<Button
invisible
onPress={() => {
audioRef?.current?.play()
setIsPlaying(true)
}}
size="sm"
isDisabled={isPlaying}
fullWidth
style={{
color: isPlaying ? 'var(--colors-primary)' : undefined,
}}
>
{isPlaying ? t('audio.speakers.ongoingTest') : t('audio.speakers.test')}
</Button>
{/* eslint-disable jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</>
)
}
@@ -0,0 +1,39 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import posthog from 'posthog-js'
import { ApiUser } from '@/features/auth/api/ApiUser'
export const startAnalyticsSession = (data: ApiUser) => {
if (posthog._isIdentified()) return
const { id, email } = data
posthog.identify(id, { email })
}
export const terminateAnalyticsSession = () => {
if (!posthog._isIdentified()) return
posthog.reset()
}
export type useAnalyticsProps = {
id?: string
host?: string
}
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
const [location] = useLocation()
useEffect(() => {
if (!id || !host) return
if (posthog.__loaded) return
posthog.init(id, {
api_host: host,
person_profiles: 'always',
})
}, [id, host])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
posthog.capture('$pageview')
}, [location])
return null
}
@@ -20,7 +20,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) {
attemptSilentLogin(3600)
attemptSilentLogin(300)
} else {
resolve(false)
}
+12 -2
View File
@@ -2,9 +2,12 @@ import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
import { useEffect } from 'react'
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
/**
* returns info about currently logged in user
* returns info about currently logged-in user
*
* `isLoggedIn` is undefined while query is loading and true/false when it's done
*/
@@ -12,9 +15,16 @@ export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
staleTime: 1000 * 60 * 60, // 1 hour
staleTime: Infinity,
})
useEffect(() => {
if (query?.data) {
startAnalyticsSession(query.data)
initializeSupportSession(query.data)
}
}, [query.data])
const isLoggedIn =
query.status === 'success' ? query.data !== false : undefined
const isLoggedOut = isLoggedIn === false
@@ -4,5 +4,7 @@ export const authUrl = ({
silent = false,
returnTo = window.location.href,
} = {}) => {
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
return apiUrl(
`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`
)
}
@@ -1,20 +1,20 @@
import { authUrl } from "@/features/auth";
import { authUrl } from '@/features/auth'
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY)
if (!lastRetryDate) {
return true;
return true
}
const now = new Date();
const now = new Date()
return now.getTime() > Number(lastRetryDate)
}
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date()
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime))
}
const initiateSilentLogin = () => {
@@ -23,7 +23,7 @@ export const JoinMeetingDialog = () => {
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
example: 'https://visio.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const [isHovered, setIsHovered] = useState(false)
return (
<Dialog
isOpen={!!roomId}
{...dialogProps}
title={t('laterMeetingDialog.heading')}
>
<P>{t('laterMeetingDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('laterMeetingDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('laterMeetingDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('laterMeetingDialog.copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
<HStack>
<div
style={{
backgroundColor: '#d9e5ff',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
}}
>
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('laterMeetingDialog.permissions')}
</Text>
</HStack>
</Dialog>
)
}
+66 -27
View File
@@ -1,26 +1,31 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { Button, Text } from '@/primitives'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { Centered } from '@/layout/Centered'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser, UserAware } from '@/features/auth'
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 { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { useState } from 'react'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
}
});
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
return (
<UserAware>
@@ -37,30 +42,64 @@ export const Home = () => {
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter">
<Button
variant="primary"
onPress={
isLoggedIn
? async () => {
const slug = generateRoomId()
await createRoom({slug})
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
>
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
</Button>
<HStack gap="gutter" alignItems="start">
{isLoggedIn ? (
<Menu>
<Button variant="primary" data-attr="create-meeting">
{t('createMeeting')}
</Button>
<RACMenu>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={async () => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
}}
data-attr="create-option-instant"
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<ProConnectButton />
)}
<DialogTrigger>
<Button variant="primary" outline>
<Button
variant="primary"
outline
style={{
height: !isLoggedIn ? '56px' : undefined, // Temporary, Align with ProConnect Button fixed height
}}
>
{t('joinMeeting')}
</Button>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Centered>
<LaterMeetingDialog
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
/>
</Screen>
</UserAware>
)
@@ -0,0 +1,112 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { NotificationType } from './NotificationType'
import { Div } from '@/primitives'
import { isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
useEffect(() => {
const showJoinNotification = (participant: Participant) => {
if (isMobileBrowser()) {
return
}
triggerNotificationSound(NotificationType.Joined)
toastQueue.add(
{
participant,
type: NotificationType.Joined,
},
{
timeout: 5000,
}
)
}
room.on(RoomEvent.ParticipantConnected, showJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
}
}, [room, triggerNotificationSound])
useEffect(() => {
const removeJoinNotification = (participant: Participant) => {
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Joined
)
if (existingToast) {
toastQueue.close(existingToast.key)
}
}
room.on(RoomEvent.ParticipantDisconnected, removeJoinNotification)
return () => {
room.off(RoomEvent.ParticipantConnected, removeJoinNotification)
}
}, [room])
useEffect(() => {
const handleNotificationReceived = (
prevMetadataStr: string | undefined,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
const metadata = JSON.parse(participant.metadata || '{}')
if (prevMetadata.raised == metadata.raised) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Raised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.Raised)
toastQueue.add(
{
participant,
type: NotificationType.Raised,
},
{ timeout: 5000 }
)
}
}
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
}
}, [room, triggerNotificationSound])
useEffect(() => {
const closeAllToasts = () => {
toastQueue.visibleToasts.forEach(({ key }) => toastQueue.close(key))
}
room.on(RoomEvent.Disconnected, closeAllToasts)
return () => {
room.off(RoomEvent.Disconnected, closeAllToasts)
}
}, [room])
return (
<Div position="absolute" bottom={20} right={5} zIndex={1000}>
<ToastProvider />
</Div>
)
}
@@ -0,0 +1,6 @@
export enum NotificationType {
Joined = 'joined',
Default = 'default',
Raised = 'raised',
Lowered = 'lowered',
}
@@ -0,0 +1,58 @@
import { useToast } from '@react-aria/toast'
import { Button } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { ToastState } from '@react-stately/toast'
import { styled } from '@/styled-system/jsx'
import { useRef } from 'react'
import { ToastData } from './ToastProvider'
import type { QueuedToast } from '@react-stately/toast'
export const StyledToastContainer = styled('div', {
base: {
margin: 0.5,
boxShadow:
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
backgroundColor: '#494c4f',
color: 'white',
borderRadius: '8px',
'&[data-entering]': { animation: 'fade 200ms' },
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
width: 'fit-content',
marginLeft: 'auto',
},
})
const StyledToast = styled('div', {
base: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
padding: '10px',
},
})
export interface ToastProps {
key: string
toast: QueuedToast<ToastData>
state: ToastState<ToastData>
}
export function Toast({ state, ...props }: ToastProps) {
const ref = useRef(null)
const { toastProps, contentProps, closeButtonProps } = useToast(
props,
state,
ref
)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<StyledToast>
<div {...contentProps}>{props.toast.content?.message} machine a</div>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine color="white" />
</Button>
</StyledToast>
</StyledToastContainer>
)
}
@@ -0,0 +1,73 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
import Source = Track.Source
import { useMaybeLayoutContext } from '@livekit/components-react'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack, styled } from '@/styled-system/jsx'
import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
const ClickableToast = styled(RACButton, {
base: {
cursor: 'pointer',
display: 'flex',
borderRadius: 'inherit',
},
})
export function ToastJoined({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const pinParticipant = () => {
layoutContext?.pin.dispatch?.({
msg: 'set_pin',
trackReference,
})
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast
ref={ref}
onPress={(e) => {
pinParticipant()
closeButtonProps.onPress?.(e)
}}
>
<HStack justify="center" alignItems="center" {...contentProps}>
<Div display="flex" overflow="hidden" width="128" height="72">
<ParticipantTile
trackRef={trackReference}
disableSpeakingIndicator={true}
disableMetadata={true}
style={{
borderRadius: '7px 0 0 7px',
width: '100%',
}}
/>
</Div>
<Div padding={20} {...titleProps}>
{t('joined.description', {
name: participant.name || t('defaultName'),
})}
</Div>
</HStack>
</ClickableToast>
</StyledToastContainer>
)
}
@@ -0,0 +1,28 @@
/* eslint-disable react-refresh/only-export-components */
import { ToastQueue, useToastQueue } from '@react-stately/toast'
import { ToastRegion } from './ToastRegion'
import { Participant } from 'livekit-client'
import { NotificationType } from '../NotificationType'
export interface ToastData {
participant: Participant
type: NotificationType
message?: string
}
// Using a global queue for toasts allows for centralized management and queuing of notifications
// from anywhere in the app, providing greater flexibility in complex scenarios.
export const toastQueue = new ToastQueue<ToastData>({
maxVisibleToasts: 5,
})
export const ToastProvider = ({ ...props }) => {
const state = useToastQueue<ToastData>(toastQueue)
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
)
}
@@ -0,0 +1,66 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { Button, Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RiCloseLine, RiHand } from '@remixicon/react'
import { useWidgetInteraction } from '@/features/rooms/livekit/hooks/useWidgetInteraction'
export function ToastRaised({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref
)
const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
<RiHand
color="white"
style={{
marginRight: '1rem',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<Div {...titleProps} marginRight={0.5}>
{t('raised.description', {
name: participant.name || t('defaultName'),
})}
</Div>
{!isParticipantsOpen && (
<Button
size="sm"
variant="text"
style={{
color: '#60a5fa',
}}
onPress={(e) => {
toggleParticipants()
closeButtonProps.onPress?.(e)
}}
>
{t('raised.cta')}
</Button>
)}
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine size={18} color="white" />
</Button>
</HStack>
</StyledToastContainer>
)
}
@@ -0,0 +1,30 @@
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import type { ToastState } from '@react-stately/toast'
import { Toast } from './Toast'
import { useRef } from 'react'
import { NotificationType } from '../NotificationType'
import { ToastJoined } from './ToastJoined'
import { ToastData } from './ToastProvider'
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
}
export function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef(null)
const { regionProps } = useToastRegion(props, state, ref)
return (
<div {...regionProps} ref={ref} className="toast-region">
{state.visibleToasts.map((toast) => {
if (toast.content?.type === NotificationType.Joined) {
return <ToastJoined key={toast.key} toast={toast} state={state} />
}
if (toast.content?.type === NotificationType.Raised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
})}
</div>
)
}
@@ -0,0 +1,18 @@
import useSound from 'use-sound'
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const [play] = useSound('./sounds/notifications.mp3', {
sprite: {
joined: [0, 1150],
raised: [1400, 180],
message: [1580, 300],
waiting: [2039, 710],
success: [2740, 1304],
},
})
const triggerNotificationSound = (type: string) => {
play({ id: type })
}
return { triggerNotificationSound }
}
@@ -9,6 +9,6 @@ export type ApiRoom = {
token: string
}
configuration?: {
[key: string]: string | number | boolean;
[key: string]: string | number | boolean
}
}
@@ -1,25 +1,30 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { fetchApi } from '@/api/fetchApi';
import { ApiError } from "@/api/ApiError";
import { ApiRoom } from "./ApiRoom";
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string;
slug: string
username?: string
}
const createRoom = ({slug}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
const createRoom = ({
slug,
username = '',
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
}),
})
};
}
export function useCreateRoom(options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>) {
export function useCreateRoom(
options?: UseMutationOptions<ApiRoom, ApiError, CreateRoomParams>
) {
return useMutation<ApiRoom, ApiError, CreateRoomParams>({
mutationFn: createRoom,
onSuccess: options?.onSuccess,
});
})
}
@@ -0,0 +1,101 @@
import { styled } from '@/styled-system/jsx'
const StyledContainer = styled('div', {
base: {
width: '24px',
height: '24px',
boxSizing: 'border-box',
backgroundColor: 'primary',
borderRadius: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
},
variants: {
pushToTalk: {
true: {
height: '46px',
width: '58px',
borderLeftRadius: 8,
borderRightRadius: 0,
backgroundColor: '#dbeafe',
border: '1px solid #3b82f6',
gap: '3px',
},
},
},
})
const StyledChild = styled('div', {
base: {
backgroundColor: 'white',
width: '4px',
height: '4px',
borderRadius: '4px',
animationDuration: '400ms',
animationName: 'active_speaker',
animationIterationCount: 'infinite',
animationDirection: 'alternate',
},
variants: {
active: {
true: {
animationIterationCount: 'infinite',
},
false: {
animationIterationCount: 0,
},
},
size: {
small: {
animationName: 'active_speaker_small',
},
},
pushToTalk: {
true: {
backgroundColor: 'primary',
width: '6px',
height: '6px',
},
},
},
})
export type ActiveSpeakerProps = {
isSpeaking: boolean
pushToTalk?: boolean
}
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
return (
<StyledContainer pushToTalk={pushToTalk}>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '300ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '500ms',
}}
/>
</StyledContainer>
)
}
@@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen'
@@ -19,6 +17,9 @@ 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'
export const Conference = ({
roomId,
userConfig,
@@ -30,17 +31,28 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId, userConfig.username]
const { mutateAsync: createRoom, status: createStatus, isError: isCreateError} = useCreateRoom({
const {
mutateAsync: createRoom,
status: createStatus,
isError: isCreateError,
} = useCreateRoom({
onSuccess: (data) => {
queryClient.setQueryData(fetchKey, data)
},
});
})
const { status: fetchStatus, isError: isFetchError, data } = useQuery({
const {
status: fetchStatus,
isError: isFetchError,
data,
} = useQuery({
queryKey: fetchKey,
enabled: !initialRoomData,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
@@ -48,7 +60,7 @@ export const Conference = ({
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({slug: roomId})
createRoom({ slug: roomId, username: userConfig.username })
}
}),
retry: false,
@@ -70,34 +82,20 @@ export const Conference = ({
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
/**
* checks for actual click on the leave button instead of
* relying on LiveKitRoom onDisconnected because onDisconnected
* triggers even on page reload, it's not a user "onLeave" event really.
* Here we want to react to the user actually deciding to leave.
*/
useEffect(() => {
const checkOnLeaveClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('lk-disconnect-button')) {
navigateTo('feedback')
}
}
document.body.addEventListener('click', checkOnLeaveClick)
return () => {
document.body.removeEventListener('click', checkOnLeaveClick)
}
}, [])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return <ErrorScreen title={t('error.createRoom.heading')} body={t('error.createRoom.body')} />
return (
<ErrorScreen
title={t('error.createRoom.heading')}
body={t('error.createRoom.body')}
/>
)
}
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<Screen header={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
@@ -106,9 +104,7 @@ export const Conference = ({
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
/>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
@@ -1,8 +1,37 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { Div, Button, type DialogProps, P } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
import {
RiCheckLine,
RiCloseLine,
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
// fixme - extract in a proper primitive this dialog without overlay
const StyledRACDialog = styled(Dialog, {
base: {
position: 'fixed',
left: '0.75rem',
bottom: 80,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
width: '24.5rem',
borderRadius: '8px',
padding: '1.5rem',
boxShadow:
'0 1px 2px 0 rgba(60, 64, 67, .3), 0 2px 6px 2px rgba(60, 64, 67, .15)',
backgroundColor: 'white',
'&[data-entering]': { animation: 'fade 200ms' },
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
},
})
export const InviteDialog = ({
roomId,
@@ -11,48 +40,103 @@ export const InviteDialog = ({
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const copyLabel = t('shareDialog.copy')
const copiedLabel = t('shareDialog.copied')
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (copyLinkLabel == copiedLabel) {
const timeout = setTimeout(() => {
setCopyLinkLabel(copyLabel)
}, 5000)
return () => {
clearTimeout(timeout)
}
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [copyLinkLabel, copyLabel, copiedLabel])
}, [isCopied])
const [isHovered, setIsHovered] = useState(false)
return (
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
<HStack alignItems="stretch" gap="gutter">
<Div flex="1">
<Input
type="text"
aria-label={t('shareDialog.inputLabel')}
value={roomUrl}
readOnly
onClick={(e) => {
e.currentTarget.select()
}}
/>
</Div>
<Div minWidth="8rem">
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VStack
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('shareDialog.heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => {
dialogProps.onClose?.()
close()
}}
aria-label={t('closeDialog')}
>
<RiCloseLine />
</Button>
</Div>
<P>{t('shareDialog.description')}</P>
<Button
variant="primary"
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('shareDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setCopyLinkLabel(copiedLabel)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="share-dialog-copy"
>
{copyLinkLabel}
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('shareDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('shareDialog.copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
</Div>
</HStack>
</Dialog>
<HStack>
<div
style={{
backgroundColor: '#d9e5ff',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
}}
>
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('shareDialog.permissions')}
</Text>
</HStack>
</VStack>
)}
</StyledRACDialog>
)
}
@@ -16,8 +16,8 @@ export const Join = ({
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
micLabel={t('join.audioinput.label')}
camLabel={t('join.videoinput.label')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
@@ -0,0 +1,5 @@
export const buildServerApiUrl = (origin: string, path: string) => {
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/${sanitizedPath}`
}
@@ -0,0 +1,21 @@
import { ApiError } from '@/api/ApiError'
export const fetchServerApi = async <T = Record<string, unknown>>(
url: string,
token: string,
options?: RequestInit
): Promise<T> => {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options?.headers,
},
})
const result = await response.json()
if (!response.ok) {
throw new ApiError(response.status, result)
}
return result
}
@@ -0,0 +1,33 @@
import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
export const useLowerHandParticipant = () => {
const data = useRoomData()
const lowerHandParticipant = (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newMetadata = JSON.parse(participant.metadata || '{}')
newMetadata.raised = !newMetadata.raised
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/UpdateParticipant'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
metadata: JSON.stringify(newMetadata),
permission: participant.permissions,
}),
}
)
}
return { lowerHandParticipant }
}
@@ -0,0 +1,19 @@
import { Participant } from 'livekit-client'
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
const lowerHandParticipants = (participants: Array<Participant>) => {
try {
const promises = participants.map((participant) =>
lowerHandParticipant(participant)
)
return Promise.all(promises)
} catch (error) {
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.')
}
}
return { lowerHandParticipants }
}
@@ -0,0 +1,39 @@
import { Participant, Track } from 'livekit-client'
import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
export const useMuteParticipant = () => {
const data = useRoomData()
const muteParticipant = (participant: Participant) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
if (!trackSid) {
throw new Error('Missing audio track')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'twirp/livekit.RoomService/MutePublishedTrack'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
muted: true,
track_sid: trackSid,
}),
}
)
}
return { muteParticipant }
}
@@ -0,0 +1,191 @@
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">
{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'}
legacyStyle
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'}
legacyStyle
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>
)
}
@@ -0,0 +1,6 @@
import { ParticipantTile } from './ParticipantTile'
import { FocusLayoutProps } from '@livekit/components-react'
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
return <ParticipantTile trackRef={trackRef} {...htmlProps} />
}
@@ -0,0 +1,26 @@
import { useTrackMutedIndicator } from '@livekit/components-react'
import { Participant, Track } from 'livekit-client'
import Source = Track.Source
import { Div } from '@/primitives'
import { RiMicOffFill } from '@remixicon/react'
export const MutedMicIndicator = ({
participant,
}: {
participant: Participant
}) => {
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Source.Microphone,
})
if (!isMuted) {
return null
}
return (
<Div padding={0.25} backgroundColor="red" borderRadius="4px">
<RiMicOffFill size={16} color="white" />
</Div>
)
}
@@ -0,0 +1,63 @@
import { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react'
const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
backgroundColor: '#3d4043', // fixme - copied from gmeet
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
})
type ParticipantPlaceholderProps = {
participant: Participant
}
export const ParticipantPlaceholder = ({
participant,
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl)
const minDimension = Math.min(width, height)
const avatarSize = useMemo(
() => Math.min(Math.round(minDimension * 0.9), 160),
[minDimension]
)
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return (
<StyledParticipantPlaceHolder ref={placeholderEl}>
<div
style={{
borderRadius: '50%',
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
height: 'auto',
aspectRatio: '1/1',
width: '80%',
maxWidth: `${avatarSize}px`,
fontSize: `${initialSize}px`,
}}
>
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
<Avatar
name={participant.name}
bgColor={participantColor}
context="placeholder"
/>
</div>
</StyledParticipantPlaceHolder>
)
}
@@ -0,0 +1,181 @@
import {
AudioTrack,
ConnectionQualityIndicator,
FocusToggle,
LockLockedIcon,
ParticipantName,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
VideoTrack,
TrackRefContext,
ParticipantContextIfNeeded,
} from '@livekit/components-react'
import React from 'react'
import {
isTrackReference,
isTrackReferencePinned,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
trackRef?: TrackReferenceOrPlaceholder
}>
) {
const hasContext = !!useMaybeTrackRefContext()
return props.trackRef && !hasContext ? (
<TrackRefContext.Provider value={props.trackRef}>
{props.children}
</TrackRefContext.Provider>
) : (
<>{props.children}</>
)
}
interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableMetadata?: boolean
}
export const ParticipantTile: (
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
ParticipantTileExtendedProps
>(function ParticipantTile(
{
trackRef,
children,
onParticipantClick,
disableSpeakingIndicator,
disableMetadata,
...htmlProps
}: ParticipantTileExtendedProps,
ref
) {
const trackReference = useEnsureTrackRef(trackRef)
const { elementProps } = useParticipantTile<HTMLDivElement>({
htmlProps,
disableSpeakingIndicator,
onParticipantClick,
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
const handleSubscribe = React.useCallback(
(subscribed: boolean) => {
if (
trackReference.source &&
!subscribed &&
layoutContext &&
layoutContext.pin.dispatch &&
isTrackReferencePinned(trackReference, layoutContext.pin.state)
) {
layoutContext.pin.dispatch({ msg: 'clear_pin' })
}
},
[trackReference, layoutContext]
)
const { isHandRaised } = useRaisedHand({
participant: trackReference.participant,
})
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{children ?? (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
<div className="lk-participant-placeholder">
<ParticipantPlaceholder
participant={trackReference.participant}
/>
</div>
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
<MutedMicIndicator
participant={trackReference.participant}
/>
<div
className="lk-participant-metadata-item"
style={{
minHeight: '24px',
backgroundColor: isHandRaised ? 'white' : undefined,
color: isHandRaised ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{trackReference.source === Track.Source.Camera ? (
<>
{isHandRaised && (
<RiHand
color="black"
size={16}
style={{
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isEncrypted && (
<LockLockedIcon
style={{ marginRight: '0.25rem' }}
/>
)}
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>&apos;s screen</ParticipantName>
</>
)}
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
)}
</>
)}
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
</div>
)
})
@@ -0,0 +1,88 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
import { Box, Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
import { ReactNode } from 'react'
import { Effects } from './Effects'
type StyledSidePanelProps = {
title: string
children: ReactNode
onClose: () => void
closeButtonTooltip: string
}
const StyledSidePanel = ({
title,
children,
onClose,
closeButtonTooltip,
}: StyledSidePanelProps) => (
<Box
size="sm"
minWidth="360px"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0,
gap: 0,
})}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
}}
>
{title}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={onClose}
aria-label={closeButtonTooltip}
tooltip={closeButtonTooltip}
>
<RiCloseLine />
</Button>
</Div>
<Div overflowY="scroll">{children}</Div>
</Box>
)
export const SidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
if (!sidePanel) {
return
}
return (
<StyledSidePanel
title={t(`heading.${sidePanel}`)}
onClose={() => (layoutStore.sidePanel = null)}
closeButtonTooltip={t('closeButton', {
content: t(`content.${sidePanel}`),
})}
>
{isParticipantsOpen && <ParticipantsList />}
{isEffectsOpen && <Effects />}
</StyledSidePanel>
)
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
onPress={() => toggleChat()}
data-attr={`controls-chat-${tooltipLabel}`}
>
<RiChat1Line />
</ToggleButton>
{!!unreadMessages && (
<div
className={css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1rem',
height: '1rem',
backgroundColor: 'red',
borderRadius: '50%',
zIndex: 1,
border: '2px solid #d1d5db',
})}
/>
)}
</div>
)
}
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { RiHand } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const tooltipLabel = isHandRaised ? 'lower' : 'raise'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={() => toggleRaisedHand()}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
</ToggleButton>
</div>
)
}
@@ -0,0 +1,37 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client'
export const LeaveButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const room = useRoomContext()
const connectionState = useConnectionState(room)
return (
<Button
isDisabled={connectionState === ConnectionState.Disconnected}
variant={'danger'}
tooltip={t('leave')}
aria-label={t('leave')}
onPress={() => {
room
.disconnect(true)
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
.finally(() => {
navigateTo('feedback')
})
}}
data-attr="controls-leave"
>
<RiPhoneFill
style={{
transform: 'rotate(135deg)',
}}
/>
</Button>
)
}
@@ -0,0 +1,33 @@
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>
<Button
square
legacyStyle
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
>
<RiMore2Line />
</Button>
<OptionsMenuItems onOpenDialog={setDialogOpen} />
</Menu>
<SettingsDialogExtended
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}

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