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": {