mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 12:09:08 +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:
committed by
aleb_the_flash
parent
18d7137a7a
commit
f598c3bf2e
@@ -15,6 +15,7 @@ and this project adheres to
|
|||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
|
- ✨(frontend) let users set default configuration for generated links
|
||||||
|
|
||||||
### Changed
|
### 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 { 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 { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
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 { useUser } from '@/features/auth/api/useUser'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
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'>
|
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) => {
|
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
|
const dialogEl = useRef<HTMLDivElement>(null)
|
||||||
|
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||||
|
|
||||||
const userDisplay =
|
const userDisplay =
|
||||||
user?.full_name && user?.email
|
user?.full_name && user?.email
|
||||||
? `${user.full_name} (${user.email})`
|
? `${user.full_name} (${user.email})`
|
||||||
: 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>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -47,6 +104,56 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
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>
|
</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",
|
"notifications": "Benachrichtigungen",
|
||||||
"accessibility": "Barrierefreiheit",
|
"accessibility": "Barrierefreiheit",
|
||||||
"transcription": "Transkription",
|
"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",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription",
|
"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",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription",
|
"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",
|
"notifications": "Meldingen",
|
||||||
"accessibility": "Toegankelijkheid",
|
"accessibility": "Toegankelijkheid",
|
||||||
"transcription": "Transcriptie",
|
"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