From 25a013ae9b0ffb0ec536f8f2f893b5819c896dce Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 17 Jul 2026 21:19:25 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20reduce=20unneces?= =?UTF-8?q?sary=20re-renders=20in=20the=20participant=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut down the number of unnecessary re-renders happening in the participant panel by narrowing subscriptions and isolating state to the components that actually depend on it. --- ...icipantListItem.tsx => ParticipantRow.tsx} | 4 +- .../ParticipantsCollapsableList.tsx | 110 ------------ .../ParticipantsCollapsibleSection.tsx | 93 ++++++++++ .../components/ParticipantsList.tsx | 163 +++++++++--------- ...ndRaisedListItem.tsx => RaisedHandRow.tsx} | 50 +++--- ...ListItem.tsx => WaitingParticipantRow.tsx} | 2 +- .../components/WaitingParticipantsSection.tsx | 28 +++ 7 files changed, 230 insertions(+), 220 deletions(-) rename src/frontend/src/features/participants/components/{ParticipantListItem.tsx => ParticipantRow.tsx} (98%) delete mode 100644 src/frontend/src/features/participants/components/ParticipantsCollapsableList.tsx create mode 100644 src/frontend/src/features/participants/components/ParticipantsCollapsibleSection.tsx rename src/frontend/src/features/participants/components/{HandRaisedListItem.tsx => RaisedHandRow.tsx} (71%) rename src/frontend/src/features/participants/components/{WaitingParticipantListItem.tsx => WaitingParticipantRow.tsx} (98%) create mode 100644 src/frontend/src/features/participants/components/WaitingParticipantsSection.tsx diff --git a/src/frontend/src/features/participants/components/ParticipantListItem.tsx b/src/frontend/src/features/participants/components/ParticipantRow.tsx similarity index 98% rename from src/frontend/src/features/participants/components/ParticipantListItem.tsx rename to src/frontend/src/features/participants/components/ParticipantRow.tsx index bf4a8256..78233a47 100644 --- a/src/frontend/src/features/participants/components/ParticipantListItem.tsx +++ b/src/frontend/src/features/participants/components/ParticipantRow.tsx @@ -93,9 +93,7 @@ type ParticipantListItemProps = { participant: Participant } -export const ParticipantListItem = ({ - participant, -}: ParticipantListItemProps) => { +export const ParticipantRow = ({ participant }: ParticipantListItemProps) => { const { t } = useTranslation('rooms') const name = participant.name || participant.identity return ( diff --git a/src/frontend/src/features/participants/components/ParticipantsCollapsableList.tsx b/src/frontend/src/features/participants/components/ParticipantsCollapsableList.tsx deleted file mode 100644 index a0c3d13a..00000000 --- a/src/frontend/src/features/participants/components/ParticipantsCollapsableList.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { ReactNode, 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 { 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: 'greyscale.50', - }, - }, -}) - -const Container = styled('div', { - base: { - border: '1px solid', - borderColor: 'greyscale.250', - borderRadius: '8px', - margin: '0 .625rem', - }, -}) - -const ListContainer = styled(VStack, { - base: { - borderTop: '1px solid', - borderTopColor: 'greyscale.250', - alignItems: 'start', - overflowY: 'scroll', - overflowX: 'hidden', - minHeight: 0, - flexGrow: 1, - display: 'flex', - paddingY: '0.5rem', - paddingX: '1rem', - gap: 0, - }, -}) - -export type ParticipantsCollapsableListProps = { - heading: string - participants: Array - renderParticipant: (participant: T) => JSX.Element - action?: ReactNode -} - -export function 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 ( - - setIsOpen(!isOpen)} - style={{ - borderRadius: !isOpen ? '7px' : undefined, - }} - > - -
- {heading} -
-
{participants?.length || 0}
-
- -
- {isOpen && ( - - {action} - {participants.map((participant) => renderParticipant(participant))} - - )} -
- ) -} diff --git a/src/frontend/src/features/participants/components/ParticipantsCollapsibleSection.tsx b/src/frontend/src/features/participants/components/ParticipantsCollapsibleSection.tsx new file mode 100644 index 00000000..025495aa --- /dev/null +++ b/src/frontend/src/features/participants/components/ParticipantsCollapsibleSection.tsx @@ -0,0 +1,93 @@ +import { ReactNode, useId, useState } from 'react' +import { RiArrowUpSLine } from '@remixicon/react' +import { styled, HStack, VStack } from '@/styled-system/jsx' + +const Container = styled('div', { + base: { + border: '1px solid', + borderColor: 'greyscale.250', + borderRadius: '8px', + margin: '0 .625rem 0.9375rem', + }, +}) + +const Header = styled('button', { + base: { + minHeight: '2.5rem', + paddingX: '1.25rem', + paddingY: '0.5rem', + gap: '0.5rem', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + fontSize: '1rem', + transition: 'background 200ms', + borderTopRadius: '7px', // container radius (8) minus its 1px border + _hover: { backgroundColor: 'greyscale.50' }, + }, + variants: { + isOpen: { false: { borderRadius: '7px' } }, + }, +}) + +const Chevron = styled(RiArrowUpSLine, { + base: { transition: 'transform 200ms', flexShrink: 0 }, + variants: { + isOpen: { false: { transform: 'rotate(180deg)' } }, + }, +}) + +const List = styled(VStack, { + base: { + borderTop: '1px solid', + borderTopColor: 'greyscale.250', + alignItems: 'start', + minHeight: 0, + flexGrow: 1, + paddingY: '0.5rem', + paddingX: '1rem', + gap: 0, + }, +}) + +export type ParticipantsCollapsibleSectionProps = { + heading: string + count: number + action?: ReactNode + children: ReactNode +} + +export const ParticipantsCollapsibleSection = ({ + heading, + count, + action, + children, +}: ParticipantsCollapsibleSectionProps) => { + const [isOpen, setIsOpen] = useState(true) + const listId = useId() + return ( + +
setIsOpen((open) => !open)} + > + + {heading} + {count} + + +
+ {isOpen && ( + + {action} + {children} + + )} +
+ ) +} diff --git a/src/frontend/src/features/participants/components/ParticipantsList.tsx b/src/frontend/src/features/participants/components/ParticipantsList.tsx index ebeefa37..57c8e843 100644 --- a/src/frontend/src/features/participants/components/ParticipantsList.tsx +++ b/src/frontend/src/features/participants/components/ParticipantsList.tsx @@ -1,54 +1,87 @@ import { css } from '@/styled-system/css' -import { useParticipants } from '@livekit/components-react' - +import { + useRemoteParticipants, + useRoomContext, +} from '@livekit/components-react' +import { RoomEvent } from 'livekit-client' import { Div, H } from '@/primitives' import { useTranslation } from 'react-i18next' -import { ParticipantListItem } from './ParticipantListItem' -import { ParticipantsCollapsableList } from './ParticipantsCollapsableList' -import { HandRaisedListItem } from './HandRaisedListItem' +import { ParticipantRow } from './ParticipantRow' +import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection' import { LowerAllHandsButton } from './LowerAllHandsButton' -import { WaitingParticipantListItem } from './WaitingParticipantListItem' -import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants' -import type { Participant } from 'livekit-client' -import { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants' import { MuteEveryoneButton } from './MuteEveryoneButton' +import { WaitingParticipantsSection } from './WaitingParticipantsSection' +import { RaisedHandRow } from './RaisedHandRow' + +const JoinedParticipantsSections = () => { + const room = useRoomContext() + const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) + + // `updateOnlyOn` filters on the room event *type*, not on which participant + // the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged` + // are room-wide and fire for the local participant too, so this hook re-emits + // (and we re-render) on local changes, despite its name. + // + // That is deliberate here: we prepend `room.localParticipant` to the list below, + // and its hand-raise state comes from `attributes.handRaisedAt`. Without the + // local events waking this component up, raising your own hand would not + // update the UI. Do not "optimise" these away. + const remoteParticipants = useRemoteParticipants({ + updateOnlyOn: [ + RoomEvent.ParticipantNameChanged, + RoomEvent.ParticipantAttributesChanged, + ], + }) + + const allParticipants = [ + room.localParticipant, + ...[...remoteParticipants].sort((a, b) => + (a.name || a.identity).localeCompare(b.name || b.identity) + ), + ] + + const participantsWithRaisedHands = allParticipants + .filter((p) => !!p.attributes.handRaisedAt) + .sort((a, b) => { + const raisedAtA = Date.parse(a.attributes.handRaisedAt!) + const raisedAtB = Date.parse(b.attributes.handRaisedAt!) + return ( + (Number.isNaN(raisedAtA) ? Infinity : raisedAtA) - + (Number.isNaN(raisedAtB) ? Infinity : raisedAtB) + ) + }) + + return ( + <> + {participantsWithRaisedHands.length > 0 && ( + + } + > + {participantsWithRaisedHands.map((p) => ( + + ))} + + )} + } + > + {allParticipants.map((p) => ( + + ))} + + + ) +} -// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. export const ParticipantsList = () => { const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) - // Preferred using the 'useParticipants' hook rather than the separate remote and local hooks, - // because the 'useLocalParticipant' hook does not update the participant's information when their - // metadata/name changes. The LiveKit team has marked this as a TODO item in the code. - const participants = useParticipants() - - 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) => !!participant.attributes.handRaisedAt) - .sort((a, b) => { - const dateA = new Date(a.attributes.handRaisedAt) - const dateB = new Date(b.attributes.handRaisedAt) - const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime() - const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime() - return timeA - timeB - }) - - const { waitingParticipants, handleParticipantEntry } = - useWaitingParticipants() - - // TODO - extract inline styling in a centralized styling file, and avoid magic numbers return (
{ > {t('subheading').toUpperCase()} - {waitingParticipants?.length > 0 && ( -
- - heading={t('waiting.title')} - participants={waitingParticipants} - renderParticipant={(participant) => ( - - )} - action={<>} - /> -
- )} - {raisedHandParticipants.length > 0 && ( -
- - heading={t('raisedHands')} - participants={raisedHandParticipants} - renderParticipant={(participant) => ( - - )} - action={ - - } - /> -
- )} - - heading={t('contributors')} - participants={sortedParticipants} - renderParticipant={(participant) => ( - - )} - action={} - /> + +
) } diff --git a/src/frontend/src/features/participants/components/HandRaisedListItem.tsx b/src/frontend/src/features/participants/components/RaisedHandRow.tsx similarity index 71% rename from src/frontend/src/features/participants/components/HandRaisedListItem.tsx rename to src/frontend/src/features/participants/components/RaisedHandRow.tsx index b3987ca8..65572220 100644 --- a/src/frontend/src/features/participants/components/HandRaisedListItem.tsx +++ b/src/frontend/src/features/participants/components/RaisedHandRow.tsx @@ -6,25 +6,45 @@ import { useTranslation } from 'react-i18next' import { Avatar } from '@/components/Avatar' import { useLowerHandParticipant } from '../api/lowerHandParticipant' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' -import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import type { Participant } from 'livekit-client' import { isLocal } from '@/utils/livekit' import { RiHand } from '@remixicon/react' import { Button } from '@/primitives' +import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly' + +const ActionButton = ({ + participant, + name, +}: { + participant: Participant + name: string +}) => { + const { t } = useTranslation('rooms') + const { lowerHandParticipant } = useLowerHandParticipant() + + return ( + + ) +} type HandRaisedListItemProps = { participant: Participant } -export const HandRaisedListItem = ({ - participant, -}: HandRaisedListItemProps) => { +export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => { const { t } = useTranslation('rooms') const name = participant.name || participant.identity - const { lowerHandParticipant } = useLowerHandParticipant() - const isAdminOrOwner = useIsAdminOrOwner() - return ( - {isAdminOrOwner && ( - - )} + + + ) } diff --git a/src/frontend/src/features/participants/components/WaitingParticipantListItem.tsx b/src/frontend/src/features/participants/components/WaitingParticipantRow.tsx similarity index 98% rename from src/frontend/src/features/participants/components/WaitingParticipantListItem.tsx rename to src/frontend/src/features/participants/components/WaitingParticipantRow.tsx index 6da75eab..66cdeaeb 100644 --- a/src/frontend/src/features/participants/components/WaitingParticipantListItem.tsx +++ b/src/frontend/src/features/participants/components/WaitingParticipantRow.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next' import { WaitingParticipant } from '../api/listWaitingParticipants' import { RiCloseLine } from '@remixicon/react' -export const WaitingParticipantListItem = ({ +export const WaitingParticipantRow = ({ participant, onAction, }: { diff --git a/src/frontend/src/features/participants/components/WaitingParticipantsSection.tsx b/src/frontend/src/features/participants/components/WaitingParticipantsSection.tsx new file mode 100644 index 00000000..59eff5be --- /dev/null +++ b/src/frontend/src/features/participants/components/WaitingParticipantsSection.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection' +import { WaitingParticipantRow } from './WaitingParticipantRow' +import { useWaitingParticipants } from '../hooks/useWaitingParticipants' + +export const WaitingParticipantsSection = () => { + const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' }) + + const { waitingParticipants, handleParticipantEntry } = + useWaitingParticipants() + + if (waitingParticipants?.length == 0) return + + return ( + + {waitingParticipants.map((p) => ( + + ))} + + ) +}