From 16321b3cb0dcbd83cd88876574a3484e8b9970f1 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 12 Aug 2024 16:24:19 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7(frontend)=20introduce=20a=20partic?= =?UTF-8?q?ipants=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Participants/ParticipantsList.tsx | 160 ++++++++++++++++++ .../Participants/ParticipantsToggle.tsx | 12 +- .../rooms/livekit/constants/events.ts | 33 ++++ .../rooms/livekit/prefabs/ControlBar.tsx | 2 + .../rooms/livekit/prefabs/VideoConference.tsx | 7 + src/frontend/src/locales/de/rooms.json | 5 + src/frontend/src/locales/en/rooms.json | 5 + src/frontend/src/locales/fr/rooms.json | 5 + src/frontend/src/primitives/Button.tsx | 1 + src/frontend/src/stores/participants.ts | 9 + src/frontend/src/utils/capitalize.ts | 7 + 11 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx create mode 100644 src/frontend/src/features/rooms/livekit/constants/events.ts create mode 100644 src/frontend/src/stores/participants.ts create mode 100644 src/frontend/src/utils/capitalize.ts diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx new file mode 100644 index 00000000..820bfa23 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx @@ -0,0 +1,160 @@ +import { css } from '@/styled-system/css' +import * as React from 'react' +import { useParticipants } from '@livekit/components-react' + +import { Heading } from 'react-aria-components' +import { Box, Button, Div } from '@/primitives' +import { HStack, VStack } from '@/styled-system/jsx' +import { Text, text } from '@/primitives/Text' +import { RiCloseLine } from '@remixicon/react' +import { capitalize } from '@/utils/capitalize' +import { participantsStore } from '@/stores/participants' +import { useTranslation } from 'react-i18next' +import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events' + +export type AvatarProps = React.HTMLAttributes & { + name: string + size?: number +} + +// TODO - extract inline styling in a centralized styling file, and avoid magic numbers +export const Avatar = ({ name, size = 32 }: AvatarProps) => ( +
+ {name?.trim()?.charAt(0).toUpperCase()} +
+) + +// 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 formattedParticipants = participants.map((participant) => ({ + name: participant.name || participant.identity, + id: participant.identity, + })) + + const sortedRemoteParticipants = formattedParticipants + .slice(1) + .sort((a, b) => a.name.localeCompare(b.name)) + + const allParticipants = [ + formattedParticipants[0], // first participant returned by the hook, is always the local one + ...sortedRemoteParticipants, + ] + + // TODO - extract inline styling in a centralized styling file, and avoid magic numbers + return ( + + + {t('participants.heading')}{' '} + + {participants?.length} + + +
+ +
+ {participants?.length && ( + + {allParticipants.map((participant, index) => ( + + + + + {capitalize(participant.name)} + + {index === 0 && ( + + ({t('participants.you')}) + + )} + + + ))} + + )} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx index bb3610d0..d8049b46 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsToggle.tsx @@ -3,7 +3,8 @@ import { RiGroupLine, RiInfinityLine } from '@remixicon/react' import { Button } from '@/primitives' import { css } from '@/styled-system/css' import { useParticipants } from '@livekit/components-react' -import { useState } from 'react' +import { useSnapshot } from 'valtio' +import { participantsStore } from '@/stores/participants' export const ParticipantsToggle = () => { const { t } = useTranslation('rooms') @@ -16,8 +17,10 @@ export const ParticipantsToggle = () => { const participants = useParticipants() const numParticipants = participants?.length - const [isOpen, setIsOpen] = useState(false) - const tooltipLabel = isOpen ? 'open' : 'closed' + const participantsSnap = useSnapshot(participantsStore) + const showParticipants = participantsSnap.showParticipants + + const tooltipLabel = showParticipants ? 'open' : 'closed' return (
{ legacyStyle aria-label={t(`controls.participants.${tooltipLabel}`)} tooltip={t(`controls.participants.${tooltipLabel}`)} - onPress={() => setIsOpen(!isOpen)} + isSelected={showParticipants} + onPress={() => (participantsStore.showParticipants = !showParticipants)} > diff --git a/src/frontend/src/features/rooms/livekit/constants/events.ts b/src/frontend/src/features/rooms/livekit/constants/events.ts new file mode 100644 index 00000000..1d1c6a87 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/constants/events.ts @@ -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, +] diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx index e34b3209..d609fd29 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx @@ -19,6 +19,7 @@ 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.tsx' /** @public */ export type ControlBarControls = { @@ -187,6 +188,7 @@ export function ControlBar({ {showIcon && } {showText && t('controls.chat')} + {showIcon && } diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 907fd4b4..5a48642a 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -32,6 +32,9 @@ import { import { ControlBar } from './ControlBar' import { styled } from '@/styled-system/jsx' import { cva } from '@/styled-system/css' +import { ParticipantsList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsList' +import { useSnapshot } from 'valtio' +import { participantsStore } from '@/stores/participants' const LayoutWrapper = styled( 'div', @@ -168,6 +171,9 @@ export function VideoConference({ ]) /* eslint-enable react-hooks/exhaustive-deps */ + const participantsSnap = useSnapshot(participantsStore) + const showParticipants = participantsSnap.showParticipants + return (
{isWeb() && ( @@ -203,6 +209,7 @@ export function VideoConference({ messageEncoder={chatMessageEncoder} messageDecoder={chatMessageDecoder} /> + {showParticipants && }
diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 208733a1..945cbd39 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -50,5 +50,10 @@ "validationError": "", "submitLabel": "" } + }, + "participants": { + "heading": "", + "closeButton": "", + "you": "" } } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 016d8644..b150ac9b 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -50,5 +50,10 @@ "validationError": "Name cannot be empty.", "submitLabel": "Save" } + }, + "participants": { + "heading": "Participants", + "closeButton": "Hide participants", + "you": "You" } } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 06fee641..1aefe69e 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -50,5 +50,10 @@ "validationError": "Le nom ne peut pas ĂȘtre vide.", "submitLabel": "Enregistrer" } + }, + "participants": { + "heading": "Participants", + "closeButton": "Masquer les participants", + "you": "Vous" } } diff --git a/src/frontend/src/primitives/Button.tsx b/src/frontend/src/primitives/Button.tsx index 8abd742d..be5c5f3d 100644 --- a/src/frontend/src/primitives/Button.tsx +++ b/src/frontend/src/primitives/Button.tsx @@ -142,6 +142,7 @@ export type ButtonProps = RecipeVariantProps & RACButtonsProps & Tooltip & { toggle?: boolean + isSelected?: boolean } type LinkButtonProps = RecipeVariantProps & LinkProps & Tooltip diff --git a/src/frontend/src/stores/participants.ts b/src/frontend/src/stores/participants.ts new file mode 100644 index 00000000..0fdc7ed9 --- /dev/null +++ b/src/frontend/src/stores/participants.ts @@ -0,0 +1,9 @@ +import { proxy } from 'valtio' + +type State = { + showParticipants: boolean +} + +export const participantsStore = proxy({ + showParticipants: false, +}) diff --git a/src/frontend/src/utils/capitalize.ts b/src/frontend/src/utils/capitalize.ts new file mode 100644 index 00000000..78834f42 --- /dev/null +++ b/src/frontend/src/utils/capitalize.ts @@ -0,0 +1,7 @@ +export function capitalize(string: string) { + if (!string) { + return string + } + const trimmed = string.trim() + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +}