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 (
+
+ )
+}
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 (
+
+ )
+}
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": {