mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-18 06:23:21 +00:00
✨(frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the authenticated participants of the meeting to a role with additional privileges. Known limitations: * Only authenticated participants can be promoted, but there is no visual indicator yet distinguishing authenticated from anonymous participants. This will be added in a follow-up commit. * The resource_access data fetched in the initial API call becomes stale after a promotion. It is not currently used in the product, so this is not visible, but it should either be refreshed later or removed from the initial fetch. * Demoting a promoted user turns them into a member, which is still a privileged role. This is a deliberate choice until we introduce finer-grained tuning of participant roles.
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
import { getParticipantIsRoomOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
import {
|
||||||
|
getParticipantIsRoomAdmin,
|
||||||
|
getParticipantIsRoomOwner,
|
||||||
|
getParticipantIsRoomMember,
|
||||||
|
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||||
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import {
|
import {
|
||||||
@@ -149,6 +153,12 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
|||||||
{getParticipantIsRoomOwner(participant) && (
|
{getParticipantIsRoomOwner(participant) && (
|
||||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||||
)}
|
)}
|
||||||
|
{getParticipantIsRoomAdmin(participant) && (
|
||||||
|
<Text variant="xsNote">{t('participants.cohost')}</Text>
|
||||||
|
)}
|
||||||
|
{getParticipantIsRoomMember(participant) && (
|
||||||
|
<Text variant="xsNote">{t('participants.member')}</Text>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack>
|
<HStack>
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import { PinMenuItem } from './items/PinMenuItem'
|
import { PinMenuItem } from './items/PinMenuItem'
|
||||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||||
|
import { PromoteMenuItem } from './items/PromoteMenuItem'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
|
import { useParticipantAttributes } from '@livekit/components-react'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
}: {
|
}: {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}) => {
|
}) => {
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||||
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
|
|
||||||
|
const { attributes } = useParticipantAttributes({ participant })
|
||||||
|
|
||||||
|
const isAdmin = attributes?.room_role === 'administrator'
|
||||||
|
const isOwner = attributes?.room_role === 'owner'
|
||||||
|
const isAuthenticated = attributes?.is_authenticated == 'true'
|
||||||
|
|
||||||
|
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RACMenu
|
<RACMenu
|
||||||
style={{
|
style={{
|
||||||
minWidth: '75px',
|
minWidth: '100px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PinMenuItem participant={participant} />
|
<PinMenuItem participant={participant} />
|
||||||
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
|
{canManage && (
|
||||||
|
<RemoveMenuItem
|
||||||
|
identity={participant.identity}
|
||||||
|
displayedName={participant.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canManage && isAuthenticated && (
|
||||||
|
<PromoteMenuItem
|
||||||
|
identity={participant.identity}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
displayedName={participant.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</RACMenu>
|
</RACMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { RiMore2Fill } from '@remixicon/react'
|
import { RiMore2Fill } from '@remixicon/react'
|
||||||
import { ParticipantMenu } from './ParticipantMenu'
|
import { ParticipantMenu } from './ParticipantMenu'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
if (!isAdminOrOwner) return null
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiAdminLine, RiUserMinusLine } from '@remixicon/react'
|
||||||
|
import { useParticipantRole } from '@/features/participants/api/updateParticipantRole'
|
||||||
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
type PromoteMenuItemProps = {
|
||||||
|
identity: string
|
||||||
|
displayedName?: string
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromoteMenuItem = React.memo(
|
||||||
|
({ identity, displayedName, isAdmin }: PromoteMenuItemProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
|
const { updateParticipantRole } = useParticipantRole()
|
||||||
|
|
||||||
|
const label = isAdmin ? 'demote' : 'promote'
|
||||||
|
const Icon = isAdmin ? RiUserMinusLine : RiAdminLine
|
||||||
|
|
||||||
|
const toggleRole = useCallback(
|
||||||
|
() =>
|
||||||
|
updateParticipantRole(identity, isAdmin ? 'member' : 'administrator'),
|
||||||
|
[isAdmin, updateParticipantRole, identity]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
aria-label={t(`${label}.ariaLabel`, {
|
||||||
|
name: displayedName || identity,
|
||||||
|
})}
|
||||||
|
className={menuRecipe({ icon: true }).item}
|
||||||
|
onAction={toggleRole}
|
||||||
|
>
|
||||||
|
<HStack gap={0.25} minWidth={280}>
|
||||||
|
<Icon size={20} aria-hidden />
|
||||||
|
{t(`${label}.label`)}
|
||||||
|
</HStack>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PromoteMenuItem.displayName = 'PromoteMenuItem'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import React from 'react'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
@@ -6,23 +6,30 @@ import { MenuItem } from 'react-aria-components'
|
|||||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const RemoveMenuItem = ({
|
type RemoveMenuItemProps = {
|
||||||
participant,
|
identity: string
|
||||||
}: {
|
displayedName?: string
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RemoveMenuItem = React.memo(
|
||||||
|
({ identity, displayedName }: RemoveMenuItemProps) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'participantMenu.remove',
|
||||||
|
})
|
||||||
|
const { removeParticipant } = useRemoveParticipant()
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
aria-label={t('ariaLabel', { name: displayedName || identity })}
|
||||||
|
className={menuRecipe({ icon: true }).item}
|
||||||
|
onAction={() => removeParticipant(identity)}
|
||||||
|
>
|
||||||
|
<HStack gap={0.25}>
|
||||||
|
<RiCloseLine size={20} aria-hidden />
|
||||||
|
{t('label')}
|
||||||
|
</HStack>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RemoveMenuItem.displayName = 'RemoveMenuItem'
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { useRoomData } from '../livekit/hooks/useRoomData'
|
import { useRoomData } from '../livekit/hooks/useRoomData'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
export const useRemoveParticipant = () => {
|
export const useRemoveParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
|
|
||||||
const removeParticipant = async (participant: Participant) => {
|
const removeParticipant = async (identity: string) => {
|
||||||
if (!data?.id) {
|
if (!data?.id) {
|
||||||
throw new Error('Room id is not available')
|
throw new Error('Room id is not available')
|
||||||
}
|
}
|
||||||
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
|
|||||||
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
participant_identity: participant.identity,
|
participant_identity: identity,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
|
|||||||
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
|
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
|
||||||
participantHasRoomRole(participant, ['owner'])
|
participantHasRoomRole(participant, ['owner'])
|
||||||
|
|
||||||
|
export const getParticipantIsRoomMember = (participant: Participant): boolean =>
|
||||||
|
participantHasRoomRole(participant, ['member'])
|
||||||
|
|
||||||
export const getParticipantIsRoomAdminOrOwner = (
|
export const getParticipantIsRoomAdminOrOwner = (
|
||||||
participant: Participant
|
participant: Participant
|
||||||
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
|
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
|
||||||
|
|||||||
@@ -583,6 +583,8 @@
|
|||||||
"you": "Du",
|
"you": "Du",
|
||||||
"unknown": "Unbekannte Person",
|
"unknown": "Unbekannte Person",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
"cohost": "Co-host",
|
||||||
|
"member": "Mitglied",
|
||||||
"contributors": "Teilnehmende",
|
"contributors": "Teilnehmende",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
"open": "{{name}}-Liste öffnen",
|
"open": "{{name}}-Liste öffnen",
|
||||||
@@ -627,6 +629,14 @@
|
|||||||
"pin": {
|
"pin": {
|
||||||
"label": "Anheften",
|
"label": "Anheften",
|
||||||
"ariaLabel": "Hefte {{name}} an"
|
"ariaLabel": "Hefte {{name}} an"
|
||||||
|
},
|
||||||
|
"promote": {
|
||||||
|
"label": "Zum Co-Host machen",
|
||||||
|
"ariaLabel": "{{name}} zum Co-Host machen"
|
||||||
|
},
|
||||||
|
"demote": {
|
||||||
|
"label": "Co-Host-Rolle entfernen",
|
||||||
|
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pinAnnouncements": {
|
"pinAnnouncements": {
|
||||||
|
|||||||
@@ -582,6 +582,8 @@
|
|||||||
"you": "You",
|
"you": "You",
|
||||||
"unknown": "Unknown participant",
|
"unknown": "Unknown participant",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
"cohost": "Co-host",
|
||||||
|
"member": "Member",
|
||||||
"contributors": "Contributors",
|
"contributors": "Contributors",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
"open": "Open {{name}} list",
|
"open": "Open {{name}} list",
|
||||||
@@ -626,6 +628,14 @@
|
|||||||
"pin": {
|
"pin": {
|
||||||
"label": "Pin",
|
"label": "Pin",
|
||||||
"ariaLabel": "Pin {{name}}"
|
"ariaLabel": "Pin {{name}}"
|
||||||
|
},
|
||||||
|
"promote": {
|
||||||
|
"label": "Make co-host",
|
||||||
|
"ariaLabel": "Make {{name}} a co-host"
|
||||||
|
},
|
||||||
|
"demote": {
|
||||||
|
"label": "Remove co-host role",
|
||||||
|
"ariaLabel": "Remove {{name}}'s co-host role"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pinAnnouncements": {
|
"pinAnnouncements": {
|
||||||
|
|||||||
@@ -583,6 +583,8 @@
|
|||||||
"unknown": "Participant inconnu",
|
"unknown": "Participant inconnu",
|
||||||
"contributors": "Contributeurs",
|
"contributors": "Contributeurs",
|
||||||
"host": "Organisateur de la réunion",
|
"host": "Organisateur de la réunion",
|
||||||
|
"cohost": "Co-organisateur",
|
||||||
|
"member": "Membre",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
"open": "Ouvrir la liste {{name}}",
|
"open": "Ouvrir la liste {{name}}",
|
||||||
"close": "Fermer la liste {{name}}"
|
"close": "Fermer la liste {{name}}"
|
||||||
@@ -626,6 +628,14 @@
|
|||||||
"pin": {
|
"pin": {
|
||||||
"label": "Épingler",
|
"label": "Épingler",
|
||||||
"ariaLabel": "Épingler {{name}}"
|
"ariaLabel": "Épingler {{name}}"
|
||||||
|
},
|
||||||
|
"promote": {
|
||||||
|
"label": "Nommer co-organisateur",
|
||||||
|
"ariaLabel": "Nommer {{name}} co-organisateur"
|
||||||
|
},
|
||||||
|
"demote": {
|
||||||
|
"label": "Retirer le rôle de co-organisateur",
|
||||||
|
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pinAnnouncements": {
|
"pinAnnouncements": {
|
||||||
|
|||||||
@@ -582,6 +582,8 @@
|
|||||||
"you": "U",
|
"you": "U",
|
||||||
"unknown": "Onbekende deelnemer",
|
"unknown": "Onbekende deelnemer",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
|
"cohost": "Co-host",
|
||||||
|
"member": "Lid",
|
||||||
"contributors": "Deelnemers",
|
"contributors": "Deelnemers",
|
||||||
"collapsable": {
|
"collapsable": {
|
||||||
"open": "Open {{name}} lijst",
|
"open": "Open {{name}} lijst",
|
||||||
@@ -626,6 +628,14 @@
|
|||||||
"pin": {
|
"pin": {
|
||||||
"label": "Pinnen",
|
"label": "Pinnen",
|
||||||
"ariaLabel": "Maak {{name}} vast"
|
"ariaLabel": "Maak {{name}} vast"
|
||||||
|
},
|
||||||
|
"promote": {
|
||||||
|
"label": "Co-host maken",
|
||||||
|
"ariaLabel": "{{name}} co-host maken"
|
||||||
|
},
|
||||||
|
"demote": {
|
||||||
|
"label": "Co-hostrol verwijderen",
|
||||||
|
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pinAnnouncements": {
|
"pinAnnouncements": {
|
||||||
|
|||||||
Reference in New Issue
Block a user