(frontend) let users set default configuration for generated links

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.
This commit is contained in:
lebaudantoine
2026-08-03 11:32:44 +02:00
parent 2e509eff28
commit 15b1ab7e0a
8 changed files with 396 additions and 6 deletions
+1
View File
@@ -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
@@ -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<ApiUser>(`/users/${userId}/`, {
method: 'PATCH',
body: JSON.stringify(user),
})
}
export const patchUserMutationKey = ['patchUser']
export function usePatchUser(
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
) {
return useMutation<ApiUser, ApiError, PatchUserParams>({
mutationKey: patchUserMutationKey,
mutationFn: patchUser,
...options,
})
}
@@ -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<DialogProps, 'isOpen' | 'onOpenChange'>
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<HTMLDivElement>(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 (
<Dialog title={t('dialog.heading')} {...props}>
const generalContent = (
<div
className={css({
display: 'flex',
flexDirection: 'column',
minWidth: '360px',
})}
>
<H lvl={2}>{t('account.heading')}</H>
{isLoggedIn ? (
<>
@@ -47,6 +104,56 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
i18n.changeLanguage(lang as string)
}}
/>
</div>
)
// Without tabs there is no rail to host the heading, so keep the plain dialog.
if (!isLoggedIn) {
return (
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
{generalContent}
</Dialog>
)
}
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs
orientation="vertical"
className={tabsStyle}
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
>
<div
className={tabListContainerStyle}
style={{
flex: isWideScreen ? '0 0 16rem' : undefined,
paddingTop: !isWideScreen ? '64px' : undefined,
paddingRight: !isWideScreen ? '1rem' : undefined,
}}
>
{isWideScreen && (
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
{t('dialog.heading')}
</Heading>
)}
<TabList border={false}>
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
</Tab>
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
<RiDoorOpenLine />
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
{generalContent}
</TabPanel>
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
</div>
</Tabs>
</Dialog>
)
}
@@ -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<ApiUser | false>([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 (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
<Text variant="note" margin={'md'}>
{t('description')}
</Text>
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
flexShrink: 0,
background: 'greyscale.250',
})}
/>
<H
lvl={3}
variant={'h2'}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{tAdmin('moderation.title')}
</H>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
})}
margin={'md'}
>
{tAdmin('moderation.description')}
</Text>
<Field
type="switch"
label={tAdmin('moderation.microphone.label')}
isSelected={isMicrophoneEnabled}
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
/>
<Field
type="switch"
label={tAdmin('moderation.camera.label')}
isSelected={isCameraEnabled}
onChange={(enabled) => updateSource([Source.Camera], enabled)}
/>
<Field
type="switch"
label={tAdmin('moderation.screenshare.label')}
isSelected={isScreenShareEnabled}
onChange={(enabled) =>
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
}
/>
<Field
type="switch"
label={tAdmin('moderation.mute.label')}
isSelected={isMutingEnabled}
onChange={(enabled) =>
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
}
/>
<RACSeparator
className={css({
border: 'none',
height: '1px',
width: '100%',
flexShrink: 0,
marginY: '1rem',
background: 'greyscale.250',
})}
/>
<H
lvl={3}
variant={'h2'}
className={css({
fontWeight: 500,
})}
margin="sm"
>
{tAdmin('access.title')}
</H>
<Field
type="radioGroup"
label={tAdmin('access.type')}
value={accessLevel}
labelProps={{
className: css({
fontSize: '1rem',
paddingBottom: '1rem',
}),
}}
onChange={(value) => 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'),
},
]}
/>
</TabPanel>
)
}
+6 -1
View File
@@ -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."
}
}
+6 -1
View File
@@ -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."
}
}
+6 -1
View File
@@ -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 dadministration."
}
}
+6 -1
View File
@@ -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."
}
}