(frontend) add participant menu with pin/unpin and admin remove actions

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.
This commit is contained in:
lebaudantoine
2025-08-28 12:27:02 +02:00
committed by aleb_the_flash
parent 6a76041a70
commit da86b30455
10 changed files with 213 additions and 0 deletions

View File

@@ -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 (
<RACMenu
style={{
minWidth: '75px',
}}
>
<PinMenuItem participant={participant} />
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
</RACMenu>
)
}

View File

@@ -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 (
<Menu>
<Button
square
variant="tertiaryText"
size="sm"
aria-label={t('moreOptions')}
tooltip={t('moreOptions')}
>
<RiMore2Fill />
</Button>
<ParticipantMenu participant={participant} />
</Menu>
)
}

View File

@@ -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 (
<MenuItem
aria-label={t(`${inFocus ? 'unpin' : 'pin'}.ariaLabel`, {
name: participant.name,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggle}
>
<HStack gap={0.25}>
{inFocus ? (
<>
<RiUnpinLine size={20} aria-hidden />
{t('unpin.label')}
</>
) : (
<>
<RiPushpin2Line size={20} aria-hidden />
{t('pin.label')}
</>
)}
</HStack>
</MenuItem>
)
}

View File

@@ -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 (
<MenuItem
aria-label={t('ariaLabel', { name: participant.name })}
className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(participant)}
>
<HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden />
{t('label')}
</HStack>
</MenuItem>
)
}

View File

@@ -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 = ({
</HStack>
<HStack>
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
</HStack>
)

View File

@@ -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<HTMLButtonElement>
mergedProps?.onClick?.(syntheticEvent)
}, [mergedProps])
return {
toggle,
inFocus,
}
}

View File

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

View File

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

View File

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

View File

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