Compare commits

...

136 Commits

Author SHA1 Message Date
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
120 changed files with 6831 additions and 3759 deletions
+3 -1
View File
@@ -13,7 +13,9 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": []
"matchPackageNames": [
"eslint"
]
}
]
}
+1 -1
Submodule secrets updated: 9da011f5c2...f5fbc16e6e
+29
View File
@@ -2,6 +2,11 @@
Utils functions used in the core app
"""
# ruff: noqa:S311
import hashlib
import json
import random
from typing import Optional
from uuid import uuid4
@@ -10,6 +15,28 @@ from django.conf import settings
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.
@@ -25,6 +52,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
@@ -49,6 +77,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
.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()
+2
View File
@@ -258,6 +258,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"
@@ -275,6 +276,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
+16 -16
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.4"
version = "0.1.6"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.34.154",
"boto3==1.35.19",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -36,28 +36,28 @@ dependencies = [
"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",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"freezegun==1.5.1",
"gunicorn==22.0.0",
"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.2",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"sentry-sdk==2.14.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]
@@ -71,18 +71,18 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.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 -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>
+2425 -2653
View File
File diff suppressed because it is too large Load Diff
+32 -27
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.4",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,43 +13,48 @@
"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.5.4",
"@livekit/components-styles": "1.1.2",
"@livekit/track-processors": "0.3.2",
"@pandacss/preset-panda": "0.46.0",
"@react-aria/toast": "3.0.0-beta.15",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"@tanstack/react-query": "5.56.2",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.1",
"i18next": "23.12.1",
"i18next": "23.15.1",
"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.1",
"posthog-js": "1.161.5",
"react": "18.3.1",
"react-aria-components": "1.3.3",
"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.0",
"@tanstack/eslint-plugin-query": "5.56.1",
"@tanstack/react-query-devtools": "5.56.2",
"@types/node": "20.16.5",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.13.1",
"@typescript-eslint/parser": "7.13.1",
"@typescript-eslint/eslint-plugin": "8.5.0",
"@typescript-eslint/parser": "8.5.0",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.9.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",
"eslint-plugin-react-refresh": "0.4.12",
"postcss": "8.4.47",
"prettier": "3.3.3",
"typescript": "5.5.2",
"vite": "5.3.1",
"vite-tsconfig-paths": "4.3.2"
"typescript": "5.6.2",
"vite": "5.4.5",
"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: '4px' },
'25%': { height: '8px' },
'50%': { height: '6px' },
'100%': { height: '16px' },
},
active_speake_small: {
'0%': { height: '4px' },
'25%': { height: '6px' },
'50%': { height: '4px' },
'100%': { height: '8px' },
},
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.
+17 -9
View File
@@ -6,12 +6,15 @@ 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 { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
function App() {
const { i18n } = useTranslation()
@@ -20,18 +23,23 @@ function App() {
const isProduction = import.meta.env.PROD
silenceLiveKitLogs(isProduction)
useAnalytics()
useSupport()
return (
<QueryClientProvider client={queryClient}>
<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>
)
+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>
)
}
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,42 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import posthog from 'posthog-js'
import { ApiUser } from '@/features/auth/api/ApiUser'
const ANALYTICS_ID = 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom'
const ANALYTICS_HOST = 'https://eu.i.posthog.com'
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 const useAnalytics = () => {
const [location] = useLocation()
const isProduction = import.meta.env.PROD
useEffect(() => {
// We're on a free tier, so we need to limit the number of events we send to PostHog.
// Be frugal with event tracking, even though we could filter them out later if necessary.
if (!isProduction) return
if (posthog.__loaded) return
posthog.init(ANALYTICS_ID, {
api_host: ANALYTICS_HOST,
person_profiles: 'always',
})
}, [isProduction])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
posthog.capture('$pageview')
}, [location])
return null
}
+11 -1
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
*/
@@ -15,6 +18,13 @@ export const useUser = () => {
staleTime: 1000 * 60 * 60, // 1 hour
})
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
@@ -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>
)
}
+60 -26
View File
@@ -1,15 +1,20 @@
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')
@@ -19,13 +24,8 @@ export const Home = () => {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
},
})
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
return (
<UserAware>
@@ -42,30 +42,64 @@ export const Home = () => {
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter">
<Button
variant="primary"
onPress={
isLoggedIn
? async () => {
<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()
await createRoom({ slug, username })
}
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
>
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
</Button>
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 }
}
@@ -0,0 +1,70 @@
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',
},
})
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_speake_small',
},
},
},
})
export const ActiveSpeaker = ({ isSpeaking }: { isSpeaking: boolean }) => {
return (
<StyledContainer>
<StyledChild
active={isSpeaking}
size="small"
style={{
animationDelay: '300ms',
}}
/>
<StyledChild
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
active={isSpeaking}
size="small"
style={{
animationDelay: '500ms',
}}
/>
</StyledContainer>
)
}
@@ -9,7 +9,6 @@ import {
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 +18,7 @@ import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
export const Conference = ({
roomId,
@@ -31,6 +31,9 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
const fetchKey = [keys.room, roomId, userConfig.username]
const {
@@ -50,7 +53,6 @@ export const Conference = ({
} = useQuery({
queryKey: fetchKey,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
enabled: !initialRoomData,
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
@@ -80,25 +82,6 @@ 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.
@@ -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>
)
}
@@ -1,19 +1,14 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
export const ChatToggle = () => {
const { t } = useTranslation('rooms')
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const { dispatch, state } = useLayoutContext().widget
const tooltipLabel = state?.showChat ? 'open' : 'closed'
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
return (
<div
@@ -22,21 +17,18 @@ export const ChatToggle = () => {
display: 'inline-block',
})}
>
<Button
toggle
<ToggleButton
square
legacyStyle
aria-label={t(`controls.chat.${tooltipLabel}`)}
tooltip={t(`controls.chat.${tooltipLabel}`)}
isSelected={state?.showChat}
onPress={() => {
if (showParticipants) participantsStore.showParticipants = false
if (dispatch) dispatch({ msg: 'toggle_chat' })
}}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
onPress={() => toggleChat()}
data-attr={`controls-chat-${tooltipLabel}`}
>
<RiChat1Line />
</Button>
{!!state?.unreadMessages && (
</ToggleButton>
{!!unreadMessages && (
<div
className={css({
position: 'absolute',
@@ -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>
)
}
@@ -1,22 +1,33 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { OptionsMenu } from '@/features/rooms/livekit/components/controls/Options/OptionsMenu.tsx'
import { MenuTrigger } from 'react-aria-components'
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 (
<MenuTrigger>
<Button
square
legacyStyle
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
>
<RiMore2Line />
</Button>
<OptionsMenu />
</MenuTrigger>
<>
<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)}
/>
</>
)
}
@@ -1,118 +0,0 @@
import { useTranslation } from 'react-i18next'
import {
RiFeedbackLine,
RiQuestionLine,
RiSettings3Line,
RiUser5Line,
} from '@remixicon/react'
import { useState } from 'react'
import { styled } from '@/styled-system/jsx'
import {
Menu as RACMenu,
MenuItem as RACMenuItem,
Popover as RACPopover,
Separator as RACSeparator,
} from 'react-aria-components'
import { SettingsDialog } from '@/features/settings'
import { UsernameDialog } from '../../dialogs/UsernameDialog'
// Styled components to be refactored
const StyledMenu = styled(RACMenu, {
base: {
maxHeight: 'inherit',
boxSizing: 'border-box',
overflow: 'auto',
padding: '2px',
minWidth: '150px',
outline: 'none',
},
})
const StyledMenuItem = styled(RACMenuItem, {
base: {
margin: '2px',
padding: '0.286rem 0.571rem',
borderRadius: '6px',
outline: 'none',
cursor: 'default',
color: 'black',
fontSize: '1.072rem',
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: '20px',
forcedColorAdjust: 'none',
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
const StyledPopover = styled(RACPopover, {
base: {
border: '1px solid #9ca3af',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.1)',
borderRadius: '4px',
background: 'white',
color: 'var(--text-color)',
outline: 'none',
minWidth: '112px',
width: '300px',
},
})
const StyledSeparator = styled(RACSeparator, {
base: {
height: '1px',
background: '#9ca3af',
margin: '2px 4px',
},
})
type DialogState = 'username' | 'settings' | null
export const OptionsMenu = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<StyledPopover>
<StyledMenu>
<StyledMenuItem onAction={() => setDialogOpen('username')}>
<RiUser5Line size={18} />
{t('options.items.username')}
</StyledMenuItem>
<StyledSeparator />
<StyledMenuItem
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
target="_blank"
>
<RiQuestionLine size={18} />
{t('options.items.support')}
</StyledMenuItem>
<StyledMenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
>
<RiFeedbackLine size={18} />
{t('options.items.feedbacks')}
</StyledMenuItem>
<StyledMenuItem onAction={() => setDialogOpen('settings')}>
<RiSettings3Line size={18} />
{t('options.items.settings')}
</StyledMenuItem>
</StyledMenu>
</StyledPopover>
<UsernameDialog
isOpen={dialogOpen === 'username'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
<SettingsDialog
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}
@@ -0,0 +1,67 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import {
RiAccountBoxLine,
RiFeedbackLine,
RiQuestionLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
onOpenDialog,
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useWidgetInteraction()
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<Section>
<MenuItem
onAction={() => toggleEffects()}
className={menuItemRecipe({ icon: true })}
>
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
</Section>
<Separator />
<Section>
<MenuItem
href="https://tchap.gouv.fr/#/room/!aGImQayAgBLjSBycpm:agent.dinum.tchap.gouv.fr?via=agent.dinum.tchap.gouv.fr"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiQuestionLine size={20} />
{t('support')}
</MenuItem>
<MenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiFeedbackLine size={20} />
{t('feedbacks')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={20} />
{t('settings')}
</MenuItem>
</Section>
</RACMenu>
)
}
@@ -0,0 +1,82 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { Participant } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react'
import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant.ts'
import { Button } from '@/primitives'
type HandRaisedListItemProps = {
participant: Participant
}
export const HandRaisedListItem = ({
participant,
}: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const { lowerHandParticipant } = useLowerHandParticipant()
return (
<HStack
role="listitem"
justify="space-between"
key={participant.identity}
id={participant.identity}
className={css({
padding: '0.25rem 0',
width: 'full',
})}
>
<HStack>
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack>
<Button
square
invisible
size="sm"
onPress={() => lowerHandParticipant(participant)}
tooltip={t('participants.lowerParticipantHand', { name })}
data-attr="participants-lower-hand"
>
<RiHand />
</Button>
</HStack>
)
}
@@ -0,0 +1,27 @@
import { Button } from '@/primitives'
import { useLowerHandParticipants } from '@/features/rooms/livekit/api/lowerHandParticipants'
import { useTranslation } from 'react-i18next'
import { Participant } from 'livekit-client'
type LowerAllHandsButtonProps = {
participants: Array<Participant>
}
export const LowerAllHandsButton = ({
participants,
}: LowerAllHandsButtonProps) => {
const { lowerHandParticipants } = useLowerHandParticipants()
const { t } = useTranslation('rooms')
return (
<Button
aria-label={t('participants.lowerParticipantsHand')}
size="sm"
fullWidth
variant="text"
onPress={() => lowerHandParticipants(participants)}
data-attr="participants-lower-hands"
>
{t('participants.lowerParticipantsHand')}
</Button>
)
}
@@ -0,0 +1,163 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
useIsSpeaking,
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button, Dialog, P } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/livekit/api/muteParticipant'
const MuteAlertDialog = ({
isOpen,
onClose,
onSubmit,
name,
}: {
isOpen: boolean
onClose: () => void
onSubmit: () => void
name: string
}) => {
const { t } = useTranslation('rooms')
return (
<Dialog isOpen={isOpen} role="alertdialog">
<P>{t('participants.muteParticipantAlert.description', { name })}</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
{t('participants.muteParticipantAlert.cancel')}
</Button>
<Button variant="text" size="sm" onPress={onSubmit}>
{t('participants.muteParticipantAlert.confirm')}
</Button>
</HStack>
</Dialog>
)
}
type MicIndicatorProps = {
participant: Participant
}
const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({
participant: participant,
source: Source.Microphone,
})
const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
return (
<>
<Button
square
invisible
size="sm"
tooltip={
isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
}
isDisabled={isMuted}
onPress={() =>
!isMuted && isLocal(participant)
? muteParticipant(participant)
: setIsAlertOpen(true)
}
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} />
) : (
<RiMicFill
style={{
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
}}
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
)
}
type ParticipantListItemProps = {
participant: Participant
}
export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
<HStack
role="listitem"
justify="space-between"
key={participant.identity}
id={participant.identity}
className={css({
padding: '0.25rem 0',
width: 'full',
})}
>
<HStack>
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack>
<HStack>
<MicIndicator participant={participant} />
</HStack>
</HStack>
)
}
@@ -0,0 +1,109 @@
import { useState } from 'react'
import { css } from '@/styled-system/css'
import { ToggleButton } from 'react-aria-components'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { RiArrowUpSLine } from '@remixicon/react'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
const ToggleHeader = styled(ToggleButton, {
base: {
minHeight: '40px', //fixme hardcoded value
paddingRight: '.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
transition: 'background 200ms',
borderTopRadius: '7px',
'&[data-hovered]': {
backgroundColor: '#f5f5f5',
},
},
})
const Container = styled('div', {
base: {
border: '1px solid #dadce0',
borderRadius: '8px',
margin: '0 .625rem',
},
})
const ListContainer = styled(VStack, {
base: {
borderTop: '1px solid #dadce0',
alignItems: 'start',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
type ParticipantsCollapsableListProps = {
heading: string
participants: Array<Participant>
renderParticipant: (participant: Participant) => JSX.Element
action?: () => JSX.Element
}
export const ParticipantsCollapsableList = ({
heading,
participants,
renderParticipant,
action,
}: ParticipantsCollapsableListProps) => {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(true)
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
name: heading,
})
return (
<Container>
<ToggleHeader
isSelected={isOpen}
aria-label={label}
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
}}
>
<HStack
justify="space-between"
className={css({
margin: '0 1.25rem',
width: '100%',
})}
>
<div
className={css({
fontSize: '1rem',
})}
>
{heading}
</div>
<div>{participants?.length || 0}</div>
</HStack>
<RiArrowUpSLine
size={32}
style={{
transform: isOpen ? 'rotate(-180deg)' : undefined,
transition: 'transform 200ms',
}}
/>
</ToggleHeader>
{isOpen && (
<ListContainer>
{action && action()}
{participants.map((participant) => renderParticipant(participant))}
</ListContainer>
)}
</Container>
)
}
@@ -1,160 +1,83 @@
import { css } from '@/styled-system/css'
import * as React from 'react'
import { allParticipantRoomEvents } from '@livekit/components-core'
import { useParticipants } from '@livekit/components-react'
import { Heading } from 'react-aria-components'
import { Box, Button, Div } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
import { Text, text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { capitalize } from '@/utils/capitalize'
import { participantsStore } from '@/stores/participants'
import { Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
export type AvatarProps = React.HTMLAttributes<HTMLSpanElement> & {
name: string
size?: number
}
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
export const Avatar = ({ name, size = 32 }: AvatarProps) => (
<div
className={css({
minWidth: `${size}px`,
minHeight: `${size}px`,
backgroundColor: '#3498db',
borderRadius: '50%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '1.25rem',
userSelect: 'none',
cursor: 'default',
color: 'white',
})}
>
{name?.trim()?.charAt(0).toUpperCase()}
</div>
)
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { RoomEvent } from 'livekit-client'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms')
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants({
updateOnlyOn: allParticipantRoomEvents,
updateOnlyOn: [
RoomEvent.ParticipantNameChanged,
...allParticipantRoomEvents,
],
})
const formattedParticipants = participants.map((participant) => ({
name: participant.name || participant.identity,
id: participant.identity,
}))
const sortedRemoteParticipants = formattedParticipants
const sortedRemoteParticipants = participants
.slice(1)
.sort((a, b) => a.name.localeCompare(b.name))
.sort((participantA, participantB) => {
const nameA = participantA.name || participantA.identity
const nameB = participantB.name || participantB.identity
return nameA.localeCompare(nameB)
})
const allParticipants = [
formattedParticipants[0], // first participant returned by the hook, is always the local one
const sortedParticipants = [
participants[0], // first participant returned by the hook, is always the local one
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants.filter((participant) => {
const data = JSON.parse(participant.metadata || '{}')
return data.raised
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Box
size="sm"
minWidth="300px"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
})}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
<span>{t('participants.heading')}</span>{' '}
<span
className={css({
marginLeft: '0.75rem',
fontWeight: 'normal',
fontSize: '1rem',
})}
>
{participants?.length}
</span>
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => (participantsStore.showParticipants = false)}
aria-label={t('participants.closeButton')}
tooltip={t('participants.closeButton')}
>
<RiCloseLine />
</Button>
</Div>
{participants?.length && (
<VStack
role="list"
className={css({
alignItems: 'start',
gap: 'none',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
})}
>
{allParticipants.map((participant, index) => (
<HStack
role="listitem"
key={participant.id}
id={participant.id}
className={css({
padding: '0.25rem 0',
})}
>
<Avatar name={participant.name} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{capitalize(participant.name)}
</span>
{index === 0 && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
</HStack>
))}
</VStack>
<>
<H
lvl={2}
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: '#5f6368',
padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{t('subheading').toUpperCase()}
</H>
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem participant={participant} />
)}
action={() => (
<LowerAllHandsButton participants={raisedHandParticipants} />
)}
/>
</Div>
)}
</Box>
<ParticipantsCollapsableList
heading={t('contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem participant={participant} />
)}
/>
</>
)
}
@@ -1,15 +1,12 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useLayoutContext, useParticipants } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
import { useParticipants } from '@livekit/components-react'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms')
const { dispatch, state } = useLayoutContext().widget
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
/**
* Context could not be used due to inconsistent refresh behavior.
@@ -19,10 +16,9 @@ export const ParticipantsToggle = () => {
const participants = useParticipants()
const numParticipants = participants?.length
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const tooltipLabel = showParticipants ? 'open' : 'closed'
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
return (
<div
@@ -31,20 +27,17 @@ export const ParticipantsToggle = () => {
display: 'inline-block',
})}
>
<Button
toggle
<ToggleButton
square
legacyStyle
aria-label={t(`controls.participants.${tooltipLabel}`)}
tooltip={t(`controls.participants.${tooltipLabel}`)}
isSelected={showParticipants}
onPress={() => {
if (dispatch && state?.showChat) dispatch({ msg: 'toggle_chat' })
participantsStore.showParticipants = !showParticipants
}}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()}
data-attr={`controls-participants-${tooltipLabel}`}
>
<RiGroupLine />
</Button>
</ToggleButton>
<div
className={css({
position: 'absolute',
@@ -0,0 +1,56 @@
import { Div, ToggleButton } from '@/primitives'
import { RiArrowUpLine, RiCloseFill, RiRectangleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Track } from 'livekit-client'
import React from 'react'
export const ScreenShareToggle = (
props: Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
>
) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
const { buttonProps, enabled } = useTrackToggle({
...props,
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const tooltipLabel = enabled ? 'stop' : 'start'
const Icon = enabled ? RiCloseFill : RiArrowUpLine
// fixme - remove ToggleButton custom styles when we design a proper icon
return (
<ToggleButton
isSelected={enabled}
square
legacyStyle
tooltip={t(tooltipLabel)}
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
)
}
style={{
maxWidth: '46px',
maxHeight: '46px',
}}
data-attr={`controls-screenshare-${tooltipLabel}`}
>
<Div position="relative">
<RiRectangleLine size={28} />
<Icon
size={16}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
</Div>
</ToggleButton>
)
}
@@ -0,0 +1,120 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { HStack } from '@/styled-system/jsx'
import { Button, Menu, MenuList, ToggleButton } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Track } from 'livekit-client'
import React from 'react'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
}
export const SelectToggleDevice = <T extends ToggleSource>({
onActiveDeviceChange,
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { buttonProps, enabled } = useTrackToggle(props)
const { kind, iconOn, iconOff } = config
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind })
const toggleLabel = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
const selectLabel = t('choose', { keyPrefix: `join.${kind}` })
const Icon = enabled ? iconOn : iconOff
return (
<HStack gap={0}>
<ToggleButton
isSelected={enabled}
variant={enabled ? undefined : 'danger'}
toggledStyles={false}
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement>
)
}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
>
<Icon />
</ToggleButton>
<Menu>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
/>
</Menu>
</HStack>
)
}
@@ -1,44 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Field, Form, Dialog, DialogProps } from '@/primitives'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
export type UsernameDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
export const UsernameDialog = (props: UsernameDialogProps) => {
const { t } = useTranslation('rooms')
const { saveUsername } = usePersistentUserChoices()
const ctx = useRoomContext()
return (
<Dialog title={t('options.username.heading')} {...props}>
<Form
onSubmit={(data) => {
const { username } = data as { username: string }
ctx.localParticipant.setName(username)
saveUsername(username)
const { onOpenChange } = props
if (onOpenChange) {
onOpenChange(false)
}
}}
submitLabel={t('options.username.submitLabel')}
>
<Field
type="text"
name="username"
label={t('options.username.label')}
description={t('options.username.description')}
defaultValue={ctx.localParticipant.name}
validate={(value) => {
return !value ? (
<p>{t('options.username.validationError')}</p>
) : null
}}
/>
</Form>
</Dialog>
)
}
@@ -1,33 +0,0 @@
import { RoomEvent } from 'livekit-client'
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
// to trigger the real-time update of participants when they change their name.
// This code is duplicated from LiveKit.
export const allRemoteParticipantRoomEvents = [
RoomEvent.ConnectionStateChanged,
RoomEvent.RoomMetadataChanged,
RoomEvent.ActiveSpeakersChanged,
RoomEvent.ConnectionQualityChanged,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.ParticipantPermissionsChanged,
RoomEvent.ParticipantMetadataChanged,
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackStreamStateChanged,
RoomEvent.TrackSubscriptionFailed,
RoomEvent.TrackSubscriptionPermissionChanged,
RoomEvent.TrackSubscriptionStatusChanged,
]
export const allParticipantRoomEvents = [
...allRemoteParticipantRoomEvents,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
]
@@ -0,0 +1,23 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}')
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
}
}
return { isHandRaised: parsedMetadata.raised, toggleRaisedHand }
}
@@ -0,0 +1,127 @@
/* eslint-disable react-hooks/exhaustive-deps */
import * as React from 'react'
const useLatest = <T>(current: T) => {
const storedValue = React.useRef(current)
React.useEffect(() => {
storedValue.current = current
})
return storedValue
}
/**
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
*
* @internal
*/
export function useResizeObserver<T extends HTMLElement>(
target: React.RefObject<T>,
callback: UseResizeObserverCallback
) {
const resizeObserver = getResizeObserver()
const storedCallback = useLatest(callback)
React.useLayoutEffect(() => {
let didUnsubscribe = false
const targetEl = target.current
if (!targetEl) return
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
if (didUnsubscribe) return
storedCallback.current(entry, observer)
}
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
return () => {
didUnsubscribe = true
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
}
}, [target.current, resizeObserver, storedCallback])
return resizeObserver?.observer
}
function createResizeObserver() {
let ticking = false
let allEntries: ResizeObserverEntry[] = []
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
if (typeof window === 'undefined') {
return
}
const observer = new ResizeObserver(
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
allEntries = allEntries.concat(entries)
if (!ticking) {
window.requestAnimationFrame(() => {
const triggered = new Set<Element>()
for (let i = 0; i < allEntries.length; i++) {
if (triggered.has(allEntries[i].target)) continue
triggered.add(allEntries[i].target)
const cbs = callbacks.get(allEntries[i].target)
cbs?.forEach((cb) => cb(allEntries[i], obs))
}
allEntries = []
ticking = false
})
}
ticking = true
}
)
return {
observer,
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
observer.observe(target)
const cbs = callbacks.get(target) ?? []
cbs.push(callback)
callbacks.set(target, cbs)
},
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
const cbs = callbacks.get(target) ?? []
if (cbs.length === 1) {
observer.unobserve(target)
callbacks.delete(target)
return
}
const cbIndex = cbs.indexOf(callback)
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
callbacks.set(target, cbs)
},
}
}
let _resizeObserver: ReturnType<typeof createResizeObserver>
const getResizeObserver = () =>
!_resizeObserver
? (_resizeObserver = createResizeObserver())
: _resizeObserver
export type UseResizeObserverCallback = (
entry: ResizeObserverEntry,
observer: ResizeObserver
) => unknown
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
const [size, setSize] = React.useState({ width: 0, height: 0 })
React.useLayoutEffect(() => {
if (target.current) {
const { width, height } = target.current.getBoundingClientRect()
setSize({ width, height })
}
}, [target.current])
const resizeCallback = React.useCallback(
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
[]
)
// Where the magic happens
useResizeObserver(target, resizeCallback)
return size
}
@@ -0,0 +1,12 @@
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { useRoomContext } from '@livekit/components-react'
import { useParams } from 'wouter'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
export const useRoomData = (): ApiRoom | undefined => {
const room = useRoomContext()
const { roomId } = useParams()
const queryKey = [keys.room, roomId, room.localParticipant.name]
return queryClient.getQueryData<ApiRoom>(queryKey)
}
@@ -0,0 +1,46 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
const isParticipantsOpen = sidePanel == 'participants'
const isEffectsOpen = sidePanel == 'effects'
const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isParticipantsOpen ? null : 'participants'
}
const toggleChat = () => {
if (isParticipantsOpen) {
layoutStore.sidePanel = null
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
}
const toggleEffects = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
layoutStore.sidePanel = isEffectsOpen ? null : 'effects'
}
return {
toggleParticipants,
toggleChat,
toggleEffects,
isChatOpen: state?.showChat,
unreadMessages: state?.unreadMessages,
isParticipantsOpen,
isEffectsOpen,
}
}
@@ -4,10 +4,6 @@ import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import {
DisconnectButton,
LeaveIcon,
MediaDeviceMenu,
TrackToggle,
useMaybeLayoutContext,
usePersistentUserChoices,
} from '@livekit/components-react'
@@ -15,10 +11,13 @@ import {
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useTranslation } from 'react-i18next'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '../components/controls/ChatToggle'
import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
/** @public */
export type ControlBarControls = {
@@ -37,7 +36,7 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enables the user to have the same device choices when they rejoin the room.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
@@ -66,7 +65,6 @@ export function ControlBar({
onDeviceError,
...props
}: ControlBarProps) {
const { t } = useTranslation('rooms')
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
React.useEffect(() => {
@@ -82,26 +80,8 @@ export function ControlBar({
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
variation ??= defaultVariation
const showIcon = React.useMemo(
() => variation === 'minimal' || variation === 'verbose',
[variation]
)
const showText = React.useMemo(
() => variation === 'textOnly' || variation === 'verbose',
[variation]
)
const browserSupportsScreenSharing = supportsScreenSharing()
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false)
const onScreenShareChange = React.useCallback(
(enabled: boolean) => {
setIsScreenShareEnabled(enabled)
},
[setIsScreenShareEnabled]
)
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
@@ -125,71 +105,38 @@ export function ControlBar({
return (
<div {...htmlProps}>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Microphone}
showIcon={showIcon}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
>
{showText && t('controls.microphone')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="audioinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Camera}
showIcon={showIcon}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
>
{showText && t('controls.camera')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="videoinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
>
{showText &&
t(
isScreenShareEnabled
? 'controls.stopScreenShare'
: 'controls.shareScreen'
)}
</TrackToggle>
/>
)}
<HandToggle />
<ChatToggle />
<ParticipantsToggle />
<OptionsButton />
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && t('controls.leave')}
</DisconnectButton>
<LeaveButton />
<StartMediaButton />
</div>
)
@@ -16,11 +16,9 @@ import * as React from 'react'
import {
CarouselLayout,
ConnectionStateToast,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
@@ -32,9 +30,12 @@ import {
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
import { layoutStore } from '@/stores/layout'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
const LayoutWrapper = styled(
'div',
@@ -171,8 +172,8 @@ export function VideoConference({
])
/* eslint-enable react-hooks/exhaustive-deps */
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
const layoutSnap = useSnapshot(layoutStore)
const sidePanel = layoutSnap.sidePanel
return (
<div className="lk-video-conference" {...props}>
@@ -184,32 +185,45 @@ export function VideoConference({
>
<div className="lk-video-conference-inner">
<LayoutWrapper>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<MainNotificationToast />
</div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{showParticipants && <ParticipantsList />}
{sidePanel && <SidePanel />}
</LayoutWrapper>
<ControlBar />
</div>
@@ -0,0 +1,11 @@
import { Participant } from 'livekit-client'
export const getParticipantColor = (
participant: Participant
): undefined | string => {
const { metadata } = participant
if (!metadata) {
return
}
return JSON.parse(metadata)['color']
}
@@ -1,7 +1,8 @@
import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { logoutUrl, useUser } from '@/features/auth'
import { ProConnectButton } from '@/components/ProConnectButton'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -28,9 +29,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
) : (
<>
<P>{t('account.youAreNotLoggedIn')}</P>
<P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
<ProConnectButton />
</>
)}
<H lvl={2}>{t('language.heading')}</H>
@@ -0,0 +1,94 @@
import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
RiAccountCircleLine,
RiSettings3Line,
RiSpeakerLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
width: '50rem', // fixme size copied from meet settings modal
marginY: '-1rem', // fixme hacky solution to cancel modal padding
maxWidth: '100%',
overflow: 'hidden',
height: 'calc(100vh - 2rem)',
})
const tabListContainerStyle = css({
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid lightGray', // fixme poor color management
paddingY: '1rem',
paddingRight: '1.5rem',
})
const tabPanelContainerStyle = css({
display: 'flex',
flexGrow: '1',
marginTop: '3.5rem',
minWidth: 0,
})
export type SettingsDialogExtended = Pick<
DialogProps,
'isOpen' | 'onOpenChange'
>
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
// display only icon on small screen
const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null)
const { width } = useSize(dialogEl)
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs orientation="vertical" className={tabsStyle}>
<div
className={tabListContainerStyle}
style={{
flex: isWideScreen ? '0 0 16rem' : undefined,
paddingTop: !isWideScreen ? '64px' : undefined,
paddingRight: !isWideScreen ? '1rem' : undefined,
}}
>
{isWideScreen && (
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
{t('dialog.heading')}
</Heading>
)}
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t('tabs.account')}
</Tab>
<Tab icon highlight id="2">
<RiSpeakerLine />
{isWideScreen && t('tabs.audio')}
</Tab>
<Tab icon highlight id="3">
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
</div>
</Tabs>
</Dialog>
)
}
@@ -0,0 +1,80 @@
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
import { Trans, useTranslation } from 'react-i18next'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
import { logoutUrl, useUser } from '@/features/auth'
import { css } from '@/styled-system/css'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { HStack } from '@/styled-system/jsx'
import { useState } from 'react'
import { ProConnectButton } from '@/components/ProConnectButton'
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const { t } = useTranslation('settings')
const { saveUsername } = usePersistentUserChoices()
const room = useRoomContext()
const { user, isLoggedIn } = useUser()
const [name, setName] = useState(room?.localParticipant.name || '')
const handleOnSubmit = () => {
if (room) room.localParticipant.setName(name)
saveUsername(name)
if (onOpenChange) onOpenChange(false)
}
const handleOnCancel = () => {
if (onOpenChange) onOpenChange(false)
}
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('account.heading')}</H>
<Field
type="text"
label={t('account.nameLabel')}
value={name}
onChange={setName}
validate={(value) => {
return !value ? <p>{'Votre Nom ne peut pas être vide'}</p> : null
}}
/>
<H lvl={2}>{t('account.authentication')}</H>
{isLoggedIn ? (
<>
<P>
<Trans
i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.email }}
components={[<Badge />]}
/>
</P>
<P>
<A href={logoutUrl()}>{t('logout', { ns: 'global' })}</A>
</P>
</>
) : (
<>
<P>{t('account.youAreNotLoggedIn')}</P>
<ProConnectButton />
</>
)}
<HStack
className={css({
marginTop: 'auto',
marginLeft: 'auto',
})}
>
<Button onPress={handleOnCancel}>
{t('cancel', { ns: 'global' })}
</Button>
<Button variant={'primary'} onPress={handleOnSubmit}>
{t('submit', { ns: 'global' })}
</Button>
</HStack>
</TabPanel>
)
}
@@ -0,0 +1,157 @@
import { DialogProps, Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
useIsSpeaking,
useMediaDeviceSelect,
useRoomContext,
} from '@livekit/components-react'
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { ReactNode } from 'react'
type RowWrapperProps = {
heading: string
children: ReactNode[]
}
const RowWrapper = ({ heading, children }: RowWrapperProps) => {
return (
<>
<H lvl={2}>{heading}</H>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const AudioTab = ({ id }: AudioTabProps) => {
const { t } = useTranslation('settings')
const { localParticipant } = useRoomContext()
const isSpeaking = useIsSpeaking(localParticipant)
const {
devices: devicesOut,
activeDeviceId: activeDeviceIdOut,
setActiveMediaDevice: setActiveMediaDeviceOut,
} = useMediaDeviceSelect({ kind: 'audiooutput' })
const {
devices: devicesIn,
activeDeviceId: activeDeviceIdIn,
setActiveMediaDevice: setActiveMediaDeviceIn,
} = useMediaDeviceSelect({ kind: 'audioinput' })
const itemsOut: DeviceItems = devicesOut.map((d) => ({
value: d.deviceId,
label: d.label,
}))
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for microphone permissions
// may raise an error. As a workaround, we infer microphone permission status by checking if the list of audio input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted microphone access.
const isMicEnabled = devicesIn?.length > 0
const disabledProps = isMicEnabled
? {}
: {
placeholder: t('audio.permissionsRequired'),
isDisabled: true,
defaultSelectedKey: undefined,
}
// No API to directly query the default audio device; this function heuristically finds it.
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('audio.microphone.heading')}>
<Field
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
defaultSelectedKey={
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
}
onSelectionChange={(key) => setActiveMediaDeviceIn(key as string)}
{...disabledProps}
style={{
width: '100%',
}}
/>
<>
{localParticipant.isMicrophoneEnabled ? (
<ActiveSpeaker isSpeaking={isSpeaking} />
) : (
<span>Micro désactivé</span>
)}
</>
</RowWrapper>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() && (
<RowWrapper heading={t('audio.speakers.heading')}>
<Field
type="select"
label={t('audio.speakers.label')}
items={itemsOut}
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
setActiveMediaDeviceOut(key as string)
}
{...disabledProps}
style={{
minWidth: 0,
}}
/>
<SoundTester />
</RowWrapper>
)}
</TabPanel>
)
}
@@ -0,0 +1,26 @@
import { Field, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
export type GeneralTabProps = Pick<TabPanelProps, 'id'>
export const GeneralTab = ({ id }: GeneralTabProps) => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('language.heading')}</H>
<Field
type="select"
label={t('language.label')}
items={languagesList}
defaultSelectedKey={currentLanguage.key}
onSelectionChange={(lang) => {
i18n.changeLanguage(lang as string)
}}
/>
</TabPanel>
)
}
@@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { ApiUser } from '@/features/auth/api/ApiUser'
const SUPPORT_ID = '58ea6697-8eba-4492-bc59-ad6562585041'
export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return
const { id, email } = user
Crisp.setTokenId(`meet-${id}`)
Crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!Crisp.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset()
}
// Configure Crisp chat for real-time support across all pages.
export const useSupport = () => {
useEffect(() => {
if (!SUPPORT_ID) {
console.warn('Crisp Website ID is not set')
return
}
if (Crisp.isCrispInjected()) return
Crisp.configure(SUPPORT_ID)
Crisp.setHideOnMobile(true)
}, [])
return null
}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button, Popover, PopoverList } from '@/primitives'
import { useLanguageLabels } from './useLanguageLabels'
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
outline
>
{i18n.language}
</Button>
<PopoverList
items={languagesList}
onAction={(lang) => {
i18n.changeLanguage(lang)
}}
/>
</Popover>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Div, VerticallyOffCenter } from '@/primitives'
import type { SystemStyleObject } from '../styled-system/types'
import type { SystemStyleObject } from '@/styled-system/types'
export const Centered = ({
width = '38rem',
+14 -9
View File
@@ -2,11 +2,16 @@ import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
import { Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
import { ProConnectButton } from '@/components/ProConnectButton'
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
export const Header = () => {
const { t } = useTranslation()
@@ -61,10 +66,10 @@ export const Header = () => {
<nav>
<Stack gap={1} direction="row" align="center">
{isLoggedIn === false && !isHome && (
<A href={authUrl()}>{t('login')}</A>
<ProConnectButton hint={false} />
)}
{!!user && (
<Popover aria-label={t('logout')}>
<Menu>
<Button
size="sm"
invisible
@@ -73,17 +78,17 @@ export const Header = () => {
>
{user.email}
</Button>
<PopoverList
items={[
{ key: 'logout', value: 'logout', label: t('logout') },
]}
<MenuList
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
terminateAnalyticsSession()
terminateSupportSession()
window.location.href = logoutUrl()
}
}}
/>
</Popover>
</Menu>
)}
<SettingsButton />
</Stack>
+8 -7
View File
@@ -1,5 +1,5 @@
{
"app": "Meet",
"app": "Visio",
"backToHome": "",
"cancel": "",
"closeDialog": "",
@@ -10,15 +10,16 @@
"forbidden": {
"heading": ""
},
"languageSelector": {
"buttonLabel": "",
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
"login": {
"buttonLabel": "",
"linkLabel": "",
"link": ""
},
"logout": "",
"notFound": {
"heading": ""
}
},
"submit": "OK"
}
+12 -1
View File
@@ -9,5 +9,16 @@
"joinMeeting": "",
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": ""
"loginToCreateMeeting": "",
"createMenu": {
"laterOption": "",
"instantOption": ""
},
"laterMeetingDialog": {
"heading": "",
"description": "",
"copy": "",
"copied": "",
"permissions": ""
}
}
@@ -0,0 +1,10 @@
{
"defaultName": "",
"joined": {
"description": ""
},
"raised": {
"description": "",
"cta": ""
}
}
+72 -16
View File
@@ -4,18 +4,35 @@
"heading": ""
},
"join": {
"camlabel": "",
"videoinput": {
"choose": "",
"disable": "",
"enable": "",
"label": "",
"placeholder": ""
},
"audioinput": {
"choose": "",
"disable": "",
"enable": "",
"label": ""
},
"heading": "",
"joinLabel": "",
"micLabel": "",
"userLabel": ""
"joinMeeting": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
"copied": "",
"copy": "",
"copied": "",
"heading": "",
"inputLabel": ""
"description": "",
"permissions": ""
},
"error": {
"createRoom": {
@@ -32,6 +49,14 @@
"open": "",
"closed": ""
},
"hand": {
"raise": "",
"lower": ""
},
"screenShare": {
"start": "",
"stop": ""
},
"leave": "",
"participants": {
"open": "",
@@ -44,19 +69,50 @@
"feedbacks": "",
"support": "",
"settings": "",
"username": ""
},
"username": {
"heading": "",
"label": "",
"description": "",
"validationError": "",
"submitLabel": ""
"username": "",
"effects": ""
}
},
"participants": {
"effects": {
"activateCamera": "",
"notAvailable": "",
"heading": "",
"closeButton": "",
"you": ""
"blur": {
"light": "",
"normal": "",
"apply": "",
"clear": ""
},
"experimental": ""
},
"sidePanel": {
"heading": {
"participants": "",
"effects": ""
},
"content": {
"participants": "",
"effects": ""
},
"closeButton": ""
},
"participants": {
"subheading": "",
"contributors": "",
"collapsable": {
"open": "",
"close": ""
},
"you": "",
"muteYourself": "",
"muteParticipant": "",
"muteParticipantAlert": {
"description": "",
"confirm": "",
"cancel": ""
},
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
}
}
+22 -2
View File
@@ -2,7 +2,22 @@
"account": {
"currentlyLoggedAs": "",
"heading": "",
"youAreNotLoggedIn": ""
"youAreNotLoggedIn": "",
"nameLabel": "",
"authentication": ""
},
"audio": {
"microphone": {
"heading": "",
"label": ""
},
"speakers": {
"heading": "",
"label": "",
"test": "",
"ongoingTest": ""
},
"permissionsRequired": ""
},
"dialog": {
"heading": ""
@@ -11,5 +26,10 @@
"heading": "",
"label": ""
},
"settingsButtonLabel": ""
"settingsButtonLabel": "",
"tabs": {
"account": "",
"audio": "",
"general": ""
}
}
+9 -8
View File
@@ -1,24 +1,25 @@
{
"app": "Meet",
"app": "Visio",
"backToHome": "Back to homescreen",
"cancel": "Cancel",
"closeDialog": "Close dialog",
"error": {
"heading": "An error occured while loading the page"
"heading": "An error occurred while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
"heading": "You don't have the permission to view this page"
},
"languageSelector": {
"buttonLabel": "Change language (currently {{currentLanguage}})",
"popoverLabel": "Choose language"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": "Login",
"login": {
"buttonLabel": "Login with ProConnect",
"linkLabel": "What is ProConnect? - new tab",
"link": "What is ProConnect?"
},
"logout": "Logout",
"notFound": {
"heading": "Page not found"
}
},
"submit": "OK"
}
+13 -2
View File
@@ -1,6 +1,6 @@
{
"createMeeting": "Create a meeting",
"heading": "Welcome in Meet",
"heading": "Welcome in Visio",
"intro": "Work easily, from anywhere.",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
@@ -9,5 +9,16 @@
"joinMeeting": "Join a meeting",
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting"
"loginToCreateMeeting": "Login to create a meeting",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
},
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without Agent Connect.",
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
}
}
@@ -0,0 +1,10 @@
{
"defaultName": "A contributor",
"joined": {
"description": "{{name}} has joined the room"
},
"raised": {
"description": "{{name}} has raised their hand.",
"cta": "Open waiting list"
}
}
+76 -22
View File
@@ -1,21 +1,38 @@
{
"feedback": {
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
"heading": "Help us improve Meet"
"heading": "Help us improve Visio"
},
"join": {
"camlabel": "Camera",
"heading": "Join the meeting",
"videoinput": {
"choose": "Select camera",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
"placeholder": "Enable camera to see the preview"
},
"audioinput": {
"choose": "Select microphone",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"heading": "Verify your settings",
"joinLabel": "Join",
"micLabel": "Microphone",
"userLabel": "Your name"
"joinMeeting": "Join meeting",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copied": "Copied",
"copy": "Copy",
"heading": "Share the meeting link",
"inputLabel": "Meeting link"
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting."
},
"error": {
"createRoom": {
@@ -26,12 +43,18 @@
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Share screen",
"stopScreenShare": "Stop screen share",
"chat": {
"open": "Close the chat",
"closed": "Open the chat"
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
},
"screenShare": {
"start": "Share screen",
"stop": "Stop screen share"
},
"leave": "Leave",
"participants": {
"open": "Hide everyone",
@@ -44,19 +67,50 @@
"feedbacks": "Give us feedbacks",
"support": "Get Help on Tchap",
"settings": "Settings",
"username": "Update Your Name"
},
"username": {
"heading": "Update Your Name",
"label": "Your Name",
"description": "All other participants will see this name.",
"validationError": "Name cannot be empty.",
"submitLabel": "Save"
"username": "Update Your Name",
"effects": "Apply effects"
}
},
"effects": {
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome :(",
"heading": "Blur",
"blur": {
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
},
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effects"
},
"content": {
"participants": "participants",
"effects": "effects"
},
"closeButton": "Hide {{content}}"
},
"participants": {
"heading": "Participants",
"closeButton": "Hide participants",
"you": "You"
"subheading": "In room",
"you": "You",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
"close": "Close {{name}} list"
},
"muteYourself": "Close your mic",
"muteParticipant": "Close the mic of {{name}}",
"muteParticipantAlert": {
"description": "Mute {{name}} for all participants? {{name}} will be the only one who can unmute themselves.",
"confirm": "Mute",
"cancel": "Cancel"
},
"raisedHands": "Raised hands",
"lowerParticipantHand": "Lower {{name}}'s hand",
"lowerParticipantsHand": "Lower all hands"
}
}
+22 -2
View File
@@ -2,7 +2,22 @@
"account": {
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
"heading": "Account",
"youAreNotLoggedIn": "You are not logged in."
"youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name",
"authentication": "Authentication"
},
"audio": {
"microphone": {
"heading": "Microphone",
"label": "Select your audio input"
},
"speakers": {
"heading": "Speakers",
"label": "Select your audio output",
"test": "Test",
"ongoingTest": "Testing sound…"
},
"permissionsRequired": "Permissions required"
},
"dialog": {
"heading": "Settings"
@@ -11,5 +26,10 @@
"heading": "Language",
"label": "Language"
},
"settingsButtonLabel": "Settings"
"settingsButtonLabel": "Settings",
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "General"
}
}
+8 -7
View File
@@ -1,5 +1,5 @@
{
"app": "Meet",
"app": "Visio",
"backToHome": "Retour à l'accueil",
"cancel": "Annuler",
"closeDialog": "Fermer la fenêtre modale",
@@ -10,15 +10,16 @@
"forbidden": {
"heading": "Accès interdit"
},
"languageSelector": {
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
"popoverLabel": "Choix de la langue"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": "Se connecter",
"login": {
"buttonLabel": "S'identifier avec ProConnect",
"linkLabel": "Qu'est-ce que ProConnect ? - nouvelle fenêtre",
"link": "Qu'est-ce que ProConnect ?"
},
"logout": "Se déconnecter",
"notFound": {
"heading": "Page introuvable"
}
},
"submit": "OK"
}
+13 -2
View File
@@ -1,6 +1,6 @@
{
"createMeeting": "Créer une réunion",
"heading": "Meet",
"heading": "Visio",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
@@ -9,5 +9,16 @@
"joinMeeting": "Rejoindre une réunion",
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion"
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans Agent Connect.",
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
}
}
@@ -0,0 +1,10 @@
{
"defaultName": "Un contributeur",
"joined": {
"description": "{{name}} participe à l'appel"
},
"raised": {
"description": "{{name}} a levé la main.",
"cta": "Ouvrir la file d'attente"
}
}
+76 -22
View File
@@ -1,21 +1,38 @@
{
"feedback": {
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
"heading": "Aidez-nous à améliorer Meet"
"heading": "Aidez-nous à améliorer Visio"
},
"join": {
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"videoinput": {
"choose": "Choisir la webcam",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
"placeholder": "Activez la webcam pour prévisualiser l'affichage"
},
"audioinput": {
"choose": "Choisir le micro",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"heading": "Vérifiez vos paramètres",
"joinLabel": "Rejoindre",
"micLabel": "Micro",
"userLabel": "Votre nom"
"joinMeeting": "Rejoindre la réjoindre",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copied": "Lien copié",
"copy": "Copier le lien",
"heading": "Partager le lien vers la réunion",
"inputLabel": "Lien vers la réunion"
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"heading": "Votre réunion est prête",
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"error": {
"createRoom": {
@@ -26,12 +43,18 @@
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Partager l'écran",
"stopScreenShare": "Arrêter le partage",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat"
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
},
"screenShare": {
"start": "Partager l'écran",
"stop": "Arrêter le partage"
},
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
@@ -44,19 +67,50 @@
"feedbacks": "Partager votre avis",
"support": "Obtenir de l'aide sur Tchap",
"settings": "Paramètres",
"username": "Choisir votre nom"
},
"username": {
"heading": "Choisir votre nom",
"label": "Votre Nom",
"description": "Tous les autres participants verront ce nom.",
"validationError": "Le nom ne peut pas être vide.",
"submitLabel": "Enregistrer"
"username": "Choisir votre nom",
"effects": "Appliquer des effets"
}
},
"effects": {
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome :(",
"heading": "Flou",
"blur": {
"light": "Léger flou",
"normal": "Flou",
"apply": "Appliquer le flou",
"clear": "Désactiver le flou"
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet sur tous les navigateurs et une meilleur qualité."
},
"sidePanel": {
"heading": {
"participants": "Participants",
"effects": "Effets"
},
"content": {
"participants": "les participants",
"effects": "les effets"
},
"closeButton": "Masquer {{content}}"
},
"participants": {
"heading": "Participants",
"closeButton": "Masquer les participants",
"you": "Vous"
"subheading": "Dans la réunion",
"you": "Vous",
"contributors": "Contributeurs",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
},
"muteYourself": "Couper votre micro",
"muteParticipant": "Couper le micro de {{name}}",
"muteParticipantAlert": {
"description": "Couper le micro de {{name}} pour tous les participants ? {{name}} est la seule personne habilitée à réactiver son micro",
"confirm": "Couper le micro",
"cancel": "Annuler"
},
"raisedHands": "Mains levées",
"lowerParticipantHand": "Baisser la main de {{name}}",
"lowerParticipantsHand": "Baisser la main de tous les participants"
}
}
+22 -2
View File
@@ -2,7 +2,22 @@
"account": {
"currentlyLoggedAs": "Vous êtes actuellement connecté en tant que <0>{{user}}</0>",
"heading": "Compte",
"youAreNotLoggedIn": "Vous n'êtes pas connecté."
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
"nameLabel": "Votre Nom",
"authentication": "Authentification"
},
"audio": {
"microphone": {
"heading": "Micro",
"label": "Sélectionner votre entrée audio"
},
"speakers": {
"heading": "Haut-parleurs",
"label": "Sélectionner votre sortie audio",
"test": "Tester",
"ongoingTest": "Test du son…"
},
"permissionsRequired": "Autorisations nécessaires"
},
"dialog": {
"heading": "Paramètres"
@@ -11,5 +26,10 @@
"heading": "Langue",
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres"
"settingsButtonLabel": "Paramètres",
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "Général"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { cva } from '@/styled-system/css'
import { styled } from '../styled-system/jsx'
import { styled } from '@/styled-system/jsx'
const box = cva({
base: {
+11 -170
View File
@@ -1,151 +1,20 @@
import { type ReactNode } from 'react'
import {
Button as RACButton,
ToggleButton as RACToggleButton,
type ButtonProps as RACButtonsProps,
TooltipTrigger,
Link,
LinkProps,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
import { Tooltip } from './Tooltip'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
const button = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-selected]': {
background: 'colorPalette.active',
},
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
default: {
colorPalette: 'control',
},
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
width: 'full',
},
},
legacyStyle: {
true: {
borderColor: 'gray.400',
'&[data-hovered]': {
borderColor: 'gray.500',
},
'&[data-pressed]': {
borderColor: 'gray.500',
},
'&[data-selected]': {
background: '#e5e7eb',
borderColor: 'gray.400',
'&[data-hovered]': {
backgroundColor: 'gray.300',
},
},
},
},
},
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
},
})
type Tooltip = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
export type ButtonProps = RecipeVariantProps<typeof button> &
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
Tooltip & {
toggle?: boolean
isSelected?: boolean
}
TooltipWrapperProps
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
@@ -154,22 +23,11 @@ export const Button = ({
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
const [variantProps, componentProps] = button.splitVariantProps(props)
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={button(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
if ((props as ButtonProps).toggle) {
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
className={button(variantProps)}
{...(componentProps as RACToggleButtonProps)}
/>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
@@ -177,26 +35,9 @@ export const Button = ({
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={button(variantProps)}
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
</TooltipWrapper>
)
}
const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & Tooltip) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
+5 -4
View File
@@ -134,10 +134,11 @@ export const Checkbox = ({
>
<StyledCheckbox {...props}>
{(renderProps) => {
renderProps.isInvalid && !!props.validate
? setError(props.validate(renderProps.isSelected))
: setError(null)
if (renderProps.isInvalid && !!props.validate) {
setError(props.validate(renderProps.isSelected))
} else {
setError(null)
}
return (
<>
<div className="mt-Checkbox-checkbox">
+35 -31
View File
@@ -10,6 +10,7 @@ import {
} from 'react-aria-components'
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
import { text } from './Text'
import { MutableRefObject } from 'react'
const StyledModalOverlay = styled(ModalOverlay, {
base: {
@@ -28,7 +29,7 @@ const StyledModalOverlay = styled(ModalOverlay, {
},
})
// disabled pointerEvents on the stuff surrouding the overlay is there so that clicking on the overlay to close the modal still works
// disabled pointerEvents on the stuff surrounding the overlay is there so that clicking on the overlay to close the modal still works
const StyledModal = styled(Modal, {
base: {
width: 'full',
@@ -48,7 +49,7 @@ const StyledRACDialog = styled(RACDialog, {
})
export type DialogProps = RACDialogProps & {
title: string
title?: string
onClose?: () => void
/**
* use the Dialog as a controlled component
@@ -60,6 +61,8 @@ export type DialogProps = RACDialogProps & {
* after user interaction
*/
onOpenChange?: (isOpen: boolean) => void
type?: 'flex'
innerRef?: MutableRefObject<HTMLDivElement | null>
}
export const Dialog = ({
@@ -68,9 +71,11 @@ export const Dialog = ({
onClose,
isOpen,
onOpenChange,
innerRef,
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
const boxType = dialogProps['type'] !== 'flex' ? 'dialog' : undefined
return (
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
@@ -89,36 +94,35 @@ export const Dialog = ({
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VerticallyOffCenter>
<Div
width="fit-content"
maxWidth="full"
margin="auto"
pointerEvents="auto"
>
<Box size="sm" type="dialog">
<Heading
slot="title"
level={1}
className={text({ variant: 'h1' })}
>
{title}
</Heading>
{typeof children === 'function'
? children({ close })
: children}
{!isAlert && (
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => close()}
aria-label={t('closeDialog')}
<Div margin="auto" width="fit-content" maxWidth="full">
<Div margin="1rem" pointerEvents="auto">
<Box size="sm" type={boxType} ref={innerRef}>
{!!title && (
<Heading
slot="title"
level={1}
className={text({ variant: 'h1' })}
>
<RiCloseLine />
</Button>
</Div>
)}
</Box>
{title}
</Heading>
)}
{typeof children === 'function'
? children({ close })
: children}
{!isAlert && (
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => close()}
aria-label={t('closeDialog')}
>
<RiCloseLine />
</Button>
</Div>
)}
</Box>
</Div>
</Div>
</VerticallyOffCenter>
)}
+1
View File
@@ -23,6 +23,7 @@ import { Div } from './Div'
const FieldWrapper = styled('div', {
base: {
marginBottom: 'textfield',
minWidth: 0,
},
})
+25
View File
@@ -0,0 +1,25 @@
import { ReactNode } from 'react'
import { MenuTrigger } from 'react-aria-components'
import { StyledPopover } from './Popover'
import { Box } from './Box'
/**
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
*/
export const Menu = ({
children,
}: {
children: [trigger: ReactNode, menu: ReactNode]
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<Box size="sm" type="popover">
{menu}
</Box>
</StyledPopover>
</MenuTrigger>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { ReactNode } from 'react'
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
import { menuItemRecipe } from './menuItemRecipe'
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const MenuList = <T extends string | number = string>({
onAction,
selectedItem,
items = [],
...menuProps
}: {
onAction: (key: T) => void
selectedItem?: T
items: Array<string | { value: T; label: ReactNode }>
} & MenuProps<unknown>) => {
return (
<Menu
selectionMode={selectedItem !== undefined ? 'single' : undefined}
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
{...menuProps}
>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
return (
<MenuItem
className={menuItemRecipe({ extraPadding: true })}
key={value}
id={value as string}
onAction={() => {
onAction(value as T)
}}
>
{label}
</MenuItem>
)
})}
</Menu>
)
}
+4 -1
View File
@@ -49,7 +49,10 @@ const StyledOverlayArrow = styled(OverlayArrow, {
})
/**
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
*
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
* This is here when needing to show unrestricted content in a box.
*/
export const Popover = ({
children,
@@ -1,72 +0,0 @@
import { ReactNode, useContext } from 'react'
import {
ButtonProps,
Button,
OverlayTriggerStateContext,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
const ListItem = styled(Button, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const PopoverList = <T extends string | number = string>({
onAction,
closeOnAction = true,
items = [],
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { key: string; value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
<ul>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
const key = typeof item === 'string' ? item : item.key
return (
<li key={key}>
<ListItem
key={value}
onPress={() => {
onAction(value as T)
if (closeOnAction) {
popoverState.close()
}
}}
>
{label}
</ListItem>
</li>
)
})}
</ul>
)
}
+16 -23
View File
@@ -11,6 +11,7 @@ import {
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuItemRecipe } from './menuItemRecipe'
const StyledButton = styled(Button, {
base: {
@@ -31,11 +32,20 @@ const StyledButton = styled(Button, {
'&[data-pressed]': {
backgroundColor: 'control.hover',
},
// fixme disabled style is being overridden by placeholder one and needs refinement.
'&[data-disabled]': {
color: 'default.subtle-text',
borderColor: 'gray.200',
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
},
},
})
const StyledSelectValue = styled(SelectValue, {
base: {
textOverflow: 'ellipsis',
overflow: 'hidden',
textWrap: 'nowrap',
'&[data-placeholder]': {
color: 'default.subtle-text',
fontStyle: 'italic',
@@ -43,27 +53,6 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledListBoxItem = styled(ListBoxItem, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
export const Select = <T extends string | number>({
label,
items,
@@ -85,9 +74,13 @@ export const Select = <T extends string | number>({
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<StyledListBoxItem id={item.value} key={item.value}>
<ListBoxItem
className={menuItemRecipe({ extraPadding: true })}
id={item.value}
key={item.value}
>
{item.label}
</StyledListBoxItem>
</ListBoxItem>
))}
</ListBox>
</Box>

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