mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-05 16:37:43 +00:00
✨(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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user