From 15b1ab7e0a39c5d76df7044f371a42d5dedd6a79 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 3 Aug 2026 11:32:44 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20let=20users=20set=20defau?= =?UTF-8?q?lt=20configuration=20for=20generated=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing out-of-room settings so users can configure a default room configuration that is applied to every link they generate from the app. --- CHANGELOG.md | 1 + .../src/features/auth/api/patchUser.ts | 36 +++ .../settings/components/SettingsDialog.tsx | 111 ++++++++- .../settings/components/tabs/RoomsTab.tsx | 226 ++++++++++++++++++ src/frontend/src/locales/de/settings.json | 7 +- src/frontend/src/locales/en/settings.json | 7 +- src/frontend/src/locales/fr/settings.json | 7 +- src/frontend/src/locales/nl/settings.json | 7 +- 8 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/features/auth/api/patchUser.ts create mode 100644 src/frontend/src/features/settings/components/tabs/RoomsTab.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 987b7959..9ef49df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - ✨(frontend) allow promoting authenticated participants - ✨(frontend) introduce an "unauthenticated" participant badge - ✨(backend) add roomkit viewset to start a room without WebRTC join +- ✨(frontend) let users set default configuration for generated links ### Changed diff --git a/src/frontend/src/features/auth/api/patchUser.ts b/src/frontend/src/features/auth/api/patchUser.ts new file mode 100644 index 00000000..eb88af21 --- /dev/null +++ b/src/frontend/src/features/auth/api/patchUser.ts @@ -0,0 +1,36 @@ +import { useMutation, type UseMutationOptions } from '@tanstack/react-query' +import { fetchApi } from '@/api/fetchApi' +import type { ApiError } from '@/api/ApiError' +import { type ApiUser } from './ApiUser' + +export type PatchUserParams = { + userId: string + user: Partial< + Pick< + ApiUser, + | 'timezone' + | 'language' + | 'default_room_access_level' + | 'default_room_configuration' + > + > +} + +export const patchUser = ({ userId, user }: PatchUserParams) => { + return fetchApi(`/users/${userId}/`, { + method: 'PATCH', + body: JSON.stringify(user), + }) +} + +export const patchUserMutationKey = ['patchUser'] + +export function usePatchUser( + options?: UseMutationOptions +) { + return useMutation({ + mutationKey: patchUserMutationKey, + mutationFn: patchUser, + ...options, + }) +} diff --git a/src/frontend/src/features/settings/components/SettingsDialog.tsx b/src/frontend/src/features/settings/components/SettingsDialog.tsx index 9cee8600..9c0df092 100644 --- a/src/frontend/src/features/settings/components/SettingsDialog.tsx +++ b/src/frontend/src/features/settings/components/SettingsDialog.tsx @@ -1,22 +1,79 @@ import { Trans, useTranslation } from 'react-i18next' +import { useRef } from 'react' +import { Heading } from 'react-aria-components' +import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react' import { useLanguageLabels } from '@/i18n/useLanguageLabels' import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives' +import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs' +import { text } from '@/primitives/Text.tsx' +import { css } from '@/styled-system/css' import { useUser } from '@/features/auth/api/useUser' import { LoginButton } from '@/components/LoginButton' import { logout } from '@/features/auth/utils/logout' +import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' +import { RoomsTab } from './tabs/RoomsTab' export type SettingsDialogProps = Pick +enum SettingsDialogTabKey { + GENERAL = 'general', + ROOMS = 'rooms', +} + +const tabsStyle = css({ + maxHeight: '40.625rem', // fixme size copied from meet settings modal + width: '50rem', // fixme size copied from meet settings modal + marginY: '-1rem', // fixme hacky solution to cancel modal padding + maxWidth: '100%', + overflow: 'hidden', + height: 'calc(100vh - 2rem)', +}) + +const tabListContainerStyle = css({ + display: 'flex', + flexDirection: 'column', + borderRight: '1px solid lightGray', // fixme poor color management + paddingY: '1rem', + paddingLeft: '0.2rem', + paddingRight: '1.5rem', +}) + +const tabPanelContainerStyle = css({ + display: 'flex', + flexGrow: '1', + marginTop: '3.5rem', + minWidth: 0, +}) + +const tabPanelStyle = css({ + flexGrow: '1', + minWidth: 0, + overflowY: 'auto', + paddingRight: '1.5rem', + paddingBottom: '1rem', +}) + export const SettingsDialog = (props: SettingsDialogProps) => { const { t, i18n } = useTranslation('settings') const { user, isLoggedIn } = useUser() const { languagesList, currentLanguage } = useLanguageLabels() + + const dialogEl = useRef(null) + const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel + const userDisplay = user?.full_name && user?.email ? `${user.full_name} (${user.email})` : user?.email - return ( - + + const generalContent = ( +
{t('account.heading')} {isLoggedIn ? ( <> @@ -47,6 +104,56 @@ export const SettingsDialog = (props: SettingsDialogProps) => { i18n.changeLanguage(lang as string) }} /> +
+ ) + + // Without tabs there is no rail to host the heading, so keep the plain dialog. + if (!isLoggedIn) { + return ( + + {generalContent} + + ) + } + + return ( + + +
+ {isWideScreen && ( + + {t('dialog.heading')} + + )} + + + + {isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)} + + + + {isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)} + + +
+
+ + {generalContent} + + +
+
) } diff --git a/src/frontend/src/features/settings/components/tabs/RoomsTab.tsx b/src/frontend/src/features/settings/components/tabs/RoomsTab.tsx new file mode 100644 index 00000000..dc77efe9 --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/RoomsTab.tsx @@ -0,0 +1,226 @@ +import { useTranslation } from 'react-i18next' +import { useUser } from '@/features/auth/api/useUser' +import { useConfig } from '@/api/useConfig' +import { + usePatchUser, + patchUserMutationKey, +} from '@/features/auth/api/patchUser' +import { type ApiUser } from '@/features/auth/api/ApiUser' +import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom' +import { useMemo } from 'react' +import { queryClient } from '@/api/queryClient' +import { keys } from '@/api/queryKeys' +import { Track } from 'livekit-client' +import Source = Track.Source +import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf' +import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' +import { Field, H, Text } from '@/primitives' +import { TabPanel } from '@/primitives/Tabs' +import { css } from '@/styled-system/css' +import { Separator as RACSeparator } from 'react-aria-components' + +type RoomsTabProps = { + id: string +} + +export const RoomsTab = ({ id }: RoomsTabProps) => { + const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' }) + const { t: tAdmin } = useTranslation('rooms', { + keyPrefix: 'admin', + useSuspense: false, + }) + + const { user } = useUser() + const { data: configData } = useConfig() + + // Optimistic updates: patch the cache immediately so the UI updates + // instantly and concurrent saves always build on the latest local state. + // Since each PATCH replaces the full JSON config, this avoids overwriting + // earlier changes with a stale snapshot. + // + // No per-request rollback: later requests already include earlier changes. + // Once the last in-flight save completes, re-fetch the server state once to + // restore the UI if all saves failed. + const { mutate: patchUser } = usePatchUser({ + onMutate: async ({ user: partialUser }) => { + await queryClient.cancelQueries({ queryKey: [keys.user] }) + queryClient.setQueryData([keys.user], (previous) => + previous ? { ...previous, ...partialUser } : previous + ) + }, + onSettled: () => { + if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) { + queryClient.invalidateQueries({ queryKey: [keys.user] }) + } + }, + }) + + const configuration: RoomConfiguration = useMemo( + () => user?.default_room_configuration ?? {}, + [user?.default_room_configuration] + ) + + const currentSources: Source[] = useMemo(() => { + const defaultSources = configData?.livekit?.default_sources ?? [] + if (!Array.isArray(configuration?.can_publish_sources)) { + return defaultSources + } + return configuration.can_publish_sources + }, [configData, configuration]) + + const accessLevel = + user?.default_room_access_level ?? + configData?.resource?.default_access_level ?? + ApiAccessLevel.PUBLIC + + // Every change saves immediately; the optimistic onMutate above keeps the + // cached user (and therefore `configuration`) in sync right away. + const saveConfiguration = (newConfiguration: RoomConfiguration) => { + if (!user) return + patchUser({ + userId: user.id, + user: { default_room_configuration: newConfiguration }, + }) + } + + const updateSource = (sources: Source[], enabled: boolean) => + saveConfiguration({ + ...configuration, + can_publish_sources: updatePublishSources( + currentSources, + sources, + enabled + ), + }) + + const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources) + const isCameraEnabled = isSubsetOf([Source.Camera], currentSources) + const isScreenShareEnabled = isSubsetOf( + [Source.ScreenShare, Source.ScreenShareAudio], + currentSources + ) + const isMutingEnabled = configuration?.everyone_can_mute ?? true + + const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => { + if (!user) return + patchUser({ + userId: user.id, + user: { default_room_access_level: newAccessLevel }, + }) + } + + return ( + + {t('heading')} + + {t('description')} + + + + {tAdmin('moderation.title')} + + + {tAdmin('moderation.description')} + + updateSource([Source.Microphone], enabled)} + /> + updateSource([Source.Camera], enabled)} + /> + + updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled) + } + /> + + saveConfiguration({ ...configuration, everyone_can_mute: enabled }) + } + /> + + + {tAdmin('access.title')} + + saveAccessLevel(value as ApiAccessLevel)} + items={[ + { + value: ApiAccessLevel.PUBLIC, + label: tAdmin('access.levels.public.label'), + description: tAdmin('access.levels.public.description'), + }, + { + value: ApiAccessLevel.TRUSTED, + label: tAdmin('access.levels.trusted.label'), + description: tAdmin('access.levels.trusted.description'), + }, + { + value: ApiAccessLevel.RESTRICTED, + label: tAdmin('access.levels.restricted.label'), + description: tAdmin('access.levels.restricted.description'), + }, + ]} + /> + + ) +} diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 9dfaffd8..a58f264b 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -179,6 +179,11 @@ "notifications": "Benachrichtigungen", "accessibility": "Barrierefreiheit", "transcription": "Transkription", - "shortcuts": "Tastenkürzel" + "shortcuts": "Tastenkürzel", + "rooms": "Räume" + }, + "roomDefaults": { + "heading": "Standardeinstellungen für Räume", + "description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern." } } diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 7f79fcd7..62aa37b8 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -179,6 +179,11 @@ "notifications": "Notifications", "accessibility": "Accessibility", "transcription": "Transcription", - "shortcuts": "Shortcuts" + "shortcuts": "Shortcuts", + "rooms": "Rooms" + }, + "roomDefaults": { + "heading": "Default room settings", + "description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings." } } diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 072c3ff4..9878ecc7 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -179,6 +179,11 @@ "notifications": "Notifications", "accessibility": "Accessibilité", "transcription": "Transcription", - "shortcuts": "Raccourcis" + "shortcuts": "Raccourcis", + "rooms": "Réunions" + }, + "roomDefaults": { + "heading": "Paramètres par défaut des réunions", + "description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration." } } diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index 6d4a71d0..3c9d3f10 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -179,6 +179,11 @@ "notifications": "Meldingen", "accessibility": "Toegankelijkheid", "transcription": "Transcriptie", - "shortcuts": "Sneltoetsen" + "shortcuts": "Sneltoetsen", + "rooms": "Vergaderingen" + }, + "roomDefaults": { + "heading": "Standaardinstellingen voor vergaderingen", + "description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen." } }