✨(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:
committed by
aleb_the_flash
parent
740355d494
commit
f28da45b4c
@@ -19,6 +19,6 @@ export type ApiRoom = {
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean
|
||||
[key: string]: string | number | boolean | string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
12
src/frontend/src/features/rooms/utils/isSubsetOf.ts
Normal file
12
src/frontend/src/features/rooms/utils/isSubsetOf.ts
Normal 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))
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user