✨(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
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean
|
[key: string]: string | number | boolean | string[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ApiError } from '@/api/ApiError'
|
|||||||
|
|
||||||
export type PatchRoomParams = {
|
export type PatchRoomParams = {
|
||||||
roomId: string
|
roomId: string
|
||||||
room: Pick<ApiRoom, 'configuration' | 'access_level'>
|
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { queryClient } from '@/api/queryClient'
|
|||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
|
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -28,6 +29,15 @@ export const Admin = () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
toggleMicrophone,
|
||||||
|
toggleCamera,
|
||||||
|
toggleScreenShare,
|
||||||
|
isMicrophoneEnabled,
|
||||||
|
isCameraEnabled,
|
||||||
|
isScreenShareEnabled,
|
||||||
|
} = usePublishSourcesManager()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -47,68 +57,155 @@ export const Admin = () => {
|
|||||||
>
|
>
|
||||||
{t('description')}
|
{t('description')}
|
||||||
</Text>
|
</Text>
|
||||||
<RACSeparator
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
border: 'none',
|
display: 'flex',
|
||||||
height: '1px',
|
flexDirection: 'column',
|
||||||
width: '100%',
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{t('access.title')}
|
<RACSeparator
|
||||||
</H>
|
className={css({
|
||||||
<Text
|
border: 'none',
|
||||||
variant="note"
|
height: '1px',
|
||||||
wrap="balance"
|
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({
|
className={css({
|
||||||
textStyle: 'sm',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '1rem',
|
||||||
})}
|
})}
|
||||||
margin={'md'}
|
|
||||||
>
|
>
|
||||||
{t('access.description')}
|
<RACSeparator
|
||||||
</Text>
|
className={css({
|
||||||
<Field
|
border: 'none',
|
||||||
type="radioGroup"
|
height: '1px',
|
||||||
label={t('access.type')}
|
width: '100%',
|
||||||
aria-label={t('access.type')}
|
background: 'greyscale.250',
|
||||||
labelProps={{
|
})}
|
||||||
className: css({
|
/>
|
||||||
fontSize: '1rem',
|
<H
|
||||||
paddingBottom: '1rem',
|
lvl={2}
|
||||||
}),
|
className={css({
|
||||||
}}
|
fontWeight: 500,
|
||||||
value={readOnlyData?.access_level}
|
})}
|
||||||
onChange={(value) =>
|
margin="sm"
|
||||||
patchRoom({ roomId, room: { access_level: value as ApiAccessLevel } })
|
>
|
||||||
.then((room) => {
|
{t('access.title')}
|
||||||
queryClient.setQueryData([keys.room, roomId], room)
|
</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))
|
.then((room) => {
|
||||||
}
|
queryClient.setQueryData([keys.room, roomId], room)
|
||||||
items={[
|
})
|
||||||
{
|
.catch((e) => console.error(e))
|
||||||
value: ApiAccessLevel.PUBLIC,
|
}
|
||||||
label: t('access.levels.public.label'),
|
items={[
|
||||||
description: t('access.levels.public.description'),
|
{
|
||||||
},
|
value: ApiAccessLevel.PUBLIC,
|
||||||
{
|
label: t('access.levels.public.label'),
|
||||||
value: ApiAccessLevel.TRUSTED,
|
description: t('access.levels.public.description'),
|
||||||
label: t('access.levels.trusted.label'),
|
},
|
||||||
description: t('access.levels.trusted.description'),
|
{
|
||||||
},
|
value: ApiAccessLevel.TRUSTED,
|
||||||
{
|
label: t('access.levels.trusted.label'),
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
description: t('access.levels.trusted.description'),
|
||||||
label: t('access.levels.restricted.label'),
|
},
|
||||||
description: t('access.levels.restricted.description'),
|
{
|
||||||
},
|
value: ApiAccessLevel.RESTRICTED,
|
||||||
]}
|
label: t('access.levels.restricted.label'),
|
||||||
/>
|
description: t('access.levels.restricted.description'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</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."
|
"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": {
|
"rating": {
|
||||||
|
|||||||
@@ -381,6 +381,22 @@
|
|||||||
"description": "People who have not been invited to the meeting must request to join."
|
"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": {
|
"rating": {
|
||||||
|
|||||||
@@ -381,6 +381,22 @@
|
|||||||
"description": "Les personnes qui n'ont pas été invitées à la réunion doivent demander à la rejoindre."
|
"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": {
|
"rating": {
|
||||||
|
|||||||
@@ -381,6 +381,22 @@
|
|||||||
"description": "Personen die niet zijn uitgenodigd voor de vergadering moeten vragen om deel te nemen."
|
"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": {
|
"rating": {
|
||||||
|
|||||||
Reference in New Issue
Block a user