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) +}