Compare commits

..

127 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
lebaudantoine 223cee3828 🔖(minor) bump release to 0.1.4 2024-08-12 23:32:19 +02:00
lebaudantoine 101be503bf 🚸(frontend) fix widget state management
The current design is inefficient, using two separate approaches to manage
the app state and determine which widget is open. This inconsistency affects
the user experience and overall design quality.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refactoring will follow to enhance code maintainability.

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

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

Previously, controls were always in English, causing confusion for French users.
A full refactor of the controls is planned soon.
2024-08-08 14:34:18 +02:00
lebaudantoine bd26a0abc1 💩(frontend) duplicate Controls bar
Same approach as the previous commit. Few elements were not exposed
by the LiveKit package, and I had to duplicate them. Let's see if
we can asap get rid off all this complexity.
2024-08-08 14:34:18 +02:00
lebaudantoine abb708aa49 💩(frontend) duplicate VideoConference component
Basically, duplicate LiveKit code to start iterating on their
components, not sure wether it's the optimal strategy, but at least
we will be more agile, shipping small features which are lacking.
2024-08-08 14:34:18 +02:00
120 changed files with 4329 additions and 914 deletions
-64
View File
@@ -5,7 +5,6 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="meet"
@@ -92,66 +91,3 @@ function _dc_exec() {
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
}
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
# terraform directory.
#
# usage: _set_openstack_project
#
# If necessary the script will prompt the user to choose a project from those available
function _set_openstack_project() {
declare prompt
declare -a projects
declare -i default=1
declare -i choice=0
declare -i n_projects
# List projects by looking in the "./env.d/terraform" directory
# and store them in an array
read -r -a projects <<< "$(
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
xargs
)"
nb_projects=${#projects[@]}
if [[ ${nb_projects} -le 0 ]]; then
echo "There are no OpenStack projects defined..." >&2
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
"of your project and copy your \"openrc.sh\" file into it." >&2
exit 10
fi
if [[ ${nb_projects} -gt 1 ]]; then
prompt="Select an OpenStack project to target:\\n"
for (( i=0; i<nb_projects; i++ )); do
prompt+="[$((i+1))] ${projects[$i]}"
if [[ $((i+1)) -eq ${default} ]]; then
prompt+=" (default)"
fi
prompt+="\\n"
done
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
prompt+="Your choice: "
read -r -p "$(echo -e "${prompt}")" choice
if [[ ${choice} -gt nb_projects ]]; then
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
exit 11
fi
if [[ ${choice} -le 0 ]]; then
choice=${default}
fi
fi
project=${projects[$((choice-1))]}
# Check that the openrc.sh file exists for this project
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
exit 12
fi
echo "${project}"
}
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
terraform-state "$@"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
project=$(_set_openstack_project)
echo "Using \"${project}\" project..."
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
# Run Terraform commands in the Hashicorp docker container via docker compose
# shellcheck disable=SC2068
DOCKER_USER="$(id -u):$(id -g)" \
PROJECT="${project}" \
docker compose run --rm \
-e OS_AUTH_URL \
-e OS_IDENTITY_API_VERSION \
-e OS_USER_DOMAIN_NAME \
-e OS_PROJECT_DOMAIN_NAME \
-e OS_TENANT_ID \
-e OS_TENANT_NAME \
-e OS_USERNAME \
-e OS_PASSWORD \
-e OS_REGION_NAME \
-e TF_VAR_user_name \
terraform "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
+1 -1
Submodule secrets updated: 9da011f5c2...f5fbc16e6e
+1 -1
View File
@@ -123,7 +123,7 @@ class RoomSerializer(serializers.ModelSerializer):
if role is not None or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.GET.get("username", None)
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_create_pg_trgm_extension'),
]
operations = [
migrations.AlterField(
model_name='room',
name='configuration',
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
)
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-08-07 14:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_room_configuration'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+1 -1
View File
@@ -296,7 +296,7 @@ class Room(Resource):
configuration = models.JSONField(
blank=True,
default={},
default=dict,
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
+43 -10
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,8 +15,30 @@ 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.
"""Generate a LiveKit access token for a user in a specific room.
Args:
room (str): The name of the room.
@@ -22,10 +49,12 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
Returns:
str: The LiveKit JWT access token.
"""
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=True,
room_record=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
@@ -34,18 +63,22 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
],
)
token = AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
).with_grants(video_grants)
if user.is_anonymous:
token.with_identity(str(uuid4()))
identity = str(uuid4())
default_username = "Anonymous"
else:
token.with_identity(user.sub)
identity = str(user.sub)
default_username = str(user)
token.with_name(username or default_username)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_metadata(json.dumps({"color": generate_color(identity)}))
)
return token.to_jwt()
+8 -1
View File
@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
import json
import os
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
@@ -71,6 +72,7 @@ class Base(Configuration):
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
# Application definition
ROOT_URLCONF = "meet.urls"
@@ -273,6 +275,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
@@ -513,7 +516,11 @@ class Production(Base):
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
ALLOWED_HOSTS = [
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
gethostbyname(gethostname()),
]
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
+11 -11
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.3"
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.3",
"version": "0.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.3",
"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.3",
"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"
},
+28
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.
Binary file not shown.
Binary file not shown.
+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)}
/>
</>
)
}
@@ -1,2 +0,0 @@
export { useAudioOutputs } from './utils/useAudioOutputs'
export { usePersistedMediaDeviceSelect } from './utils/usePersistedMediaDeviceSelect'
@@ -1,36 +0,0 @@
import { useState, useEffect } from 'react'
const getOutputDevices = () => {
return navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then(() => navigator.mediaDevices.enumerateDevices())
.then((devices) => devices.filter(({ kind }) => kind === 'audiooutput'))
}
/**
* custom hook to fetch audio outputs
*
* this is used instead of livekit's useMediaDevices because the livekit integrated one seems to request
* outputs in a weird order, resulting in empty results in firefox if we didn't ask for input before
*/
export const useAudioOutputs = () => {
const [audioOutputs, setAudioOutputs] = useState<MediaDeviceInfo[]>([])
useEffect(() => {
const retrieveOutputDevices = () => {
getOutputDevices()
.then(setAudioOutputs)
.catch((error) => {
console.error('Audio outputs retrieval error :', error)
})
}
retrieveOutputDevices()
const onDeviceChange = () => {
retrieveOutputDevices()
}
navigator?.mediaDevices?.addEventListener('devicechange', onDeviceChange)
return () => {
navigator.mediaDevices.removeEventListener('devicechange', onDeviceChange)
}
}, [])
return audioOutputs
}
@@ -1,27 +0,0 @@
import { useMediaDeviceSelect } from '@livekit/components-react'
import { settingsStore } from '@/features/settings'
/**
* wrap livekit's useMediaDeviceSelect to automatically save in our devices state user selection
*
* note: audiooutput devices are not handled here as we dont use useMediaDeviceSelect for them
*/
export const usePersistedMediaDeviceSelect = (
...args: Parameters<typeof useMediaDeviceSelect>
): ReturnType<typeof useMediaDeviceSelect> => {
const results = useMediaDeviceSelect(...args)
const originalSetter = results.setActiveMediaDevice
results.setActiveMediaDevice = (
...activeMediaDeviceArgs: Parameters<typeof results.setActiveMediaDevice>
) => {
const id = activeMediaDeviceArgs[0]
if (args[0].kind === 'audioinput') {
settingsStore.devices.micDeviceId = id
}
if (args[0].kind === 'videoinput') {
settingsStore.devices.cameraDeviceId = id
}
return originalSetter(...activeMediaDeviceArgs)
}
return results
}
@@ -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>
)
}
+53 -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'
@@ -9,18 +9,23 @@ import { generateRoomId } from '@/features/rooms'
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')
const { isLoggedIn } = useUser()
const { mutateAsync: createRoom } = useCreateRoom({
onSuccess: (data) => {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
},
})
const {
userChoices: { username },
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
return (
<UserAware>
@@ -38,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 })
}
: 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')}
@@ -61,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 }
}
@@ -5,10 +5,14 @@ import { ApiRoom } from './ApiRoom'
export interface CreateRoomParams {
slug: string
username?: string
}
const createRoom = ({ slug }: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/`, {
const createRoom = ({
slug,
username = '',
}: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify({
name: slug,
@@ -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>
)
}
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import {
formatChatMessageLinks,
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
@@ -17,7 +17,8 @@ import { fetchRoom } from '../api/fetchRoom'
import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { type SettingsState } from '@/features/settings'
import { VideoConference } from '../livekit/prefabs/VideoConference'
export const Conference = ({
roomId,
@@ -26,10 +27,7 @@ export const Conference = ({
mode = 'join',
}: {
roomId: string
userConfig: {
devices: SettingsState['devices']
username: SettingsState['username']
}
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
@@ -51,7 +49,7 @@ export const Conference = ({
data,
} = useQuery({
queryKey: fetchKey,
enabled: !initialRoomData,
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
initialData: initialRoomData,
queryFn: () =>
fetchRoom({
@@ -59,7 +57,7 @@ export const Conference = ({
username: userConfig.username,
}).catch((error) => {
if (error.statusCode == '404') {
createRoom({ slug: roomId })
createRoom({ slug: roomId, username: userConfig.username })
}
}),
retry: false,
@@ -68,21 +66,14 @@ export const Conference = ({
const roomOptions = useMemo((): RoomOptions => {
return {
videoCaptureDefaults: {
deviceId: userConfig.devices.cameraDeviceId ?? undefined,
deviceId: userConfig.videoDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.devices.micDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.devices.speakerDeviceId ?? undefined,
deviceId: userConfig.audioDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.devices.cameraDeviceId,
userConfig.devices.micDeviceId,
userConfig.devices.speakerDeviceId,
])
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -120,14 +111,14 @@ export const Conference = ({
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen>
<Screen header={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={userConfig.devices.enableMic}
video={userConfig.devices.enableCamera}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
>
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
{showInviteDialog && (
@@ -1,358 +0,0 @@
import { Screen } from '@/layout/Screen'
import {
Button,
Div,
Field,
Form,
H,
Menu,
MenuList,
ToggleButton,
VerticallyOffCenter,
} from '@/primitives'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import {
RiArrowDropDownLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeUpLine,
} from '@remixicon/react'
import {
useMaybeRoomContext,
usePreviewTracks,
} from '@livekit/components-react'
import { Track, LocalVideoTrack, LocalAudioTrack } from 'livekit-client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
usePersistedMediaDeviceSelect,
useAudioOutputs,
} from '@/features/devices'
import { settingsStore, type SettingsState } from '@/features/settings'
import { css } from '@/styled-system/css'
export const HomemadeJoin = ({
onSubmit,
}: {
onSubmit: (choices: {
devices: SettingsState['devices']
username: SettingsState['username']
}) => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const settingsSnap = useSnapshot(settingsStore)
const [initialUserChoices] = useState({ ...settingsSnap.devices })
const tracks = usePreviewTracks({
audio: settingsSnap.devices.enableMic
? { deviceId: initialUserChoices.micDeviceId }
: false,
video: settingsSnap.devices.enableCamera
? { deviceId: initialUserChoices.cameraDeviceId }
: false,
})
const videoEl = useRef(null)
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
)[0] as LocalVideoTrack,
[tracks]
)
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
[tracks]
)
useEffect(() => {
if (videoEl.current && videoTrack) {
videoTrack.unmute()
videoTrack.attach(videoEl.current)
}
return () => {
videoTrack?.detach()
}
}, [videoTrack])
const room = useMaybeRoomContext()
const {
devices: micDevices,
activeDeviceId: activeMicDeviceId,
setActiveMediaDevice: setActiveMicDevice,
} = usePersistedMediaDeviceSelect({
kind: 'audioinput',
room,
track: audioTrack,
requestPermissions: true,
})
const {
devices: cameraDevices,
activeDeviceId: activeCameraDeviceId,
setActiveMediaDevice: setActiveCameraDevice,
} = usePersistedMediaDeviceSelect({
kind: 'videoinput',
room,
track: videoTrack,
requestPermissions: true,
})
const speakerDevices = useAudioOutputs()
useEffect(() => {
if (settingsStore.devices.micDeviceId) {
setActiveMicDevice(settingsStore.devices.micDeviceId)
}
if (settingsStore.devices.cameraDeviceId) {
setActiveCameraDevice(settingsStore.devices.cameraDeviceId)
}
}, [setActiveCameraDevice, setActiveMicDevice])
return (
<Screen>
<VerticallyOffCenter>
<Div
className={css({
margin: 'auto',
flexWrap: 'wrap',
width: 'fit-content',
maxWidth: 'full',
display: 'flex',
gap: 2,
paddingX: 1,
flexDirection: 'column',
alignItems: 'center',
lg: {
alignItems: 'stretch',
flexDirection: 'row',
},
})}
>
<VStack
className={css({
width: 'full',
maxWidth: '38rem',
margin: '0 auto',
alignItems: 'center',
flexShrink: '1',
})}
>
<Center
className={css({
width: '38rem',
maxWidth: 'full',
height: 'auto',
aspectRatio: '16 / 9',
background: 'gray.900',
color: 'white',
borderRadius: 16,
overflow: 'hidden',
})}
>
{videoTrack && settingsSnap.devices.enableCamera ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="608"
height="342"
className={css({
width: 'full',
height: 'auto',
})}
/>
) : (
settingsSnap.devices.enableCamera === false && (
<p>{t('cameraPlaceholder')}</p>
)
)}
</Center>
<HStack gap={1} justify="center" flexWrap={'wrap'}>
{/* audio output dropdown */}
<Menu>
<Button
tooltip={t('chooseSpeaker')}
aria-label={t('chooseSpeaker')}
>
<RiVolumeUpLine />
<RiArrowDropDownLine />
</Button>
<MenuList
items={speakerDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={settingsSnap.devices.speakerDeviceId}
onAction={(value) => {
settingsStore.devices.speakerDeviceId = value as string
}}
/>
</Menu>
{/* audio input toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableMic}
variant={
settingsSnap.devices.enableMic ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableMic = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('micIsOn')} ${t('toggleOff')}`
: `${t('micIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('micIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('micIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableMic ? (
<RiMicLine />
) : (
<RiMicOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseMic')}
aria-label={t('chooseMic')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={micDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeMicDeviceId}
onAction={(value) => {
setActiveMicDevice(value as string)
}}
/>
</Menu>
</HStack>
{/* video toggle + dropdown */}
<HStack gap={0}>
<ToggleButton
isSelected={settingsSnap.devices.enableCamera}
variant={
settingsSnap.devices.enableCamera ? undefined : 'danger'
}
toggledStyles={false}
onChange={(enabled) =>
(settingsStore.devices.enableCamera = enabled)
}
aria-label={
settingsSnap.devices.enableMic
? `${t('cameraIsOn')} ${t('toggleOff')}`
: `${t('cameraIsOff')} ${t('toggleOn')}`
}
tooltip={
settingsSnap.devices.enableMic ? (
<>
{t('cameraIsOn')}
<br />
{t('toggleOff')}
</>
) : (
<>
{t('cameraIsOff')}
<br />
{t('toggleOn')}
</>
)
}
groupPosition="left"
>
{settingsSnap.devices.enableCamera ? (
<RiVideoOnLine />
) : (
<RiVideoOffLine />
)}
</ToggleButton>
<Menu>
<Button
tooltip={t('chooseCamera')}
aria-label={t('chooseCamera')}
groupPosition="right"
square
>
<RiArrowDropDownLine />
</Button>
<MenuList
items={cameraDevices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeCameraDeviceId}
onAction={(value) => {
setActiveCameraDevice(value as string)
}}
/>
</Menu>
</HStack>
</HStack>
</VStack>
<Div width="24rem" maxWidth="full" flexShrink="1">
<VerticallyOffCenter>
<Center>
<H lvl={1}>{t('heading')}</H>
</Center>
<Form
onSubmit={(data) => {
settingsStore.username = (data.username as string).trim()
onSubmit({
devices: { ...settingsStore.devices },
username: settingsStore.username,
})
}}
submitLabel={t('joinMeeting')}
withSubmitButton={false}
>
<Field
type="text"
name="username"
defaultValue={settingsSnap.username}
label={t('usernameLabel')}
description={t('usernameHint')}
isRequired
/>
<Center>
<Button type="submit" variant="primary">
{t('joinMeeting')}
</Button>
</Center>
</Form>
</VerticallyOffCenter>
</Div>
</Div>
</VerticallyOffCenter>
</Screen>
)
}
@@ -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>
)
}
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { RiChat1Line } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
export const ChatToggle = () => {
const { t } = useTranslation('rooms')
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={t(`controls.chat.${tooltipLabel}`)}
tooltip={t(`controls.chat.${tooltipLabel}`)}
isSelected={isChatOpen}
onPress={() => toggleChat()}
>
<RiChat1Line />
</ToggleButton>
{!!unreadMessages && (
<div
className={css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1rem',
height: '1rem',
backgroundColor: 'red',
borderRadius: '50%',
zIndex: 1,
border: '2px solid #d1d5db',
})}
/>
)}
</div>
)
}
@@ -0,0 +1,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>
)
}
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { useState } from 'react'
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
export type DialogState = 'username' | 'settings' | null
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<Menu>
<Button
square
legacyStyle
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
>
<RiMore2Line />
</Button>
<OptionsMenuItems onOpenDialog={setDialogOpen} />
</Menu>
<SettingsDialogExtended
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}
@@ -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>
)
}
@@ -0,0 +1,118 @@
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { Heading } from 'react-aria-components'
import { Box, Button, Div, H } from '@/primitives'
import { text } from '@/primitives/Text'
import { RiCloseLine } from '@remixicon/react'
import { participantsStore } from '@/stores/participants'
import { useTranslation } from 'react-i18next'
import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events'
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 = () => {
const { t } = useTranslation('rooms')
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants({
updateOnlyOn: allParticipantRoomEvents,
})
const sortedRemoteParticipants = participants
.slice(1)
.sort((participantA, participantB) => {
const nameA = participantA.name || participantA.identity
const nameB = participantB.name || participantB.identity
return nameA.localeCompare(nameB)
})
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="360px"
className={css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
margin: '1.5rem 1.5rem 1.5rem 0',
padding: 0,
gap: 0,
})}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
}}
>
{t('participants.heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
size="xs"
onPress={() => (participantsStore.showParticipants = false)}
aria-label={t('participants.closeButton')}
tooltip={t('participants.closeButton')}
>
<RiCloseLine />
</Button>
</Div>
<Div overflowY="scroll">
<H
lvl={2}
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: '#5f6368',
padding: '0 1.5rem',
marginBottom: '0.83em',
})}
>
{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>
)
}
@@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useWidgetInteraction } from '../../../hooks/useWidgetInteraction'
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms')
/**
* Context could not be used due to inconsistent refresh behavior.
* The 'numParticipant' property on the room only updates when the room's metadata changes,
* resulting in a delay compared to the participant list's actual refresh rate.
*/
const participants = useParticipants()
const numParticipants = participants?.length
const { isParticipantsOpen, toggleParticipants } = useWidgetInteraction()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
legacyStyle
aria-label={t(`controls.participants.${tooltipLabel}`)}
tooltip={t(`controls.participants.${tooltipLabel}`)}
isSelected={isParticipantsOpen}
onPress={() => toggleParticipants()}
>
<RiGroupLine />
</ToggleButton>
<div
className={css({
position: 'absolute',
top: '-.25rem',
right: '-.25rem',
width: '1.25rem',
height: '1.25rem',
backgroundColor: 'gray',
borderRadius: '50%',
color: 'white',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
zIndex: 1,
userSelect: 'none',
})}
>
{numParticipants < 100 ? (
numParticipants || 1
) : (
<RiInfinityLine size={10} />
)}
</div>
</div>
)
}
@@ -0,0 +1,52 @@
import {
useRoomContext,
useStartAudio,
useStartVideo,
} from '@livekit/components-react'
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
/**
* The `StartMediaButton` component is only visible when the browser blocks media playback. This is due to some browser implemented autoplay policies.
* To start media playback, the user must perform a user-initiated event such as clicking this button.
* As soon as media playback starts, the button hides itself again.
*
* @example
* ```tsx
* <LiveKitRoom>
* <StartMediaButton label="Click to allow media playback" />
* </LiveKitRoom>
* ```
*
* @see Autoplay policy on MDN web docs: {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices#autoplay_policy}
* @public
*/
export const StartMediaButton: (
props: AllowMediaPlaybackProps & React.RefAttributes<HTMLButtonElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLButtonElement,
AllowMediaPlaybackProps
>(function StartMediaButton({ label, ...props }: AllowMediaPlaybackProps, ref) {
const room = useRoomContext()
const { mergedProps: audioProps, canPlayAudio } = useStartAudio({
room,
props,
})
const { mergedProps, canPlayVideo } = useStartVideo({
room,
props: audioProps,
})
const { style, ...restProps } = mergedProps
style.display = canPlayAudio && canPlayVideo ? 'none' : 'block'
return (
<button ref={ref} style={style} {...restProps}>
{label ?? `Start ${!canPlayAudio ? 'Audio' : 'Video'}`}
</button>
)
})
@@ -0,0 +1,33 @@
import { RoomEvent } from 'livekit-client'
// Issue: 'allRemoteParticipantRoomEvents' is not exposed or importable. One event is missing
// to trigger the real-time update of participants when they change their name.
// This code is duplicated from LiveKit.
export const allRemoteParticipantRoomEvents = [
RoomEvent.ConnectionStateChanged,
RoomEvent.RoomMetadataChanged,
RoomEvent.ActiveSpeakersChanged,
RoomEvent.ConnectionQualityChanged,
RoomEvent.ParticipantConnected,
RoomEvent.ParticipantDisconnected,
RoomEvent.ParticipantPermissionsChanged,
RoomEvent.ParticipantMetadataChanged,
RoomEvent.ParticipantNameChanged, // This element is missing in LiveKit and causes problems
RoomEvent.TrackMuted,
RoomEvent.TrackUnmuted,
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
RoomEvent.TrackStreamStateChanged,
RoomEvent.TrackSubscriptionFailed,
RoomEvent.TrackSubscriptionPermissionChanged,
RoomEvent.TrackSubscriptionStatusChanged,
]
export const allParticipantRoomEvents = [
...allRemoteParticipantRoomEvents,
RoomEvent.LocalTrackPublished,
RoomEvent.LocalTrackUnpublished,
]
@@ -0,0 +1,46 @@
import * as React from 'react'
/**
* Implementation used from https://github.com/juliencrn/usehooks-ts
*
* @internal
*/
export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => {
// Prevents SSR issues
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
}
const [matches, setMatches] = React.useState<boolean>(getMatches(query))
function handleChange() {
setMatches(getMatches(query))
}
React.useEffect(() => {
const matchMedia = window.matchMedia(query)
// Triggered at the first client-side load and if query changes
handleChange()
// Listen matchMedia
if (matchMedia.addListener) {
matchMedia.addListener(handleChange)
} else {
matchMedia.addEventListener('change', handleChange)
}
return () => {
if (matchMedia.removeListener) {
matchMedia.removeListener(handleChange)
} else {
matchMedia.removeEventListener('change', handleChange)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
return matches
}
@@ -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,
}
}
@@ -0,0 +1,198 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import {
DisconnectButton,
LeaveIcon,
MediaDeviceMenu,
TrackToggle,
useMaybeLayoutContext,
usePersistentUserChoices,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps.ts'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { useMediaQuery } from '../hooks/useMediaQuery'
import { useTranslation } from 'react-i18next'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enables the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
variation,
saveUserChoices = true,
onDeviceError,
...props
}: ControlBarProps) {
const { t } = useTranslation('rooms')
const [isChatOpen, setIsChatOpen] = React.useState(false)
const layoutContext = useMaybeLayoutContext()
React.useEffect(() => {
if (layoutContext?.widget.state?.showChat !== undefined) {
setIsChatOpen(layoutContext?.widget.state?.showChat)
}
}, [layoutContext?.widget.state?.showChat])
const isTooLittleSpace = useMediaQuery(
`(max-width: ${isChatOpen ? 1000 : 760}px)`
)
const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'
variation ??= defaultVariation
const showIcon = React.useMemo(
() => variation === 'minimal' || variation === 'verbose',
[variation]
)
const showText = React.useMemo(
() => variation === 'textOnly' || variation === 'verbose',
[variation]
)
const browserSupportsScreenSharing = supportsScreenSharing()
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false)
const onScreenShareChange = React.useCallback(
(enabled: boolean) => {
setIsScreenShareEnabled(enabled)
},
[setIsScreenShareEnabled]
)
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
return (
<div {...htmlProps}>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Microphone}
showIcon={showIcon}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
>
{showText && t('controls.microphone')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="audioinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
<div className="lk-button-group">
<TrackToggle
source={Track.Source.Camera}
showIcon={showIcon}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
>
{showText && t('controls.camera')}
</TrackToggle>
<div className="lk-button-group-menu">
<MediaDeviceMenu
kind="videoinput"
onActiveDeviceChange={(_kind, deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
</div>
</div>
{browserSupportsScreenSharing && (
<TrackToggle
source={Track.Source.ScreenShare}
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
showIcon={showIcon}
onChange={onScreenShareChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
>
{showText &&
t(
isScreenShareEnabled
? 'controls.stopScreenShare'
: 'controls.shareScreen'
)}
</TrackToggle>
)}
<HandToggle />
<ChatToggle />
<ParticipantsToggle />
<OptionsButton />
<DisconnectButton>
{showIcon && <LeaveIcon />}
{showText && t('controls.leave')}
</DisconnectButton>
<StartMediaButton />
</div>
)
}
@@ -0,0 +1,238 @@
import type {
MessageDecoder,
MessageEncoder,
TrackReferenceOrPlaceholder,
WidgetState,
} from '@livekit/components-core'
import {
isEqualTrackRef,
isTrackReference,
isWeb,
log,
} from '@livekit/components-core'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import {
CarouselLayout,
ConnectionStateToast,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
RoomAudioRenderer,
MessageFormatter,
usePinnedTracks,
useTracks,
useCreateLayoutContext,
Chat,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList'
import { useSnapshot } from 'valtio'
import { participantsStore } from '@/stores/participants'
import { 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',
cva({
base: {
position: 'relative',
display: 'flex',
width: '100%',
height: 'calc(100% - var(--lk-control-bar-height))',
},
})
)
/**
* @public
*/
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
chatMessageEncoder?: MessageEncoder
chatMessageDecoder?: MessageDecoder
/** @alpha */
SettingsComponent?: React.ComponentType
}
/**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
* of participants, basic non-persistent chat, screen sharing, and more.
*
* @remarks
* The component is implemented with other LiveKit components like `FocusContextProvider`,
* `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
* You can use this components as a starting point for your own custom video conferencing application.
*
* @example
* ```tsx
* <LiveKitRoom>
* <VideoConference />
* <LiveKitRoom>
* ```
* @public
*/
export function VideoConference({
chatMessageFormatter,
chatMessageDecoder,
chatMessageEncoder,
...props
}: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({
showChat: false,
unreadMessages: 0,
showSettings: false,
})
const lastAutoFocusedScreenShareTrack =
React.useRef<TrackReferenceOrPlaceholder | null>(null)
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const widgetUpdate = (state: WidgetState) => {
log.debug('updating widget state', state)
setWidgetState(state)
}
const layoutContext = useCreateLayoutContext()
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, focusTrack)
)
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
React.useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: screenShareTracks[0],
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: screenShareTracks[0],
})
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) =>
track.publication.trackSid ===
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
)
) {
log.debug('Auto clearing screen share focus.')
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
lastAutoFocusedScreenShareTrack.current = null
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) =>
tr.participant.identity === focusTrack.participant.identity &&
tr.source === focusTrack.source
)
if (
updatedFocusTrack !== focusTrack &&
isTrackReference(updatedFocusTrack)
) {
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: updatedFocusTrack,
})
}
}
}, [
screenShareTracks
.map(
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
)
.join(),
focusTrack?.publication?.trackSid,
tracks,
])
/* eslint-enable react-hooks/exhaustive-deps */
const participantsSnap = useSnapshot(participantsStore)
const showParticipants = participantsSnap.showParticipants
return (
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<div className="lk-video-conference-inner">
<RecordingIndicator />
<LayoutWrapper>
<div
style={{ display: 'flex', position: 'relative', width: '100%' }}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
<MainNotificationToast />
</div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
{showParticipants && <ParticipantsList />}
</LayoutWrapper>
<ControlBar />
</div>
</LayoutContextProvider>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
)
}
+23 -15
View File
@@ -1,29 +1,37 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { HomemadeJoin } from '../components/HomemadeJoin'
import { settingsStore, type SettingsState } from '@/features/settings'
import { useSnapshot } from 'valtio'
import { Join } from '../components/Join'
export const Room = () => {
const { isLoggedIn } = useUser()
const settingsSnap = useSnapshot(settingsStore)
const existingUserConfig = {
username: settingsSnap.username,
devices: settingsSnap.devices,
}
const [userConfig, setUserConfig] = useState<null | {
username: SettingsState['username']
devices: SettingsState['devices']
}>(null)
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
const clearRouterState = () => {
if (window?.history?.state) {
window.history.replaceState({}, '')
}
}
useEffect(() => {
window.addEventListener('beforeunload', clearRouterState)
return () => {
window.removeEventListener('beforeunload', clearRouterState)
}
}, [])
if (!roomId) {
return <ErrorScreen />
}
@@ -31,7 +39,7 @@ export const Room = () => {
if (!userConfig && !skipJoinScreen) {
return (
<UserAware>
<HomemadeJoin onSubmit={setUserConfig} />
<Join onSubmit={setUserConfig} />
</UserAware>
)
}
@@ -43,7 +51,7 @@ export const Room = () => {
roomId={roomId}
mode={mode}
userConfig={{
...existingUserConfig,
...existingUserChoices,
...userConfig,
}}
/>
@@ -0,0 +1,11 @@
import { Participant } from 'livekit-client'
export const getParticipantColor = (
participant: Participant
): undefined | string => {
const { metadata } = participant
if (!metadata) {
return
}
return JSON.parse(metadata)['color']
}
@@ -1,12 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { Dialog, Field, H } from '@/primitives'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
export const SettingsDialog = () => {
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
export const SettingsDialog = (props: SettingsDialogProps) => {
const { t, i18n } = useTranslation('settings')
const { user, isLoggedIn } = useUser()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Dialog title={t('dialog.heading')}>
<Dialog title={t('dialog.heading')} {...props}>
<H lvl={2}>{t('account.heading')}</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>
</>
)}
<H lvl={2}>{t('language.heading')}</H>
<Field
type="select"
@@ -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,4 +1,2 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
export { settingsStore } from './stores/settings'
export { type SettingsState } from './stores/settings'
@@ -1,50 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { devtools } from 'valtio/utils'
export type SettingsState = {
username: string | undefined
devices: {
/**
* MediaDeviceInfo id
*/
speakerDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
micDeviceId: string | undefined
/**
* MediaDeviceInfo id
*/
cameraDeviceId: string | undefined
enableMic: boolean
enableCamera: boolean
}
}
// sync the valtio store with localstorage data
// @TODO: make it easier to have "persisted" stores as we will definitely use it quite often
const localData = localStorage.getItem('meet.settings')
export const settingsStore = proxy<SettingsState>(
localData
? JSON.parse(localData)
: {
username: undefined,
devices: {
speakerDeviceId: undefined,
micDeviceId: undefined,
cameraDeviceId: undefined,
enableMic: false,
enableCamera: false,
},
}
)
subscribe(settingsStore, () => {
localStorage.setItem('meet.settings', JSON.stringify(settingsStore))
})
if (import.meta.env.DEV) {
devtools(settingsStore, { name: 'settings', enabled: true })
}
+5 -1
View File
@@ -7,11 +7,15 @@
"heading": ""
},
"feedbackAlert": "",
"forbidden": {
"heading": ""
},
"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": ""
}
}
+53 -16
View File
@@ -4,36 +4,73 @@
"heading": ""
},
"join": {
"cameraIsOff": "",
"cameraIsOn": "",
"cameraPlaceholder": "",
"camlabel": "",
"chooseCamera": "",
"chooseMic": "",
"chooseSpeaker": "",
"heading": "",
"joinLabel": "",
"joinMeeting": "",
"micIsOff": "",
"micIsOn": "",
"micLabel": "",
"toggleOff": "",
"toggleOn": "",
"userLabel": "",
"usernameHint": "",
"usernameLabel": ""
"userLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
"copied": "",
"copy": "",
"copied": "",
"heading": "",
"inputLabel": ""
"description": "",
"permissions": ""
},
"error": {
"createRoom": {
"heading": "",
"body": ""
}
},
"controls": {
"microphone": "",
"camera": "",
"shareScreen": "",
"stopScreenShare": "",
"chat": {
"open": "",
"closed": ""
},
"hand": {
"raise": "",
"lower": ""
},
"leave": "",
"participants": {
"open": "",
"closed": ""
}
},
"options": {
"buttonLabel": "",
"items": {
"feedbacks": "",
"support": "",
"settings": "",
"username": ""
}
},
"participants": {
"heading": "",
"subheading": "",
"closeButton": "",
"contributors": "",
"collapsable": {
"open": "",
"close": ""
},
"you": "",
"muteYourself": "",
"muteParticipant": "",
"muteParticipantAlert": {
"description": "",
"confirm": "",
"cancel": ""
},
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
}
}
+26 -1
View File
@@ -1,4 +1,24 @@
{
"account": {
"currentlyLoggedAs": "",
"heading": "",
"youAreNotLoggedIn": "",
"nameLabel": "",
"authentication": ""
},
"audio": {
"microphone": {
"heading": "",
"label": ""
},
"speakers": {
"heading": "",
"label": "",
"test": "",
"ongoingTest": ""
},
"permissionsRequired": ""
},
"dialog": {
"heading": ""
},
@@ -6,5 +26,10 @@
"heading": "",
"label": ""
},
"settingsButtonLabel": ""
"settingsButtonLabel": "",
"tabs": {
"account": "",
"audio": "",
"general": ""
}
}
+5 -1
View File
@@ -7,11 +7,15 @@
"heading": "An error occured while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
"heading": "You don't have the permission to view this page"
},
"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"
}
}
+59 -22
View File
@@ -4,36 +4,73 @@
"heading": "Help us improve Meet"
},
"join": {
"cameraIsOff": "Camera is off.",
"cameraIsOn": "Camera is on.",
"cameraPlaceholder": "Turn on the camera to see the preview",
"camlabel": "",
"chooseCamera": "Select camera",
"chooseMic": "Select microphone",
"chooseSpeaker": "Select speakers",
"heading": "Verify your settings",
"joinLabel": "",
"joinMeeting": "Join meeting",
"micIsOff": "Microphone is off.",
"micIsOn": "Microphone is on.",
"micLabel": "",
"toggleOff": "Click to turn off",
"toggleOn": "Click to turn on",
"userLabel": "",
"usernameHint": "Shown to other participants",
"usernameLabel": "Your name"
"camlabel": "Camera",
"heading": "Join the meeting",
"joinLabel": "Join",
"micLabel": "Microphone",
"userLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copied": "Copied",
"copy": "Copy",
"heading": "Share the meeting link",
"inputLabel": "Meeting link"
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting."
},
"error": {
"createRoom": {
"heading": "Authentication Required",
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
}
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Share screen",
"stopScreenShare": "Stop screen share",
"chat": {
"open": "Close the chat",
"closed": "Open the chat"
},
"hand": {
"raise": "Raise hand",
"lower": "Lower hand"
},
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone"
}
},
"options": {
"buttonLabel": "More Options",
"items": {
"feedbacks": "Give us feedbacks",
"support": "Get Help on Tchap",
"settings": "Settings",
"username": "Update Your Name"
}
},
"participants": {
"heading": "Participants",
"subheading": "In room",
"closeButton": "Hide participants",
"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"
}
}
+26 -1
View File
@@ -1,4 +1,24 @@
{
"account": {
"currentlyLoggedAs": "You are currently logged in as <0>{{user}}</0>",
"heading": "Account",
"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"
},
@@ -6,5 +26,10 @@
"heading": "Language",
"label": "Language"
},
"settingsButtonLabel": "Settings"
"settingsButtonLabel": "Settings",
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "General"
}
}
+5 -1
View File
@@ -7,11 +7,15 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedbackAlert": "Donnez-nous votre avis",
"forbidden": {
"heading": "Accès interdit"
},
"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"
}
}
+59 -22
View File
@@ -4,36 +4,73 @@
"heading": "Aidez-nous à améliorer Meet"
},
"join": {
"cameraIsOff": "Webcam coupée.",
"cameraIsOn": "Webcam activée.",
"cameraPlaceholder": "Activez la webcam pour prévisualiser l'affichage",
"camlabel": "",
"chooseCamera": "Choisir la webcam",
"chooseMic": "Choisir le micro",
"chooseSpeaker": "Choisir la sortie audio",
"heading": "Vérifiez vos paramètres",
"joinLabel": "",
"joinMeeting": "Rejoindre la réjoindre",
"micIsOff": "Micro coupé.",
"micIsOn": "Micro activé.",
"micLabel": "",
"toggleOff": "Cliquez pour désactiver",
"toggleOn": "Cliquez pour activer",
"userLabel": "",
"usernameHint": "Affiché aux autres participants",
"usernameLabel": "Votre nom"
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"micLabel": "Micro",
"userLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copied": "Lien copié",
"copy": "Copier le lien",
"heading": "Partager le lien vers la réunion",
"inputLabel": "Lien vers la réunion"
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"heading": "Votre réunion est prête",
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"error": {
"createRoom": {
"heading": "Authentification requise",
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
}
},
"controls": {
"microphone": "Microphone",
"camera": "Camera",
"shareScreen": "Partager l'écran",
"stopScreenShare": "Arrêter le partage",
"chat": {
"open": "Masquer le chat",
"closed": "Afficher le chat"
},
"hand": {
"raise": "Lever la main",
"lower": "Baisser la main"
},
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants"
}
},
"options": {
"buttonLabel": "Plus d'options",
"items": {
"feedbacks": "Partager votre avis",
"support": "Obtenir de l'aide sur Tchap",
"settings": "Paramètres",
"username": "Choisir votre nom"
}
},
"participants": {
"heading": "Participants",
"subheading": "Dans la réunion",
"closeButton": "Masquer les participants",
"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"
}
}
+26 -1
View File
@@ -1,4 +1,24 @@
{
"account": {
"currentlyLoggedAs": "Vous êtes actuellement connecté en tant que <0>{{user}}</0>",
"heading": "Compte",
"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"
},
@@ -6,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
View File
@@ -31,6 +31,7 @@ export const Button = ({
</TooltipWrapper>
)
}
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
+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="0" right="0">
<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,12 +0,0 @@
import { ReactNode, useContext } from 'react'
import { OverlayTriggerStateContext } from 'react-aria-components'
/**
* Small helper you can use as a wrapper of a <Dialog> component if you want to make sure it is not rendered when it is closed.
*
* Not required all the time, it's mostly helpful to avoid calling hooks of a child component that uses a Dialog.
*/
export const DialogContainer = ({ children }: { children: ReactNode }) => {
const state = useContext(OverlayTriggerStateContext)!
return state.isOpen ? children : null
}
+1
View File
@@ -23,6 +23,7 @@ import { Div } from './Div'
const FieldWrapper = styled('div', {
base: {
marginBottom: 'textfield',
minWidth: 0,
},
})

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