✨(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:
committed by
aleb_the_flash
parent
6a76041a70
commit
da86b30455
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user