Compare commits

...

97 Commits

Author SHA1 Message Date
lebaudantoine dc9dedb91b wip update tag 2024-09-12 11:19:23 +02:00
lebaudantoine 3da3641a19 🚧 (frontend) Add start/stop audio recording menu item
Functional but not mergeable—remote participants need to be informed
when recording starts, including the egress ID to prevent collisions.

Quick and dirty implementation.
2024-09-12 10:58:21 +02:00
lebaudantoine 21626b4c4c 🚧(frontend) show recording status indicator
Room recording status triggers only when the egress worker is active.
Querying the Egress API starts the worker, which joins the room as an invisible participant.
The room is marked as "recording" only once the worker has fully joined.
Not mergeable yet.

Sadly the room event gives no information about the Egress worker’s ID.
2024-09-12 10:58:19 +02:00
lebaudantoine f16cedf5a5 🚧(frontend) draft audio-only room recording method
(roomId is wrongly named, and designates room’s slug)

Room’s slug are passed in filenames to simplify post-processing. Thus, from filename,
we can retrieve the room being recorded.

Current limitation: audio tracks are mixed, requiring diarization.
Future plans include full audio + video recording for a more complete meeting experience.
2024-09-12 10:49:00 +02:00
lebaudantoine 00e4679dc5 🚧(backend) allow room recording
Added 'room_record' grant to LiveKit token to enable room recording.
Initial implementation allows any authenticated user to record,
but this needs refinement.

Only admins and persisted room members should have this capability,
as they are the ones notified post-recording. Not ready for merge.
2024-09-12 10:49:00 +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
102 changed files with 3619 additions and 872 deletions
+1 -1
Submodule secrets updated: 9da011f5c2...f5fbc16e6e
+30
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,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=True,
room_record=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
@@ -49,6 +78,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()
+1
View File
@@ -275,6 +275,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
+11 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.4"
version = "0.1.5"
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.10",
"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",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.9",
"factory_boy==3.3.0",
"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",
"PyJWT==2.9.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.12.0",
"sentry-sdk==2.13.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,17 +71,17 @@ 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-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.5.6",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
]
+107 -46
View File
@@ -1,16 +1,17 @@
{
"name": "meet",
"version": "0.1.4",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.4",
"version": "0.1.5",
"dependencies": {
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@react-aria/toast": "3.0.0-beta.15",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
@@ -23,6 +24,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"use-sound": "^4.0.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
@@ -37,7 +39,7 @@
"@typescript-eslint/parser": "7.13.1",
"@vitejs/plugin-react": "4.3.1",
"eslint": "8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
@@ -454,7 +456,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -1108,9 +1109,9 @@
"dev": true
},
"node_modules/@internationalized/date": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.4.tgz",
"integrity": "sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==",
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz",
"integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -1972,35 +1973,35 @@
}
},
"node_modules/@react-aria/i18n": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.11.1.tgz",
"integrity": "sha512-vuiBHw1kZruNMYeKkTGGnmPyMnM5T+gT8bz97H1FqIq1hQ6OPzmtBZ6W6l6OIMjeHI5oJo4utTwfZl495GALFQ==",
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.2.tgz",
"integrity": "sha512-PvEyC6JWylTpe8dQEWqQwV6GiA+pbTxHQd//BxtMSapRW3JT9obObAnb/nFhj3HthkUvqHyj0oO1bfeN+mtD8A==",
"dependencies": {
"@internationalized/date": "^3.5.4",
"@internationalized/date": "^3.5.5",
"@internationalized/message": "^3.1.4",
"@internationalized/number": "^3.5.3",
"@internationalized/string": "^3.2.3",
"@react-aria/ssr": "^3.9.4",
"@react-aria/utils": "^3.24.1",
"@react-types/shared": "^3.23.1",
"@react-aria/ssr": "^3.9.5",
"@react-aria/utils": "^3.25.2",
"@react-types/shared": "^3.24.1",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.21.3",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.21.3.tgz",
"integrity": "sha512-BWIuf4qCs5FreDJ9AguawLVS0lV9UU+sK4CCnbCNNmYqOWY+1+gRXCsnOM32K+oMESBxilAjdHW5n1hsMqYMpA==",
"version": "3.22.2",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.22.2.tgz",
"integrity": "sha512-xE/77fRVSlqHp2sfkrMeNLrqf2amF/RyuAS6T5oDJemRSgYM3UoxTbWjucPhfnoW7r32pFPHHgz4lbdX8xqD/g==",
"dependencies": {
"@react-aria/ssr": "^3.9.4",
"@react-aria/utils": "^3.24.1",
"@react-types/shared": "^3.23.1",
"@react-aria/ssr": "^3.9.5",
"@react-aria/utils": "^3.25.2",
"@react-types/shared": "^3.24.1",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/label": {
@@ -2016,6 +2017,20 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
"node_modules/@react-aria/landmark": {
"version": "3.0.0-beta.15",
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.0-beta.15.tgz",
"integrity": "sha512-EEABy0IFzqoS7r11HoD2YwiGR5LFw4kWDFTFUFwJkRP5tHEzsrEgkKPSPJXScQr5m7tenV6j0/Zzl1+w1ki3lA==",
"dependencies": {
"@react-aria/utils": "^3.25.2",
"@react-types/shared": "^3.24.1",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/link": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.7.1.tgz",
@@ -2289,9 +2304,9 @@
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.4",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.4.tgz",
"integrity": "sha512-4jmAigVq409qcJvQyuorsmBR4+9r3+JEC60wC+Y0MZV0HCtTmm8D9guYXlJMdx0SSkgj0hHAyFm/HvPNFofCoQ==",
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz",
"integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
@@ -2299,7 +2314,7 @@
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/switch": {
@@ -2402,6 +2417,24 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
"node_modules/@react-aria/toast": {
"version": "3.0.0-beta.15",
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.0-beta.15.tgz",
"integrity": "sha512-bg6ZXq4B5JYVt3GXVlf075tQOakcfumbDLnUMaijez4BhacuxF01+IvBPzAVEsARVNXfELoXa7Frb2K54Wgvtw==",
"dependencies": {
"@react-aria/i18n": "^3.12.2",
"@react-aria/interactions": "^3.22.2",
"@react-aria/landmark": "3.0.0-beta.15",
"@react-aria/utils": "^3.25.2",
"@react-stately/toast": "3.0.0-beta.5",
"@react-types/button": "^3.9.6",
"@react-types/shared": "^3.24.1",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/toggle": {
"version": "3.10.4",
"resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.10.4.tgz",
@@ -2470,18 +2503,18 @@
}
},
"node_modules/@react-aria/utils": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.24.1.tgz",
"integrity": "sha512-O3s9qhPMd6n42x9sKeJ3lhu5V1Tlnzhu6Yk8QOvDuXf7UGuUjXf9mzfHJt1dYzID4l9Fwm8toczBzPM9t0jc8Q==",
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.25.2.tgz",
"integrity": "sha512-GdIvG8GBJJZygB4L2QJP1Gabyn2mjFsha73I2wSe+o4DYeGWoJiMZRM06PyTIxLH4S7Sn7eVDtsSBfkc2VY/NA==",
"dependencies": {
"@react-aria/ssr": "^3.9.4",
"@react-stately/utils": "^3.10.1",
"@react-types/shared": "^3.23.1",
"@react-aria/ssr": "^3.9.5",
"@react-stately/utils": "^3.10.3",
"@react-types/shared": "^3.24.1",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-aria/visually-hidden": {
@@ -2827,6 +2860,18 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
"node_modules/@react-stately/toast": {
"version": "3.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.0.0-beta.5.tgz",
"integrity": "sha512-MEdQwcKsexlcJ4YQZ9cN5QSIqTlGGdgC5auzSKXXoq15DHuo4mtHfLzXPgcMDYOhOdmyphMto8Vt+21UL2FOrw==",
"dependencies": {
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-stately/toggle": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.7.4.tgz",
@@ -2869,14 +2914,14 @@
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.1.tgz",
"integrity": "sha512-VS/EHRyicef25zDZcM/ClpzYMC5i2YGN6uegOeQawmgfGjb02yaCX0F0zR69Pod9m2Hr3wunTbtpgVXvYbZItg==",
"version": "3.10.3",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.3.tgz",
"integrity": "sha512-moClv7MlVSHpbYtQIkm0Cx+on8Pgt1XqtPx6fy9rQFb2DNc9u1G3AUVnqA17buOkH1vLxAtX4MedlxMWyRCYYA==",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-stately/virtualizer": {
@@ -2905,14 +2950,14 @@
}
},
"node_modules/@react-types/button": {
"version": "3.9.4",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.9.4.tgz",
"integrity": "sha512-raeQBJUxBp0axNF74TXB8/H50GY8Q3eV6cEKMbZFP1+Dzr09Ngv0tJBeW0ewAxAguNH5DRoMUAUGIXtSXskVdA==",
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.9.6.tgz",
"integrity": "sha512-8lA+D5JLbNyQikf8M/cPP2cji91aVTcqjrGpDqI7sQnaLFikM8eFR6l1ZWGtZS5MCcbfooko77ha35SYplSQvw==",
"dependencies": {
"@react-types/shared": "^3.23.1"
"@react-types/shared": "^3.24.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-types/calendar": {
@@ -3122,11 +3167,11 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.23.1.tgz",
"integrity": "sha512-5d+3HbFDxGZjhbMBeFHRQhexMFt4pUce3okyRtUVKbbedQFUrtXSBg9VszgF2RTeQDKDkMCIQDtz5ccP/Lk1gw==",
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.24.1.tgz",
"integrity": "sha512-AUQeGYEm/zDTN6zLzdXolDxz3Jk5dDL7f506F07U8tBwxNNI3WRdhU84G0/AaFikOZzDXhOZDr3MhQMzyE7Ydw==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-types/slider": {
@@ -6651,6 +6696,11 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"dev": true
},
"node_modules/howler": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -10118,6 +10168,17 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sound": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/use-sound/-/use-sound-4.0.3.tgz",
"integrity": "sha512-L205pEUFIrLsGYsCUKHQVCt0ajs//YQOFbEQeNwaWaqQj3y3st4SuR+rvpMHLmv8hgTcfUFlvMQawZNI3OE18w==",
"dependencies": {
"howler": "^2.1.3"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -16,6 +16,7 @@
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@react-aria/toast": "3.0.0-beta.15",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
@@ -28,6 +29,7 @@
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"use-sound": "4.0.3",
"valtio": "1.13.2",
"wouter": "3.3.0"
},
+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.
+12 -9
View File
@@ -6,6 +6,7 @@ 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'
@@ -23,15 +24,17 @@ function App() {
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>
)
}
@@ -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,93 @@
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}
>
{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>
)
}
+48 -22
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { Button, Text } from '@/primitives'
import { Button, Menu, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { Screen } from '@/layout/Screen'
@@ -10,6 +10,11 @@ import { authUrl, useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react'
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import { RiAddLine, RiLink } from '@remixicon/react'
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
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>
@@ -43,21 +43,43 @@ export const Home = () => {
</Text>
)}
<HStack gap="gutter">
<Button
variant="primary"
onPress={
isLoggedIn
? async () => {
{isLoggedIn ? (
<Menu>
<Button variant="primary">{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 },
})
)
}}
>
<RiAddLine size={18} />
{t('createMenu.instantOption')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
)
}}
>
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
<Button variant="primary" href={authUrl()}>
{t('login', { ns: 'global' })}
</Button>
)}
<DialogTrigger>
<Button variant="primary" outline>
{t('joinMeeting')}
@@ -66,6 +88,10 @@ export const Home = () => {
</DialogTrigger>
</HStack>
</Centered>
<LaterMeetingDialog
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
/>
</Screen>
</UserAware>
)
@@ -0,0 +1,101 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, 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])
// fixme - close all related toasters when hands are lowered remotely
useEffect(() => {
const decoder = new TextDecoder()
const handleNotificationReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
if (!participant) {
return
}
if (isMobileBrowser()) {
return
}
const notification = decoder.decode(payload)
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.Raised
)
if (existingToast && notification === NotificationType.Lowered) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && notification === NotificationType.Raised) {
triggerNotificationSound(NotificationType.Raised)
toastQueue.add(
{
participant,
type: NotificationType.Raised,
},
{ timeout: 5000 }
)
}
}
room.on(RoomEvent.DataReceived, handleNotificationReceived)
return () => {
room.off(RoomEvent.DataReceived, handleNotificationReceived)
}
}, [room, triggerNotificationSound])
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>
)
}
@@ -50,7 +50,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({
@@ -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,102 @@ 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}
>
{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>
)
}
@@ -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,18 @@
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) {
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,56 @@
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useParams } from 'wouter'
export const useRecordRoom = () => {
const data = useRoomData()
const { roomId: roomSlug } = useParams()
const recordRoom = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
if (!roomSlug) {
throw new Error('Room ID is not available')
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
'/twirp/livekit.Egress/StartRoomCompositeEgress'
),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
room_name: data.livekit.room,
audio_only: true,
file_outputs: [
{
file_extension: 'ogg',
filepath: `{room_name}_{time}_${roomSlug}`,
},
],
}),
}
)
}
const stopRecordingRoom = (egressId: string) => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
return fetchServerApi(
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
data.livekit.token,
{
method: 'POST',
body: JSON.stringify({
egressId,
}),
}
)
}
return { recordRoom, stopRecordingRoom }
}
@@ -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,32 @@
import { useRoomContext } from '@livekit/components-react'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
export const RecordingIndicator = () => {
const room = useRoomContext()
const [isRecording, setIsRecording] = useState(room.isRecording)
useEffect(() => {
const handleRecordingStatusChanges = (isRecording: boolean) => {
setIsRecording(isRecording)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
}
}, [room])
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
width: '100%',
}}
>
Room is recording: {isRecording ? 'yes' : 'no'}
</div>
)
}
@@ -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 { 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,17 @@ 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' })
}}
isSelected={isChatOpen}
onPress={() => toggleChat()}
>
<RiChat1Line />
</Button>
{!!state?.unreadMessages && (
</ToggleButton>
{!!unreadMessages && (
<div
className={css({
position: 'absolute',
@@ -0,0 +1,54 @@
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'
import { NotificationType } from '@/features/notifications/NotificationType'
export const HandToggle = () => {
const { t } = useTranslation('rooms')
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const label = isHandRaised
? t('controls.hand.lower')
: t('controls.hand.raise')
const notifyOtherParticipants = (isHandRaised: boolean) => {
room.localParticipant.publishData(
new TextEncoder().encode(
!isHandRaised ? NotificationType.Raised : NotificationType.Lowered
),
{
reliable: true,
}
)
}
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={label}
tooltip={label}
isSelected={isHandRaised}
onPress={() => {
notifyOtherParticipants(isHandRaised)
toggleRaisedHand()
}}
>
<RiHand />
</ToggleButton>
</div>
)
}
@@ -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,54 @@
import { menuItemRecipe } from '@/primitives/menuItemRecipe'
import {
RiFeedbackLine,
RiQuestionLine,
RiSettings3Line,
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
import { RecordingMenuItem } from './RecordingMenuItem.tsx'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
onOpenDialog,
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms')
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<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={18} />
{t('options.items.support')}
</MenuItem>
<MenuItem
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuItemRecipe({ icon: true })}
>
<RiFeedbackLine size={18} />
{t('options.items.feedbacks')}
</MenuItem>
<MenuItem
className={menuItemRecipe({ icon: true })}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={18} />
{t('options.items.settings')}
</MenuItem>
<RecordingMenuItem />
</RACMenu>
)
}
@@ -0,0 +1,51 @@
import { MenuItem } from 'react-aria-components'
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
import { useState } from 'react'
import { egressStore } from '@/stores/egress.tsx'
import { useSnapshot } from 'valtio'
export const RecordingMenuItem = () => {
const { recordRoom, stopRecordingRoom } = useRecordRoom()
const egressSnap = useSnapshot(egressStore)
const egressId = egressSnap.egressId
const [isPending, setIsPending] = useState(false)
const handleAction = async () => {
if (egressId) {
setIsPending(true)
const response = await stopRecordingRoom(egressId)
console.log(response)
egressStore.egressId = undefined
setIsPending(false)
} else {
setIsPending(true)
const response = await recordRoom()
egressStore.egressId = response['egress_id'] as string
setIsPending(false)
}
}
return (
<MenuItem
isDisabled={isPending}
className={menuItemRecipe({ icon: true })}
onAction={handleAction}
>
{egressId ? (
<>
<RiPauseCircleLine size={18} />
Stop recording room
</>
) : (
<>
<RiRecordCircleLine size={18} />
Record room
</>
)}
</MenuItem>
)
}
@@ -0,0 +1,81 @@
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 })}
>
<RiHand />
</Button>
</HStack>
)
}
@@ -0,0 +1,26 @@
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)}
>
{t('participants.lowerParticipantsHand')}
</Button>
)
}
@@ -0,0 +1,162 @@
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)
}
>
{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,42 +1,17 @@
import { css } from '@/styled-system/css'
import * as React from 'react'
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 { Box, Button, Div, H } from '@/primitives'
import { text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { capitalize } from '@/utils/capitalize'
import { participantsStore } from '@/stores/participants'
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 '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '@/features/rooms/livekit/components/controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '@/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
@@ -49,43 +24,48 @@ export const ParticipantsList = () => {
updateOnlyOn: 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"
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={3} className={text({ variant: 'h2' })}>
<span>{t('participants.heading')}</span>{' '}
<span
className={css({
marginLeft: '0.75rem',
fontWeight: 'normal',
fontSize: '1rem',
})}
>
{participants?.length}
</span>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
}}
>
{t('participants.heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -98,63 +78,41 @@ export const ParticipantsList = () => {
<RiCloseLine />
</Button>
</Div>
{participants?.length && (
<VStack
role="list"
<Div overflowY="scroll">
<H
lvl={2}
className={css({
alignItems: 'start',
gap: 'none',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
fontSize: '0.875rem',
fontWeight: 'bold',
color: '#5f6368',
padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{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>
)}
{t('participants.subheading').toUpperCase()}
</H>
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList
heading={t('participants.raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem participant={participant} />
)}
action={() => (
<LowerAllHandsButton participants={raisedHandParticipants} />
)}
/>
</Div>
)}
<ParticipantsCollapsableList
heading={t('participants.contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem participant={participant} />
)}
/>
</Div>
</Box>
)
}
@@ -1,16 +1,13 @@
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
/**
* Context could not be used due to inconsistent refresh behavior.
* The 'numParticipant' property on the room only updates when the room's metadata changes,
@@ -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,16 @@ 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
}}
isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()}
>
<RiGroupLine />
</Button>
</ToggleButton>
<div
className={css({
position: 'absolute',
@@ -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>
)
}
@@ -0,0 +1,22 @@
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) {
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,34 @@
import { useLayoutContext } from '@livekit/components-react'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants.ts'
export const useWidgetInteraction = () => {
const { dispatch, state } = useLayoutContext().widget
const participantsSnap = useSnapshot(participantsStore)
const isParticipantsOpen = participantsSnap.showParticipants
const toggleParticipants = () => {
if (dispatch && state?.showChat) {
dispatch({ msg: 'toggle_chat' })
}
participantsStore.showParticipants = !isParticipantsOpen
}
const toggleChat = () => {
if (isParticipantsOpen) {
participantsStore.showParticipants = false
}
if (dispatch) {
dispatch({ msg: 'toggle_chat' })
}
}
return {
toggleParticipants,
toggleChat,
isChatOpen: state?.showChat,
unreadMessages: state?.unreadMessages,
isParticipantsOpen,
}
}
@@ -19,6 +19,7 @@ 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 { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
/** @public */
export type ControlBarControls = {
@@ -183,6 +184,7 @@ export function ControlBar({
)}
</TrackToggle>
)}
<HandToggle />
<ChatToggle />
<ParticipantsToggle />
<OptionsButton />
@@ -16,11 +16,9 @@ import * as React from 'react'
import {
CarouselLayout,
ConnectionStateToast,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
@@ -35,6 +33,10 @@ 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 { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { RecordingIndicator } from '@/features/rooms/livekit/components/RecordingIndicator.tsx'
const LayoutWrapper = styled(
'div',
@@ -183,26 +185,40 @@ export function VideoConference({
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
<RecordingIndicator />
<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}
@@ -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']
}
@@ -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,81 @@
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
import { Trans, useTranslation } from 'react-i18next'
import {
usePersistentUserChoices,
useRoomContext,
} from '@livekit/components-react'
import { authUrl, 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'
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>
<P>
<A href={authUrl()}>{t('login', { ns: 'global' })}</A>
</P>
</>
)}
<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>
)
}
@@ -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',
+7 -7
View File
@@ -2,11 +2,13 @@ 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 { A, Text, Button } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, 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'
export const Header = () => {
const { t } = useTranslation()
@@ -64,7 +66,7 @@ export const Header = () => {
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Popover aria-label={t('logout')}>
<Menu>
<Button
size="sm"
invisible
@@ -73,17 +75,15 @@ 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') {
window.location.href = logoutUrl()
}
}}
/>
</Popover>
</Menu>
)}
<SettingsButton />
</Stack>
+2 -5
View File
@@ -10,15 +10,12 @@
"forbidden": {
"heading": ""
},
"languageSelector": {
"buttonLabel": "",
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
"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": ""
}
}
+24 -10
View File
@@ -12,10 +12,11 @@
},
"leaveRoomPrompt": "",
"shareDialog": {
"copied": "",
"copy": "",
"copied": "",
"heading": "",
"inputLabel": ""
"description": "",
"permissions": ""
},
"error": {
"createRoom": {
@@ -32,6 +33,10 @@
"open": "",
"closed": ""
},
"hand": {
"raise": "",
"lower": ""
},
"leave": "",
"participants": {
"open": "",
@@ -45,18 +50,27 @@
"support": "",
"settings": "",
"username": ""
},
"username": {
"heading": "",
"label": "",
"description": "",
"validationError": "",
"submitLabel": ""
}
},
"participants": {
"heading": "",
"subheading": "",
"closeButton": "",
"you": ""
"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": ""
}
}
+2 -5
View File
@@ -10,15 +10,12 @@
"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",
"logout": "Logout",
"notFound": {
"heading": "Page not found"
}
},
"submit": "OK"
}
+12 -1
View File
@@ -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"
}
}
+26 -12
View File
@@ -12,10 +12,11 @@
},
"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": {
@@ -32,6 +33,10 @@
"open": "Close the chat",
"closed": "Open the chat"
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
},
"leave": "Leave",
"participants": {
"open": "Hide everyone",
@@ -45,18 +50,27 @@
"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"
}
},
"participants": {
"heading": "Participants",
"subheading": "In room",
"closeButton": "Hide participants",
"you": "You"
"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": "Votre Nom",
"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"
}
}
+2 -5
View File
@@ -10,15 +10,12 @@
"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",
"logout": "Se déconnecter",
"notFound": {
"heading": "Page introuvable"
}
},
"submit": "OK"
}
+12 -1
View File
@@ -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"
}
}
+26 -12
View File
@@ -12,10 +12,11 @@
},
"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": {
@@ -32,6 +33,10 @@
"open": "Masquer le chat",
"closed": "Afficher le chat"
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
},
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
@@ -45,18 +50,27 @@
"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"
}
},
"participants": {
"heading": "Participants",
"subheading": "Dans la réunion",
"closeButton": "Masquer les participants",
"you": "Vous"
"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
)
}
+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()}
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>
+188
View File
@@ -0,0 +1,188 @@
import {
Tabs as RACTabs,
Tab as RACTab,
TabPanel as RACTabPanel,
TabList as RACTabList,
TabProps as RACTabProps,
TabsProps as RACTabsProps,
TabListProps as RACTabListProps,
TabPanelProps as RACTabPanelProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { StyledVariantProps } from '@/styled-system/types'
import React from 'react'
const StyledTabs = styled(RACTabs, {
base: {
display: 'flex',
color: 'colorPalette.text',
'&[data-orientation=horizontal]': {
flexDirection: 'column',
'--horizontal': '3px',
},
'&[data-orientation=vertical]': {
flexDirection: 'row',
'--vertical': '3px',
},
},
})
export type TabsProps = RACTabsProps & StyledVariantProps<typeof StyledTabs>
export const Tabs = ({ children, ...props }: TabsProps) => {
return <StyledTabs {...props}>{children}</StyledTabs>
}
const StyledTab = styled(RACTab, {
base: {
padding: '10px',
cursor: 'pointer',
outline: 'none',
position: 'relative',
color: 'box.text',
borderColor: 'transparent',
forcedColorAdjust: 'none',
},
variants: {
border: {
true: {
'&[data-selected]': {
borderColor: 'primary',
},
borderBottom: 'var(--horizontal) solid',
borderInlineEnd: 'var(--vertical) solid',
},
false: {
border: 'none',
},
},
highlight: {
true: {
borderRadius: 4,
backgroundColor: 'colorPalette.active',
transition: 'background 200ms, color 200ms',
'&[data-hovered]': {
backgroundColor: 'gray.100',
color: 'box.text',
},
'&[data-selected]': {
backgroundColor: 'primary',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'primary',
color: 'white',
},
},
},
},
icon: {
true: {
display: 'flex',
gap: '1rem',
alignItems: 'center',
},
},
},
defaultVariants: {
border: true,
icon: false,
},
})
export type TabProps = RACTabProps & StyledVariantProps<typeof StyledTab>
export const Tab = ({ children, ...props }: TabProps) => {
const parentBorder = React.useContext(BorderContext)
return (
<StyledTab border={parentBorder} {...props}>
{children}
</StyledTab>
)
}
const StyledTabList = styled(RACTabList, {
base: {
display: 'flex',
'&[data-orientation=vertical]': {
flexDirection: 'column',
},
},
variants: {
border: {
false: {
border: 'none',
},
true: {
'&[data-orientation=horizontal]': {
borderBottom: '1px solid',
borderColor: 'gray.300',
},
'&[data-orientation=vertical]': {
borderInlineEnd: '1px solid',
borderColor: 'gray.300',
},
},
},
},
defaultVariants: {
border: true,
},
})
// @fixme type could be simplified to avoid redefining the children props
export type TabListProps = {
children: React.ReactNode
} & RACTabListProps<typeof Tab> &
StyledVariantProps<typeof StyledTabList>
const BorderContext = React.createContext<boolean | undefined>(true)
export const TabList = ({ children, border, ...props }: TabListProps) => {
return (
<BorderContext.Provider value={border}>
<StyledTabList {...props} border={border}>
{children}
</StyledTabList>
</BorderContext.Provider>
)
}
const StyledTabPanel = styled(RACTabPanel, {
base: {
marginTop: '4px',
borderRadius: '4px',
outline: 'none',
'&[data-focus-visible]': {
outline: '2px solid red',
},
},
variants: {
flex: {
true: {
display: 'flex',
flexGrow: 1,
overflow: 'auto',
flexDirection: 'column', //fixme should be parameterizable
},
},
padding: {
sm: {
padding: '10px',
},
md: {
padding: '2rem',
},
},
},
defaultVariants: {
padding: 'sm',
flex: false,
},
})
export type TabPanelProps = RACTabPanelProps &
StyledVariantProps<typeof StyledTabPanel>
export const TabPanel = ({ children, ...props }: TabPanelProps) => {
return <StyledTabPanel {...props}>{children}</StyledTabPanel>
}
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import type { HTMLAttributes } from 'react'
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
@@ -0,0 +1,25 @@
import {
ToggleButton as RACToggleButton,
ToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
/>
</TooltipWrapper>
)
}
@@ -2,16 +2,41 @@ import { type ReactNode } from 'react'
import {
OverlayArrow,
Tooltip as RACTooltip,
TooltipProps,
TooltipTrigger,
type TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
export type TooltipWrapperProps = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
/**
* Wrap a component you want to apply a tooltip on (for example a Button)
*
* If no tooltip is given, just returns children
*/
export const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & TooltipWrapperProps) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
/**
* Styled react aria Tooltip component.
*
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
* so you should not need to use this component directly.
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const StyledTooltip = styled(RACTooltip, {
@@ -80,7 +105,7 @@ const TooltipArrow = () => {
)
}
export const Tooltip = ({
const Tooltip = ({
children,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { styled } from '../styled-system/jsx'
import { styled } from '@/styled-system/jsx'
export const Ul = styled('ul', {
base: {
+178
View File
@@ -0,0 +1,178 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
export const buttonRecipe = cva({
base: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
'&[data-selected]': {
backgroundColor: 'colorPalette.active',
},
'&[data-disabled]': {
cursor: 'auto',
},
},
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',
borderColor: 'control.subtle',
},
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!',
},
},
text: {
color: 'primary',
'&[data-hovered]': {
background: 'gray.100 !important',
color: 'primary !important',
},
},
danger: {
colorPalette: 'danger',
borderColor: 'danger.600',
color: 'danger.subtle-text',
backgroundColor: 'danger.subtle',
'&[data-hovered]': {
backgroundColor: 'danger.200',
},
'&[data-pressed]': {
backgroundColor: 'danger.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',
},
},
// if the button is next to other ones to make a "button group", tell where the button is to handle radius
groupPosition: {
left: {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
right: {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: 0,
},
center: {
borderRadius: 0,
},
},
// some toggle buttons make more sense without a "pushed button" style when selected because their content changes to mark the state
toggledStyles: {
false: {
'&[data-selected]': {
backgroundColor: 'colorPalette',
},
},
},
legacyStyle: {
true: {
borderColor: 'gray.400',
transition: 'border 200ms, background 200ms, color 200ms',
'&[data-hovered]': {
borderColor: 'gray.500',
},
'&[data-pressed]': {
borderColor: 'gray.500',
},
'&[data-selected]': {
backgroundColor: '#1d4ed8',
color: 'white',
borderColor: 'gray.500',
'&[data-hovered]': {
borderColor: '#6b7280',
backgroundColor: '#1e40af',
},
},
},
},
},
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
toggledStyles: true,
},
})
+9 -1
View File
@@ -1,3 +1,9 @@
/**
* exposes all primitives we want to use in other parts of the app.
*
* It's intended not everything is exported: some primitives are meant as building-blocks
* for other primitives and don't have any value being exposed.
*/
export { A } from './A'
export { Badge } from './Badge'
export { Bold } from './Bold'
@@ -13,9 +19,11 @@ export { Hr } from './Hr'
export { Italic } from './Italic'
export { Input } from './Input'
export { Link } from './Link'
export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
@@ -0,0 +1,54 @@
import { cva } from '@/styled-system/css'
/**
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
*
* these are in their own files because react hot refresh doesn't like exporting stuff
* that aren't components in component files
*/
export const menuItemRecipe = cva({
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
position: 'relative',
'&[data-selected]': {
'&::before': {
content: '"✓"',
position: 'absolute',
top: '2px',
left: '6px',
},
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
variants: {
icon: {
true: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
paddingY: '0.4rem',
},
},
extraPadding: {
true: {
paddingLeft: 1.5,
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
egressId: string | undefined
}
export const egressStore = proxy<State>({
egressId: undefined,
})
+23 -1
View File
@@ -1,5 +1,27 @@
import { LogLevel, setLogLevel } from 'livekit-client'
import {
getBrowser,
LocalParticipant,
LogLevel,
Participant,
setLogLevel,
} from 'livekit-client'
export const silenceLiveKitLogs = (shouldSilenceLogs: boolean) => {
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
}
export function isFireFox(): boolean {
return getBrowser()?.name === 'Firefox'
}
export function isChromiumBased(): boolean {
return getBrowser()?.name === 'Chrome'
}
export function isSafari(): boolean {
return getBrowser()?.name === 'Safari'
}
export function isLocal(p: Participant) {
return p instanceof LocalParticipant
}
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.4"
tag: "v0.1.5"
backend:
migrateJobAnnotations:
@@ -108,7 +108,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.4"
tag: "v0.1.5"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.4"
tag: "v0.1.5"
backend:
migrateJobAnnotations:
@@ -109,7 +109,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.4"
tag: "v0.1.5"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "main"
tag: "hackathon"
backend:
migrateJobAnnotations:
@@ -92,7 +92,7 @@ backend:
secretKeyRef:
name: backend
key: LIVEKIT_API_KEY
LIVEKIT_API_URL: https://livekit-preprod.beta.numerique.gouv.fr
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
ALLOW_UNREGISTERED_ROOMS: False
@@ -108,7 +108,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "main"
tag: "hackathon"
ingress:
enabled: true

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