From da86b304556ef4451f127bb9c08f0538d645fe49 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 28 Aug 2025 12:27:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20participant=20menu?= =?UTF-8?q?=20with=20pin/unpin=20and=20admin=20remove=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce participant menu in participant list to enable more participant actions and interactions beyond current capabilities. Initialize menu with universal pin/unpin track action available to all users, plus admin-restricted participant removal action. Completes admin action set by enabling room ejection functionality. Menu designed for reuse when called from participant tile focus components, providing consistent interaction patterns across different contexts where participant management is needed. --- .../ParticipantMenu/ParticipantMenu.tsx | 24 ++++++++++++ .../ParticipantMenu/ParticipantMenuButton.tsx | 30 +++++++++++++++ .../ParticipantMenu/PinMenuItem.tsx | 37 +++++++++++++++++++ .../ParticipantMenu/RemoveMenuItem.tsx | 28 ++++++++++++++ .../Participants/ParticipantListItem.tsx | 2 + .../hooks/useFocusToggleParticipant.ts | 32 ++++++++++++++++ src/frontend/src/locales/de/rooms.json | 15 ++++++++ src/frontend/src/locales/en/rooms.json | 15 ++++++++ src/frontend/src/locales/fr/rooms.json | 15 ++++++++ src/frontend/src/locales/nl/rooms.json | 15 ++++++++ 10 files changed, 213 insertions(+) create mode 100644 src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenu.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenuButton.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/ParticipantMenu/PinMenuItem.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/ParticipantMenu/RemoveMenuItem.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenu.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenu.tsx new file mode 100644 index 00000000..513481a0 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenu.tsx @@ -0,0 +1,24 @@ +import { Menu as RACMenu } from 'react-aria-components' +import { Participant } from 'livekit-client' +import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import { PinMenuItem } from './PinMenuItem' +import { RemoveMenuItem } from './RemoveMenuItem' + +export const ParticipantMenu = ({ + participant, +}: { + participant: Participant +}) => { + const isAdminOrOwner = useIsAdminOrOwner() + const canModerateParticipant = !participant.isLocal && isAdminOrOwner + return ( + + + {canModerateParticipant && } + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenuButton.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenuButton.tsx new file mode 100644 index 00000000..900fd2a8 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/ParticipantMenuButton.tsx @@ -0,0 +1,30 @@ +import { Button, Menu } from '@/primitives' +import { RiMore2Fill } from '@remixicon/react' +import { ParticipantMenu } from './ParticipantMenu' +import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' +import type { Participant } from 'livekit-client' +import { useTranslation } from 'react-i18next' + +export const ParticipantMenuButton = ({ + participant, +}: { + participant: Participant +}) => { + const { t } = useTranslation('rooms', { keyPrefix: 'participants' }) + const isAdminOrOwner = useIsAdminOrOwner() + if (!isAdminOrOwner) return null + return ( + + + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/PinMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/PinMenuItem.tsx new file mode 100644 index 00000000..f2699ae4 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/PinMenuItem.tsx @@ -0,0 +1,37 @@ +import { Participant } from 'livekit-client' +import { menuRecipe } from '@/primitives/menuRecipe' +import { HStack } from '@/styled-system/jsx' +import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react' +import { MenuItem } from 'react-aria-components' +import { useTranslation } from 'react-i18next' +import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant' + +export const PinMenuItem = ({ participant }: { participant: Participant }) => { + const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' }) + + const { toggle, inFocus } = useFocusToggleParticipant(participant) + + return ( + + + {inFocus ? ( + <> + + {t('unpin.label')} + + ) : ( + <> + + {t('pin.label')} + + )} + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/RemoveMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/RemoveMenuItem.tsx new file mode 100644 index 00000000..ae583210 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantMenu/RemoveMenuItem.tsx @@ -0,0 +1,28 @@ +import { Participant } from 'livekit-client' +import { menuRecipe } from '@/primitives/menuRecipe' +import { HStack } from '@/styled-system/jsx' +import { RiCloseLine } from '@remixicon/react' +import { MenuItem } from 'react-aria-components' +import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant' +import { useTranslation } from 'react-i18next' + +export const RemoveMenuItem = ({ + participant, +}: { + participant: Participant +}) => { + const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' }) + const { removeParticipant } = useRemoveParticipant() + return ( + removeParticipant(participant)} + > + + + {t('label')} + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx index 9df116ce..e92eeb50 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx @@ -19,6 +19,7 @@ import { useState } from 'react' import { MuteAlertDialog } from '../../MuteAlertDialog' import { useMuteParticipant } from '@/features/rooms/api/muteParticipant' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' +import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton' type MicIndicatorProps = { participant: Participant @@ -144,6 +145,7 @@ export const ParticipantListItem = ({ + ) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts new file mode 100644 index 00000000..ca3fc8f8 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts @@ -0,0 +1,32 @@ +import { useFocusToggle } from '@livekit/components-react' +import { Participant, Track } from 'livekit-client' +import { useCallback } from 'react' +import type { MouseEvent } from 'react' +import Source = Track.Source + +export const useFocusToggleParticipant = (participant: Participant) => { + const trackRef = { + participant: participant, + publication: participant.getTrackPublication(Source.Camera), + source: Source.Camera, + } + + const { mergedProps, inFocus } = useFocusToggle({ + trackRef, + props: {}, + }) + + const toggle = useCallback(() => { + const syntheticEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + } as MouseEvent + + mergedProps?.onClick?.(syntheticEvent) + }, [mergedProps]) + + return { + toggle, + inFocus, + } +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 6e56c60b..7a886e3e 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -456,6 +456,21 @@ "label": "{{name}} vom Meeting ablehnen", "all": "Alle ablehnen" } + }, + "moreOptions": "Weitere Optionen" + }, + "participantMenu": { + "remove": { + "label": "Vom Anruf ausschließen", + "ariaLabel": "{{name}} vom Anruf ausschließen" + }, + "unpin": { + "label": "Lösen", + "ariaLabel": "Löse {{name}}" + }, + "pin": { + "label": "Anheften", + "ariaLabel": "Hefte {{name}} an" } }, "recordingStateToast": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 473897b8..654299bc 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -456,6 +456,21 @@ "label": "Deny {{name}} from the meeting", "all": "Deny all" } + }, + "moreOptions": "More options" + }, + "participantMenu": { + "remove": { + "label": "Remove from the call", + "ariaLabel": "Remove {{name}} from the call" + }, + "unpin": { + "label": "Unpin", + "ariaLabel": "Unpin {{name}}" + }, + "pin": { + "label": "Pin", + "ariaLabel": "Pin {{name}}" } }, "recordingStateToast": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 2d478cce..c280c07c 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -456,6 +456,21 @@ "label": "Refuser {{name}} dans la réunion", "all": "Tout rejeter" } + }, + "moreOptions": "Plus d'options" + }, + "participantMenu": { + "remove": { + "label": "Exclure de l'appel", + "ariaLabel": "Exclure {{name}} de l'appel" + }, + "unpin": { + "label": "Désépingler", + "ariaLabel": "Désépingler {{name}}" + }, + "pin": { + "label": "Épingler", + "ariaLabel": "Épingler {{name}}" } }, "recordingStateToast": { diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index ca21c9eb..372e11a3 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -456,6 +456,21 @@ "label": "{{name}} weigeren voor de vergadering", "all": "Alles weigeren" } + }, + "moreOptions": "Meer opties" + }, + "participantMenu": { + "remove": { + "label": "Van het gesprek uitsluiten", + "ariaLabel": "{{name}} van het gesprek uitsluiten" + }, + "unpin": { + "label": "Losmaken", + "ariaLabel": "Maak {{name}} los" + }, + "pin": { + "label": "Pinnen", + "ariaLabel": "Maak {{name}} vast" } }, "recordingStateToast": {