Compare commits

...

4 Commits

Author SHA1 Message Date
lebaudantoine 69457a01cf ♻️(frontend) make room pattern validation rules reusable
Make the validation rules more maintainable and reusable across the codebase.
Particularly useful when these values need to be referenced in multiple
validation contexts.

The personalize room's link modal needs a step-by-step validation.
2025-01-21 21:12:28 +01:00
lebaudantoine 9b604040af 📝(frontend) add examples of the personalized link format
@manuhabitela create a well-documented modal for users trying to
join a meeting.

Update it with the last room's link format introduced.
2025-01-21 21:12:28 +01:00
lebaudantoine 51dfc46e15 (frontend) introduce a new option while creating room
Display a "personalized" link modal, inspired by Whereby.
The UX and the UI are still cumberstome, however it's fully
functional and accessible.

It's a "okay" v0, allowing users creating their custom link.
Thus we can study this feature traction, and decide whether or
not prioritize this feature in the UX.
2025-01-21 21:12:28 +01:00
lebaudantoine 7529126ca3 (frontend) support custom room names inspired by Jitsi
Following numerous user requests, add support for personalized room names
alongside our existing generated IDs. This pattern, inherited from Jitsi Meet,
allows teams to create memorable, persistent meeting spaces like "daily-standup"
or "teamalpha" instead of relying only on auto-generated IDs.

Note: This change introduces a permanent update to room URL patterns.
Once custom room links are shared, we will need to maintain support for both
formats to avoid breaking existing shared URLs.
2025-01-21 17:45:41 +01:00
7 changed files with 241 additions and 7 deletions
@@ -32,6 +32,7 @@ export const JoinMeetingDialog = () => {
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
<li>teamalpha</li>
</Ul>
</>
) : null
@@ -0,0 +1,138 @@
import { FormEvent, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSpam2Fill } from '@remixicon/react'
import { navigateTo } from '@/navigation/navigateTo.ts'
import { useCreateRoom } from '@/features/rooms'
import {
FieldError,
Form as RACForm,
Input,
Label,
TextField,
} from 'react-aria-components'
import { css } from '@/styled-system/css'
import {
MIN_ROOM_LENGTH,
ALPHANUMERIC_LOWERCASE,
} from '@/features/rooms/utils/isRoomValid'
export const PersonalizeMeetingDialog = ({
isOpen,
...dialogProps
}: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', {
keyPrefix: 'personalizeMeetingDialog',
})
const { mutateAsync: createRoom } = useCreateRoom()
const [roomSlug, setRoomSlug] = useState('')
const [serverErrors, setServerErrors] = useState<Array<string>>([])
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
createRoom({ slug: roomSlug })
.then((data) =>
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
)
.catch((e) => {
const msg =
e.statusCode === 400
? t('errors.server.taken')
: t('errors.server.unknown')
setServerErrors([msg])
})
}
const validationErrors = []
if (roomSlug.length < MIN_ROOM_LENGTH) {
validationErrors.push(t('errors.validation.length'))
}
if (!new RegExp(`^${ALPHANUMERIC_LOWERCASE}+$`).test(roomSlug)) {
validationErrors.push(t('errors.validation.spaceOrSpecialCharacter'))
}
const errors = [...validationErrors, ...serverErrors]
return (
<Dialog isOpen={!!isOpen} {...dialogProps} title={t('heading')}>
<RACForm onSubmit={onSubmit}>
<TextField
isRequired
isInvalid={errors.length > 0}
value={roomSlug}
onChange={(e) => {
setServerErrors([])
setRoomSlug(e.toLowerCase())
}}
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Label>{t('label')}</Label>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
})}
>
<Input
className={css({
height: '46px',
border: '1px solid black',
padding: '4px 8px',
width: '80%',
borderRadius: '0.25rem',
})}
placeholder={t('placeholder')}
/>
<Button type="submit">{t('submit')}</Button>
</div>
<div
className={css({
minHeight: '72px',
})}
>
<FieldError>
<ul
className={css({
listStyle: 'square inside',
color: 'red',
marginLeft: '10px',
marginTop: '10px',
})}
>
{errors.map((error, i) => (
<li key={i}>
<Text as="span" variant={'sm'}>
{error}
</Text>
</li>
))}
</ul>
</FieldError>
</div>
</TextField>
</RACForm>
<HStack>
<div
style={{
backgroundColor: '#d9e5ff',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
}}
>
<RiSpam2Fill size={22} style={{ fill: '#4c84fc' }} />
</div>
<Text variant="sm" style={{ marginTop: '1rem', textWrap: 'balance' }}>
{t('warning')}
</Text>
</HStack>
</Dialog>
)
}
+19 -1
View File
@@ -10,8 +10,9 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { ProConnectButton } from '@/components/ProConnectButton'
import { useCreateRoom } from '@/features/rooms'
import { usePersistentUserChoices } from '@livekit/components-react'
import { RiAddLine, RiLink } from '@remixicon/react'
import { RiAddLine, RiLink, RiUserAddLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { PersonalizeMeetingDialog } from '@/features/home/components/PersonalizeMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
@@ -155,6 +156,7 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const [isPersonalizeModalOpen, setIsPersonalizeModalOpen] = useState(false)
return (
<UserAware>
@@ -209,6 +211,18 @@ export const Home = () => {
<RiLink size={18} />
{t('createMenu.laterOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => {
setIsPersonalizeModalOpen(true)
}}
data-attr="create-option-personalize"
>
<RiUserAddLine size={18} />
{t('createMenu.personalizeOption')}
</MenuItem>
</RACMenu>
</Menu>
) : (
@@ -250,6 +264,10 @@ export const Home = () => {
roomId={laterRoomId || ''}
onOpenChange={() => setLaterRoomId(null)}
/>
<PersonalizeMeetingDialog
isOpen={isPersonalizeModalOpen}
onOpenChange={() => setIsPersonalizeModalOpen(false)}
/>
</Screen>
</UserAware>
)
@@ -1,4 +1,27 @@
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
/**
* Pattern for system-generated room IDs
* Format: xxx-xxxx-xxx (e.g., abc-defg-hij)
* This pattern is used when rooms are automatically created by the system,
* ensuring consistent and predictable room IDs for randomly generated rooms.
*/
export const generatedRoomPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const ALPHANUMERIC_LOWERCASE = '[a-z0-9]'
// Minimum length requirement for personalized rooms
export const MIN_ROOM_LENGTH = 5
/**
* Pattern for user-defined custom room IDs
* Format: Minimum 5 lowercase alphanumeric characters
* This pattern allows users to create memorable, personalized room names
* while maintaining basic validation rules (e.g., myroom123, teamspace, project2024)
*/
export const personalizedRoomPattern = `${ALPHANUMERIC_LOWERCASE}{${MIN_ROOM_LENGTH},}`
// Combined pattern that accepts both system-generated and personalized room IDs
// This allows flexibility in room creation while maintaining consistent validation
export const roomIdPattern = `(?:${generatedRoomPattern}|${personalizedRoomPattern})`
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
+19 -1
View File
@@ -15,7 +15,8 @@
"moreAbout": "",
"createMenu": {
"laterOption": "",
"instantOption": ""
"instantOption": "",
"personalizeOption": ""
},
"laterMeetingDialog": {
"heading": "",
@@ -24,6 +25,23 @@
"copied": "",
"permissions": ""
},
"personalizeMeetingDialog": {
"heading": "",
"label": "",
"submit": "",
"placeholder": "",
"errors": {
"validation": {
"length": "",
"spaceOrSpecialCharacter": ""
},
"server": {
"taken": "",
"unknown": ""
}
},
"warning": ""
},
"introSlider": {
"previous": {
"label": "",
+20 -2
View File
@@ -3,7 +3,7 @@
"heading": "Simple and Secure Video Conferencing",
"intro": "Communicate and work with ease, without compromising your sovereignty",
"joinInputError": "Use a meeting link or code. Examples:",
"joinInputExample": "URL or 10-letter code",
"joinInputExample": "Example of meeting codes: abc-defg-hij ou teamalpha",
"joinInputLabel": "Meeting link",
"joinInputSubmit": "Join meeting",
"joinMeeting": "Join a meeting",
@@ -15,7 +15,8 @@
"moreAbout": "about Visio",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
"instantOption": "Start an instant meeting",
"personalizeOption": "Create a custom link"
},
"laterMeetingDialog": {
"heading": "Your connection details",
@@ -24,6 +25,23 @@
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
},
"personalizeMeetingDialog": {
"heading": "Your custom link",
"label": "Your custom link",
"submit": "Create",
"placeholder": "standupbizdev",
"errors": {
"validation": {
"length": "Must contain at least 5 characters.",
"spaceOrSpecialCharacter": "Cannot contain spaces or special characters"
},
"server": {
"taken": "Already in use, please try something else",
"unknown": "An unknown error occurred, please try again"
}
},
"warning": "This link provides direct access to the meeting. Choose a long and complex name to limit unauthorized access."
},
"introSlider": {
"previous": {
"label": "previous",
+20 -2
View File
@@ -3,7 +3,7 @@
"heading": "Visioconférences simples et sécurisées",
"intro": "Communiquez et travaillez en toute simplicité, sans compromis sur votre souveraineté",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij ou teamalpha",
"joinInputLabel": "Lien complet ou code de la réunion",
"joinInputSubmit": "Rejoindre la réunion",
"joinMeeting": "Rejoindre une réunion",
@@ -15,7 +15,8 @@
"moreAbout": "sur Visio",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
"instantOption": "Démarrer une réunion instantanée",
"personalizeOption": "Créer un lien personnalisé"
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
@@ -24,6 +25,23 @@
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
},
"personalizeMeetingDialog": {
"heading": "Votre lien personnalisé",
"label": "Votre lien personnalisé",
"submit": "Créer",
"placeholder": "standupbizdev",
"errors": {
"validation": {
"length": "Doit contenir au moins 5 caractères.",
"spaceOrSpecialCharacter": "Ne peut pas contenir d'espaces ou de caractères spéciaux"
},
"server": {
"taken": "Déjà utilisé, veuillez essayer autre chose",
"unknown": "Une erreur inconnue s'est produite, veuillez réessayer"
}
},
"warning": "Ce lien permet un accès direct à la réunion. Choisissez un nom long et complexe pour limiter les accès non autorisés."
},
"introSlider": {
"previous": {
"label": "précédent",