(frontend) add admin controls for real-time source publication limits

Introduce new admin panel actions allowing room owners to restrict
participant source publication (video/audio/screenshare) with immediate
real-time updates across all participants.

Provides granular room-wide media control for admins to manage
bandwidth, focus attention, or handle disruptive situations by
selectively enabling or disabling specific media types instantly.
This commit is contained in:
lebaudantoine
2025-08-27 14:21:18 +02:00
committed by aleb_the_flash
parent 740355d494
commit f28da45b4c
9 changed files with 364 additions and 57 deletions

View File

@@ -19,6 +19,6 @@ export type ApiRoom = {
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: {
[key: string]: string | number | boolean
[key: string]: string | number | boolean | string[]
}
}

View File

@@ -5,7 +5,7 @@ import { ApiError } from '@/api/ApiError'
export type PatchRoomParams = {
roomId: string
room: Pick<ApiRoom, 'configuration' | 'access_level'>
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
}
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {

View File

@@ -9,6 +9,7 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -28,6 +29,15 @@ export const Admin = () => {
enabled: false,
})
const {
toggleMicrophone,
toggleCamera,
toggleScreenShare,
isMicrophoneEnabled,
isCameraEnabled,
isScreenShareEnabled,
} = usePublishSourcesManager()
return (
<Div
display="flex"
@@ -47,68 +57,155 @@ export const Admin = () => {
>
{t('description')}
</Text>
<RACSeparator
<div
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
display: 'flex',
flexDirection: 'column',
})}
>
{t('access.title')}
</H>
<Text
variant="note"
wrap="balance"
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('moderation.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('moderation.description')}
</Text>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<Field
type="switch"
label={t('moderation.microphone.label')}
description={t('moderation.microphone.description')}
isSelected={isMicrophoneEnabled}
onChange={toggleMicrophone}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.camera.label')}
description={t('moderation.camera.description')}
isSelected={isCameraEnabled}
onChange={toggleCamera}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.screenshare.label')}
description={t('moderation.screenshare.description')}
isSelected={isScreenShareEnabled}
onChange={toggleScreenShare}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
className={css({
textStyle: 'sm',
display: 'flex',
flexDirection: 'column',
marginTop: '1rem',
})}
margin={'md'}
>
{t('access.description')}
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({ roomId, room: { access_level: value as ApiAccessLevel } })
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
background: 'greyscale.250',
})}
/>
<H
lvl={2}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{t('access.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{t('access.description')}
</Text>
<Field
type="radioGroup"
label={t('access.type')}
aria-label={t('access.type')}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
value={readOnlyData?.access_level}
onChange={(value) =>
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
.then((room) => {
queryClient.setQueryData([keys.room, roomId], room)
})
.catch((e) => console.error(e))
}
items={[
{
value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'),
description: t('access.levels.public.description'),
},
{
value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'),
},
{
value: ApiAccessLevel.RESTRICTED,
label: t('access.levels.restricted.label'),
description: t('access.levels.restricted.description'),
},
]}
/>
</div>
</Div>
)
}

View File

@@ -0,0 +1,134 @@
import { Track } from 'livekit-client'
import { useCallback, useMemo } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import Source = Track.Source
// todo - synchronisation with backend
export const DEFAULT_PUBLISH_SOURCES: Array<Source> = [
Source.Microphone,
Source.Camera,
Source.ScreenShare,
Source.ScreenShareAudio,
]
export const updatePublishSources = (
currentSources: Source[],
sources: Source[],
enabled: boolean
): Source[] => {
if (enabled) {
const combined = [...currentSources, ...sources]
return Array.from(new Set(combined))
} else {
return currentSources.filter(
(source) => !sources.some((newSource) => newSource === source)
)
}
}
export const usePublishSourcesManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const configuration = data?.configuration
// The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
const roomId = data?.slug
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
const remoteParticipants = useRemoteParticipants()
// todo - filter, update only contributors and not admin
const currentSources = useMemo(() => {
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return DEFAULT_PUBLISH_SOURCES
}
return configuration.can_publish_sources.map((source) => {
return source as Source
})
}, [configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
if (!roomId) return
try {
const newSources = updatePublishSources(
currentSources,
sources,
enabled
)
const newConfiguration = {
...configuration,
can_publish_sources: newSources as string[],
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
await updateParticipantsPermissions(remoteParticipants, newSources)
return { configuration: newConfiguration }
} catch (error) {
console.error(`Failed to update ${sources}:`, error)
return { success: false, error }
}
},
[
configuration,
currentSources,
roomId,
patchRoom,
remoteParticipants,
updateParticipantsPermissions,
]
)
const toggleMicrophone = useCallback(
(enabled: boolean) => updateSource([Source.Microphone], enabled),
[updateSource]
)
const toggleCamera = useCallback(
(enabled: boolean) => updateSource([Source.Camera], enabled),
[updateSource]
)
const toggleScreenShare = useCallback(
(enabled: boolean) =>
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled),
[updateSource]
)
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
const isScreenShareEnabled = isSubsetOf(
[Source.ScreenShare, Source.ScreenShareAudio],
currentSources
)
return {
updateSource,
toggleMicrophone,
toggleCamera,
toggleScreenShare,
isMicrophoneEnabled,
isCameraEnabled,
isScreenShareEnabled,
}
}

View File

@@ -0,0 +1,12 @@
export const isSubsetOf = <T>(
subset: T[],
superset: T[] | undefined
): boolean => {
if (!superset || superset.length === 0) {
return subset.length === 0
}
if (!subset || subset.length === 0) {
return true
}
return subset.every((item) => superset.includes(item))
}

View File

@@ -381,6 +381,22 @@
"description": "Nicht eingeladene Personen müssen um Zugang bitten."
}
}
},
"moderation": {
"title": "Moderation des Meetings",
"description": "Diese Einstellungen beschränken die Aktionen, die Mitwirkende während des Meetings durchführen können.",
"microphone": {
"label": "Mikrofon einschalten",
"description": ""
},
"camera": {
"label": "Video aktivieren",
"description": ""
},
"screenshare": {
"label": "Ihren Bildschirm teilen",
"description": "Wenn Sie diese Option deaktivieren, können Teilnehmer ihren Bildschirm nicht mehr teilen, und laufende Bildschirmfreigaben werden sofort beendet."
}
}
},
"rating": {

View File

@@ -381,6 +381,22 @@
"description": "People who have not been invited to the meeting must request to join."
}
}
},
"moderation": {
"title": "Meeting Moderation",
"description": "These settings restrict the actions contributors can take during the meeting.",
"microphone": {
"label": "Turn on microphone",
"description": ""
},
"camera": {
"label": "Enable video",
"description": ""
},
"screenshare": {
"label": "Share their screen",
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
}
}
},
"rating": {

View File

@@ -381,6 +381,22 @@
"description": "Les personnes qui n'ont pas été invitées à la réunion doivent demander à la rejoindre."
}
}
},
"moderation": {
"title": "Modération de la réunion",
"description": "Ces paramètres limitent les actions possibles des contributeurs pendant la réunion.",
"microphone": {
"label": "Allumer le micro",
"description": ""
},
"camera": {
"label": "Activer la vidéo",
"description": ""
},
"screenshare": {
"label": "Partager leur écran",
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
}
}
},
"rating": {

View File

@@ -381,6 +381,22 @@
"description": "Personen die niet zijn uitgenodigd voor de vergadering moeten vragen om deel te nemen."
}
}
},
"moderation": {
"title": "Moderatie van de vergadering",
"description": "Deze instellingen beperken de acties die deelnemers tijdens de vergadering kunnen uitvoeren.",
"microphone": {
"label": "Microfoon inschakelen",
"description": ""
},
"camera": {
"label": "Video inschakelen",
"description": ""
},
"screenshare": {
"label": "Hun scherm delen",
"description": "Als u deze optie uitschakelt, kunnen deelnemers hun scherm niet meer delen en wordt elke lopende schermdeling onmiddellijk gestopt."
}
}
},
"rating": {