Compare commits

..

1 Commits

Author SHA1 Message Date
Arnaud Robin c1be443a46 📝(terms) update terms of service
Enhance the terms of service by adding detailed sections on
service availability, security, support management,
and monitoring of service use. This update aims to provide clearer
guidelines and responsibilities for users and the DINUM administration.
2026-01-06 22:53:31 +01:00
49 changed files with 363 additions and 933 deletions
-16
View File
@@ -147,14 +147,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -186,14 +178,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+2 -22
View File
@@ -8,26 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
@@ -47,7 +27,7 @@ and this project adheres to
## [1.1.0] - 2025-12-22
### Added
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
@@ -59,6 +39,6 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+4 -5
View File
@@ -31,7 +31,6 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
@@ -123,8 +122,9 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
@@ -193,8 +193,7 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+1 -1
View File
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
"aiohttp==3.13.2",
]
[project.urls]
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -42,17 +42,6 @@ export const ControlsButton = ({
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
@@ -108,7 +97,6 @@ export const ControlsButton = ({
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
@@ -141,23 +129,31 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button
@@ -166,7 +162,6 @@ export const ControlsButton = ({
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
@@ -1,4 +1,4 @@
import { H, Text, Icon } from '@/primitives'
import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="login" />
<span className="material-symbols" aria-hidden={true}>
login
</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useMemo } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -21,9 +21,6 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -70,23 +67,6 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const hasScreenRecordingAccessAndActive =
@@ -94,74 +74,61 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
)
}
@@ -1,5 +1,4 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
return <span className="material-symbols">speech_to_text</span>
}
return <Icon type="symbols" name="screen_record" />
return <span className="material-symbols">screen_record</span>
}
@@ -1,4 +1,4 @@
import { Button, Icon, H, Text } from '@/primitives'
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="person_raised_hand" />
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,5 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -46,7 +45,7 @@ export const RowWrapper = ({
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} />
<span className="material-icons">{iconName}</span>
</div>
<div
className={css({
@@ -5,6 +5,7 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '@/features/recording'
@@ -18,6 +19,7 @@ import {
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
@@ -26,7 +28,6 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -37,7 +38,10 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -94,17 +98,14 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
posthog.capture('screen-recording-started', {})
}
} catch (error) {
console.error('Failed to handle recording:', error)
}
}
if (!isAdminOrOwner) {
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -145,7 +146,7 @@ export const ScreenRecordingSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={15}>
<VStack gap={0} marginBottom={30}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
@@ -162,7 +163,7 @@ export const ScreenRecordingSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<VStack gap={0} marginBottom={40}>
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
@@ -117,10 +117,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -181,7 +178,7 @@ export const TranscriptSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={15}>
<VStack gap={0} marginBottom={30}>
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
@@ -198,7 +195,7 @@ export const TranscriptSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<VStack gap={0} marginBottom={40}>
<RowWrapper iconName="article" position="first">
<Text variant="sm">
{data?.transcription_destination ? (
@@ -11,7 +11,6 @@ export const useHumanizeRecordingMaxDuration = () => {
return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
delimiter: ' ',
})
}, [data])
}
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({
roomId,
@@ -228,20 +228,10 @@ export const Conference = ({
posthog.captureException(e)
}}
onDisconnected={(e) => {
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo('feedback')
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
}
}}
onMediaDeviceFailure={(e, kind) => {
@@ -117,9 +117,7 @@ export const ParticipantTile: (
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
const target = event.target as HTMLElement | null
const isFocusVisible = !!target?.matches?.(':focus-visible')
setHasKeyboardFocus(isFocusVisible)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
@@ -16,7 +16,6 @@ import { Info } from './Info'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
@@ -27,7 +26,6 @@ type StyledSidePanelProps = {
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
@@ -35,7 +33,7 @@ const StyledSidePanel = ({
isSubmenu = false,
onBack,
}: StyledSidePanelProps) => (
<aside
<div
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -60,8 +58,6 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<Heading
slot="title"
@@ -78,7 +74,7 @@ const StyledSidePanel = ({
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
size={'sm'}
square
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
@@ -108,7 +104,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</aside>
</div>
)
type PanelProps = {
@@ -148,7 +144,6 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -169,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -1,10 +1,9 @@
import { A, Div, Icon, Text } from '@/primitives'
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -87,7 +86,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<Icon type="symbols" name="chevron_forward" />
<span className="material-symbols">chevron_forward</span>
</div>
</RACButton>
)
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -163,7 +146,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
icon={<span className="material-symbols">speech_to_text</span>}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -171,7 +154,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<Icon type="symbols" name="mode_standby" />}
icon={<span className="material-symbols">mode_standby</span>}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
@@ -9,7 +9,6 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -9,13 +9,13 @@ import {
} from '../blur'
import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,17 +50,11 @@ export const EffectsConfiguration = ({
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -75,78 +69,16 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
},
[]
)
const announceBlurStatusMessage = (message: string) => {
blurAnnouncementId.current += 1
const currentId = blurAnnouncementId.current
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setBlurStatusMessage('')
blurAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== blurAnnouncementId.current) return
setBlurStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const updateBlurStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (type !== ProcessorType.BLUR) return
let message = ''
if (wasSelectedBeforeToggle) {
message = t('blur.status.none')
} else if (options.blurRadius === BlurRadius.LIGHT) {
message = t('blur.status.light')
} else if (options.blurRadius === BlurRadius.NORMAL) {
message = t('blur.status.strong')
}
if (message) {
announceBlurStatusMessage(message)
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
@@ -172,7 +104,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (wasSelectedBeforeToggle) {
if (isSelected(type, options)) {
// Stop processor.
await clearEffect()
} else if (
@@ -199,8 +131,6 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -223,28 +153,8 @@ export const EffectsConfiguration = ({
)
}
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
@@ -355,60 +265,65 @@ export const EffectsConfiguration = ({
>
{t('blur.title')}
</H>
<div>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
>
<ToggleButton
ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{blurStatusMessage}
</div>
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div
className={css({
@@ -428,37 +343,39 @@ export const EffectsConfiguration = ({
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return (
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
<ToggleButton
variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
)
})}
</div>
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React from 'react'
import React, { useEffect } from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -39,16 +38,33 @@ export function Chat({ ...props }: ChatProps) {
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -73,7 +73,6 @@ export function MobileControlBar({
/>
<HandToggle />
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -37,7 +37,6 @@ export const LateralMenu = () => {
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
id="controlbar-more-options-trigger"
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
@@ -3,9 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import { useLocation, useSearchParams } from 'wouter'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -22,40 +20,25 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const reasonKey = useMemo(() => {
const state = window.history.state
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -107,7 +106,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
<span className="material-symbols">speech_to_text</span>
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
@@ -18,14 +18,6 @@ export const useTranscriptionLanguage = () => {
key: RecordingLanguage.ENGLISH,
label: t('language.options.english'),
},
{
key: RecordingLanguage.GERMAN,
label: t('language.options.german'),
},
{
key: RecordingLanguage.DUTCH,
label: t('language.options.dutch'),
},
{
key: RecordingLanguage.AUTOMATIC,
label: t('language.options.auto'),
@@ -2,14 +2,10 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -24,18 +20,6 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles
}
useEffect(() => {
if (!room) return
const closeSubtitles = () => {
layoutStore.showSubtitles = false
}
room.on(RoomEvent.Disconnected, closeSubtitles)
return () => {
room.off(RoomEvent.Disconnected, closeSubtitles)
}
}, [room])
return {
areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles,
-61
View File
@@ -1,61 +0,0 @@
import { useEffect, useRef } from 'react'
export type RestoreFocusOptions = {
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
onOpened?: () => void
onClosed?: () => void
restoreFocusRaf?: boolean
preventScroll?: boolean
}
/**
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
* when the panel/menu closes.
*/
export function useRestoreFocus(
isOpen: boolean,
options: RestoreFocusOptions = {}
) {
const {
resolveTrigger,
onOpened,
onClosed,
restoreFocusRaf = false,
preventScroll = true,
} = options
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const wasOpen = prevIsOpenRef.current
// Just opened
if (!wasOpen && isOpen) {
const activeEl = document.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
onOpened?.()
}
// Just closed
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
}, [
isOpen,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
])
}
+7 -28
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -234,34 +233,15 @@
"clear": "Effekt deaktivieren",
"blur": {
"title": "Hintergrundunschärfe",
"status": {
"none": "Hintergrund scharf.",
"light": "Hintergrund leicht verschwommen.",
"strong": "Hintergrund stark verschwommen."
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
"light": "Leichte Unschärfe",
"normal": "Unschärfe",
"apply": "Unschärfe aktivieren",
"clear": "Unschärfe deaktivieren"
},
"virtual": {
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
"apply": "Virtuellen Hintergrund aktivieren",
"clear": "Virtuellen Hintergrund deaktivieren"
},
"faceLandmarks": {
"title": "Visuelle Effekte",
@@ -276,7 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
@@ -75,8 +75,6 @@
"options": {
"french": "Französisch (fr)",
"english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch"
}
}
+13 -34
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by an administrator"
"duplicateIdentity": "You have joined the meeting from another device"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -16,7 +15,7 @@
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Backgrounds and Effects",
"effects": "Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
@@ -39,7 +38,7 @@
},
"join": {
"effects": {
"description": "Apply backgrounds and effects",
"description": "Apply effects",
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
@@ -217,7 +216,7 @@
"screenRecording": "Screen recording",
"transcript": "Transcription",
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"effects": "Apply effects",
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
@@ -234,34 +233,15 @@
"clear": "Disable effect",
"blur": {
"title": "Background blur",
"status": {
"none": "Background clear.",
"light": "Background slightly blurred.",
"strong": "Background strongly blurred."
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"virtual": {
"title": "Virtual background",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"faceLandmarks": {
"title": "Visual Effects",
@@ -276,10 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"heading": {
"participants": "Participants",
"effects": "Backgrounds and Effects",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
@@ -289,7 +268,7 @@
},
"content": {
"participants": "participants",
"effects": "Backgrounds and Effects",
"effects": "effects",
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
@@ -564,7 +543,7 @@
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply backgrounds and effects",
"effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
@@ -75,8 +75,6 @@
"options": {
"french": "French (fr)",
"english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic"
}
}
+14 -35
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -16,7 +15,7 @@
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -40,8 +39,8 @@
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Arrière-plans et effets",
"title": "Arrière-plans et effets",
"description": "Effets d'arrière-plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
@@ -217,7 +216,7 @@
"screenRecording": "Enregistrement",
"transcript": "Transcription",
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -231,37 +230,18 @@
"cameraDisabled": "Votre caméra est désactivée.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effet",
"clear": "Désactiver l'effect",
"blur": {
"title": "Flou d'arrière-plan",
"status": {
"none": "Arrière-plan net.",
"light": "Arrière-plan légèrement flouté.",
"strong": "Arrière-plan fortement flouté."
},
"light": {
"apply": "Flouter l'arrière-plan",
"clear": "Désactiver le flou"
},
"normal": {
"apply": "Flouter encore plus l'arrière-plan",
"clear": "Désactiver le flou"
}
"light": "Flou léger",
"normal": "Flou",
"apply": "Activer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
"apply": "Activer l'arrière-plan virtuel",
"clear": "Désactiver l'arrière-plan virtuel"
},
"faceLandmarks": {
"title": "Effets visuels",
@@ -276,10 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"heading": {
"participants": "Participants",
"effects": "Arrière-plans et effets",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Transcrire",
"screenRecording": "Enregistrer",
@@ -564,7 +543,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
@@ -75,8 +75,6 @@
"options": {
"french": "Français (fr)",
"english": "Anglais (en)",
"dutch": "Hollandais (nl)",
"german": "Allemand (de)",
"auto": "Automatique"
}
}
+7 -28
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -234,34 +233,15 @@
"clear": "Effect uitschakelen",
"blur": {
"title": "Achtergrondvervaging",
"status": {
"none": "Achtergrond scherp.",
"light": "Achtergrond licht vervaagd.",
"strong": "Achtergrond sterk vervaagd."
},
"light": {
"apply": "Vervaag je achtergrond lichtjes",
"clear": "Vervaging uitschakelen"
},
"normal": {
"apply": "Vervaag je achtergrond",
"clear": "Vervaging uitschakelen"
}
"light": "Lichte vervaging",
"normal": "Vervaging",
"apply": "Vervaging inschakelen",
"clear": "Vervaging uitschakelen"
},
"virtual": {
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
"apply": "Virtuele achtergrond inschakelen",
"clear": "Virtuele achtergrond uitschakelen"
},
"faceLandmarks": {
"title": "Visuele effecten",
@@ -276,7 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
@@ -75,8 +75,6 @@
"options": {
"french": "Frans (fr)",
"english": "Engels (en)",
"dutch": "Nederlands (nl)",
"german": "Duits (de)",
"auto": "Automatisch"
}
}
+22 -21
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react'
import { ReactNode } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,24 +17,25 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
-71
View File
@@ -1,71 +0,0 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { ComponentPropsWithoutRef } from 'react'
const iconRecipe = cva({
base: {
fontWeight: 'normal',
fontStyle: 'normal',
display: 'inline-block',
lineHeight: 1,
textTransform: 'none',
letterSpacing: 'normal',
wordWrap: 'normal',
whiteSpace: 'nowrap',
direction: 'ltr',
},
variants: {
type: {
icons: {
fontFamily: 'Material Icons Outlined',
webkitFontSmoothing: 'antialiased',
mozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontFeatureSettings: '"liga"',
},
symbols: {
fontFamily: 'Material Symbols Outlined Variable',
},
},
size: {
sm: {
fontSize: '18px',
},
md: {
fontSize: '24px',
},
lg: {
fontSize: '32px',
},
xl: {
fontSize: '40px',
},
},
},
defaultVariants: {
type: 'icons',
size: 'md',
},
})
export type IconRecipeProps = RecipeVariantProps<typeof iconRecipe>
export type IconProps = IconRecipeProps &
ComponentPropsWithoutRef<'span'> & {
name: string
}
export const Icon = ({ name, ...props }: IconProps) => {
const [variantProps, componentProps] = iconRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<span
translate="no"
aria-hidden="true"
className={[iconRecipe(variantProps), className].join(' ')}
{...remainingComponentProps}
>
{name}
</span>
)
}
+21 -22
View File
@@ -1,10 +1,10 @@
import { forwardRef, ReactNode } from 'react'
import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
@@ -16,25 +16,24 @@ export type ToggleButtonProps = RACToggleButtonProps &
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
({ tooltip, tooltipType, ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
)
ToggleButton.displayName = 'ToggleButton'
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
@@ -1,88 +0,0 @@
import { type ReactNode, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
tooltip: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
tooltip,
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const getPosition = () => {
if (!wrapperRef.current) return null
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
left: rect.left + rect.width / 2,
}
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</div>
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
className={css({
position: 'fixed',
padding: '2px 8px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
borderRadius: '4px',
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
document.body
)}
</>
)
}
-1
View File
@@ -30,4 +30,3 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
export { Icon } from './Icon'
+5
View File
@@ -43,6 +43,11 @@ export const routes: Record<
feedback: {
name: 'feedback',
path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute,
},
legalTerms: {
-2
View File
@@ -3,8 +3,6 @@ import { proxy } from 'valtio'
export enum RecordingLanguage {
ENGLISH = 'en',
FRENCH = 'fr',
DUTCH = 'nl',
GERMAN = 'de',
AUTOMATIC = 'auto',
}
+27
View File
@@ -0,0 +1,27 @@
@import '@fontsource/material-icons-outlined';
.material-icons,
.material-symbols {
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.material-icons {
font-family: 'Material Icons Outlined';
}
.material-symbols {
font-family: 'Material Symbols Outlined Variable';
}
+1 -45
View File
@@ -1,4 +1,5 @@
@import './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities;
html,
body,
@@ -6,18 +7,6 @@ body,
height: 100%;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
* {
outline: 2px solid transparent;
}
@@ -53,36 +42,3 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
transform: translateY(0);
}
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-icons-outlined/install
*/
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-display: block;
font-weight: 400;
src: url(@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2)
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-symbols-outlined/install
*/
/* material-symbols-outlined-latin-wght-normal */
@font-face {
font-family: 'Material Symbols Outlined Variable';
font-style: normal;
font-display: block;
font-weight: 100 700;
src: url(@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -267,7 +267,6 @@ agents:
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
image:
repository: localhost:5001/meet-agents
+1 -1
View File
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
whisperx_max_retries: int = 0
# ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None
whisperx_allowed_languages: Set[str] = {"en", "fr", "de", "nl"}
whisperx_allowed_languages: Set[str] = {"en", "fr"}
llm_base_url: str
llm_api_key: SecretStr
llm_model: str