diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 554c47bd..061fa0ef 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -19,6 +19,6 @@ export type ApiRoom = { access_level: ApiAccessLevel livekit?: ApiLiveKit configuration?: { - [key: string]: string | number | boolean + [key: string]: string | number | boolean | string[] } } diff --git a/src/frontend/src/features/rooms/api/patchRoom.tsx b/src/frontend/src/features/rooms/api/patchRoom.tsx index 2f4badef..76d05fb0 100644 --- a/src/frontend/src/features/rooms/api/patchRoom.tsx +++ b/src/frontend/src/features/rooms/api/patchRoom.tsx @@ -5,7 +5,7 @@ import { ApiError } from '@/api/ApiError' export type PatchRoomParams = { roomId: string - room: Pick + room: Partial> } export const patchRoom = ({ roomId, room }: PatchRoomParams) => { diff --git a/src/frontend/src/features/rooms/livekit/components/Admin.tsx b/src/frontend/src/features/rooms/livekit/components/Admin.tsx index 744192e9..efe1090a 100644 --- a/src/frontend/src/features/rooms/livekit/components/Admin.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Admin.tsx @@ -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 (
{ > {t('description')} - - - {t('access.title')} - - + + {t('moderation.title')} + + + {t('moderation.description')} + +
+ + + +
+
+
- {t('access.description')} - - - patchRoom({ roomId, room: { access_level: value as ApiAccessLevel } }) - .then((room) => { - queryClient.setQueryData([keys.room, roomId], room) + + + {t('access.title')} + + + {t('access.description')} + + + 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'), + }, + ]} + /> +
) } diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts new file mode 100644 index 00000000..5fe4b707 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts @@ -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.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, + } +} diff --git a/src/frontend/src/features/rooms/utils/isSubsetOf.ts b/src/frontend/src/features/rooms/utils/isSubsetOf.ts new file mode 100644 index 00000000..0b8e1287 --- /dev/null +++ b/src/frontend/src/features/rooms/utils/isSubsetOf.ts @@ -0,0 +1,12 @@ +export const isSubsetOf = ( + 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)) +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index ff51e8b4..a6bed7ef 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 42acea30..652ec1d2 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index fcd4d0da..72e75d85 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index f42680a0..42877d2b 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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": {