Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine c149c8ce9c 💄(frontend) add minor layout adjustments
Propose minor layout adjustments to ensure the DINUM version with French
copywriting does not look visually awkward due to line breaks.
2026-01-05 17:43:40 +01:00
lebaudantoine 198442b137 🐛(backend) fix certificates volume mount path for Python 3.13
After upgrading Python to 3.13, not all development environments were
updated accordingly. This fixes the incorrect volume mount path
introduced by that upgrade.
2026-01-05 17:43:37 +01:00
85 changed files with 581 additions and 1604 deletions
-16
View File
@@ -147,14 +147,6 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -186,14 +178,6 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} 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 name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
+3 -48
View File
@@ -8,64 +8,19 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
## [1.3.0] - 2026-01-13
### 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
- ✨(agent) support Kyutai client for subtitle - ✨(agent) support Kyutai client for subtitle
- ✨(frontend) remove the beta badge
- ✨(all) support starting transcription and recording simultaneously - ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording - ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language - ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file - ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording - ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service - ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side - ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22 ## [1.1.0] - 2025-12-22
### Added ### Added
- ✨(backend) enable user creation via email for external integrations - ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls - ✨(summary) add Langfuse observability for LLM API calls
@@ -77,6 +32,6 @@ and this project adheres to
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803 - ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813 - ♿️(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) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807 - ♿️(frontend) Improve focus management when opening and closing chat #807
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation # Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates # Upgrade system packages to install security updates
RUN apk update && \ RUN apk update && \
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose # Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md). We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements ## Requirements
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev" APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password" APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage" AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000" AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false AWS_S3_SECURE_ACCESS=false
+4 -5
View File
@@ -31,7 +31,6 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber") TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram") STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider(): def create_stt_provider():
@@ -123,8 +122,9 @@ class MultiUserTranscriber:
if participant.identity in self._sessions: if participant.identity in self._sessions:
return self._sessions[participant.identity] return self._sessions[participant.identity]
vad = self.ctx.proc.userdata.get("vad", None) session = AgentSession(
session = AgentSession(vad=vad) vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO( room_io = RoomIO(
agent_session=session, agent_session=session,
room=self.ctx.room, room=self.ctx.room,
@@ -193,8 +193,7 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess): def prewarm(proc: JobProcess):
"""Preload voice activity detection model.""" """Preload voice activity detection model."""
if ENABLE_SILERO_VAD: proc.userdata["vad"] = silero.VAD.load()
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.3.0" version = "1.1.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.3.10", "livekit-agents==1.3.10",
+2 -6
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
import jwt import jwt
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
) )
try: try:
user = models.User.objects.get(email__iexact=email) user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e: except models.User.DoesNotExist as e:
if ( if (
settings.APPLICATION_ALLOW_USER_CREATION settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,10 +123,6 @@ class ApplicationViewSet(viewsets.GenericViewSet):
) )
else: else:
raise drf_exceptions.NotFound("User not found.") from e raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or []) scope = " ".join(application.scopes or [])
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings): def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token.""" """Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey" settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com") user = UserFactory(email="user@example.com")
application = ApplicationFactory( application = ApplicationFactory(
active=True, active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE], scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id, "client_id": application.client_id,
"client_secret": plain_secret, "client_secret": plain_secret,
"grant_type": "client_credentials", "grant_type": "client_credentials",
"scope": "user.family@example.com", "scope": user.email,
}, },
format="json", format="json",
) )
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "1.3.0" version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0", "whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1", "mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7", "livekit-api==1.0.7",
"aiohttp==3.13.3", "aiohttp==3.13.2",
] ]
[project.urls] [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="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest"> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
</head> </head>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.3.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.3.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30", "@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6", "@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.3.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer> <SlideContainer>
{SLIDES.map((slide, index) => ( {SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}> <Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" /> <Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}> <TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading> <Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body> <Body>{t(`${slide.key}.body`)}</Body>
@@ -111,13 +111,15 @@ export const TermsOfServiceRoute = () => {
))} ))}
{/* Article 7 */} {/* Article 7 */}
<H lvl={2}>{t('articles.article7.title')}</H> <H lvl={2} margin={false}>
<P>{t('articles.article7.content')}</P> {t('articles.article7.title')}
</H>
{/* Section 7.1 */} {/* Section 7.1 */}
<H lvl={3} bold> <H lvl={3} bold>
{t('articles.article7.sections.section1.title')} {t('articles.article7.sections.section1.title')}
</H> </H>
<P>{t('articles.article7.sections.section1.content')}</P>
{ensureArray( {ensureArray(
t('articles.article7.sections.section1.paragraphs', { t('articles.article7.sections.section1.paragraphs', {
returnObjects: true, returnObjects: true,
@@ -130,51 +132,16 @@ export const TermsOfServiceRoute = () => {
<H lvl={3} bold> <H lvl={3} bold>
{t('articles.article7.sections.section2.title')} {t('articles.article7.sections.section2.title')}
</H> </H>
{ensureArray(
t('articles.article7.sections.section2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.3 */}
<H lvl={3} bold>
{t('articles.article7.sections.section3.title')}
</H>
{ensureArray(
t('articles.article7.sections.section3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.4 */}
<H lvl={3} bold>
{t('articles.article7.sections.section4.title')}
</H>
{ensureArray(
t('articles.article7.sections.section4.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.5 */}
<H lvl={3} bold>
{t('articles.article7.sections.section5.title')}
</H>
<P> <P>
{t('articles.article7.sections.section5.content') {t('articles.article7.sections.section2.content')
.split('https://github.com/suitenumerique/meet')[0] .split('https://github.com/suitenumerique/meet')[0]
.replace('https://github.com/suitenumerique/meet', '')}{' '} .replace('https://github.com/suitenumerique/meet', '')}{' '}
<A href="https://github.com/suitenumerique/meet" color="primary"> <A href="https://github.com/suitenumerique/meet" color="primary">
https://github.com/suitenumerique/meet https://github.com/suitenumerique/meet
</A> </A>
{'. '}
{ {
t('articles.article7.sections.section5.content').split( t('articles.article7.sections.section2.content').split(
'https://github.com/suitenumerique/meet' 'https://github.com/suitenumerique/meet'
)[1] )[1]
} }
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css' import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives' import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses' import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react' import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -42,17 +42,6 @@ export const ControlsButton = ({
}: ControlsButtonProps) => { }: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix }) 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 room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected const isRoomConnected = room.state == ConnectionState.Connected
@@ -108,7 +97,6 @@ export const ControlsButton = ({
fullWidth fullWidth
onPress={handle} onPress={handle}
isDisabled={isDisabled} isDisabled={isDisabled}
ref={primaryActionRef}
> >
{t('button.stop')} {t('button.stop')}
</Button> </Button>
@@ -141,23 +129,31 @@ export const ControlsButton = ({
})} })}
onPress={() => openSidePanel()} onPress={() => openSidePanel()}
> >
<Icon <span
className={css({ className={cx(
color: 'primary.500', 'material-icons',
marginRight: '1rem', css({
})} color: 'primary.500',
name="info" marginRight: '1rem',
/> })
)}
>
info
</span>
<Text variant={'smNote'}> <Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))} {parseLineBreaks(t('button.anotherModeStarted'))}
</Text> </Text>
<Icon <span
className={css({ className={cx(
color: 'primary.500', 'material-icons',
marginLeft: 'auto', css({
})} color: 'primary.500',
name="chevron_right" marginLeft: 'auto',
/> })
)}
>
chevron_right
</span>
</RACButton> </RACButton>
)} )}
<Button <Button
@@ -166,7 +162,6 @@ export const ControlsButton = ({
onPress={handle} onPress={handle}
isDisabled={isDisabled} isDisabled={isDisabled}
size="compact" size="compact"
ref={primaryActionRef}
> >
{t('button.start')} {t('button.start')}
</Button> </Button>
@@ -1,4 +1,4 @@
import { H, Text, Icon } from '@/primitives' import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton' import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})} })}
> >
<HStack justify="start" alignItems="center" marginBottom="0.5rem"> <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}> <H lvl={3} margin={false} padding={false}>
{heading} {heading}
</H> </H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react' import { useMemo } from 'react'
import { Text } from '@/primitives' import { Text } from '@/primitives'
import { import {
RecordingMode, RecordingMode,
@@ -21,9 +21,6 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel() const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess( const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript, RecordingMode.Transcript,
FeatureFlags.Transcript FeatureFlags.Transcript
@@ -70,23 +67,6 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}` return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording]) }, [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 if (!key) return null
const hasScreenRecordingAccessAndActive = const hasScreenRecordingAccessAndActive =
@@ -94,74 +74,61 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return ( return (
<> <div
{/* Screen reader only message to announce state changes once */} className={css({
<div display: 'flex',
role="status" position: 'fixed',
aria-live="polite" top: '10px',
aria-atomic="true" left: '10px',
className="sr-only" paddingY: '0.25rem',
> paddingX: '0.75rem 0.75rem',
{srMessage} backgroundColor: 'danger.700',
</div> borderColor: 'white',
{/* Visual banner (without aria-live to avoid duplicate announcements) */} border: '1px solid',
<div color: 'white',
className={css({ borderRadius: '4px',
display: 'flex', gap: '0.5rem',
position: 'fixed', })}
top: '10px', >
left: '10px', <RecordingStatusIcon
paddingY: '0.25rem', isStarted={isStarted}
paddingX: '0.75rem 0.75rem', isTranscriptActive={isTranscriptActive}
backgroundColor: 'danger.700', />
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive && {!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
!hasTranscriptAccessAndActive && ( <Text
<Text variant={'sm'}
variant={'sm'} className={css({
className={css({ fontWeight: '500 !important',
fontWeight: '500 !important', })}
})} >
> {t(key)}
{t(key)} </Text>
</Text> )}
)} {hasScreenRecordingAccessAndActive && (
{hasScreenRecordingAccessAndActive && ( <RACButton
<RACButton onPress={openScreenRecording}
onPress={openScreenRecording} className={css({
className={css({ textStyle: 'sm !important',
textStyle: 'sm !important', fontWeight: '500 !important',
fontWeight: '500 !important', cursor: 'pointer',
cursor: 'pointer', })}
})} >
> {t(key)}
{t(key)} </RACButton>
</RACButton> )}
)} {hasTranscriptAccessAndActive && (
{hasTranscriptAccessAndActive && ( <RACButton
<RACButton onPress={openTranscript}
onPress={openTranscript} className={css({
className={css({ textStyle: 'sm !important',
textStyle: 'sm !important', fontWeight: '500 !important',
fontWeight: '500 !important', cursor: 'pointer',
cursor: 'pointer', })}
})} >
> {t(key)}
{t(key)} </RACButton>
</RACButton> )}
)} </div>
</div>
</>
) )
} }
@@ -1,5 +1,4 @@
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps { interface RecordingStatusIconProps {
isStarted: boolean isStarted: boolean
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
} }
if (isTranscriptActive) { 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 { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})} })}
> >
<HStack justify="start" alignItems="center" marginBottom="0.5rem"> <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}> <H lvl={3} margin={false} padding={false}>
{heading} {heading}
</H> </H>
@@ -1,6 +1,5 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single' type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -46,7 +45,7 @@ export const RowWrapper = ({
})} })}
> >
{/* fixme - doesn't handle properly material-symbols */} {/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} /> <span className="material-icons">{iconName}</span>
</div> </div>
<div <div
className={css({ className={css({
@@ -5,6 +5,7 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { import {
RecordingMode, RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration, useHumanizeRecordingMaxDuration,
useRecordingStatuses, useRecordingStatuses,
} from '@/features/recording' } from '@/features/recording'
@@ -18,6 +19,7 @@ import {
} from '@/features/notifications' } from '@/features/notifications'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView' import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton' import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper' import { RowWrapper } from './RowWrapper'
@@ -26,7 +28,6 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings' import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording' import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => { export const ScreenRecordingSidePanel = () => {
const { data } = useConfig() const { data } = useConfig()
@@ -37,7 +38,10 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false) const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner() const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const { notifyParticipants } = useNotifyParticipants() const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } = const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -94,17 +98,14 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.ScreenRecordingStarted, type: NotificationType.ScreenRecordingStarted,
}) })
posthog.capture('screen-recording-started', { posthog.capture('screen-recording-started', {})
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
} }
} catch (error) { } catch (error) {
console.error('Failed to handle recording:', error) console.error('Failed to handle recording:', error)
} }
} }
if (!isAdminOrOwner) { if (hasFeatureWithoutAdminRights) {
return ( return (
<NoAccessView <NoAccessView
i18nKeyPrefix={keyPrefix} 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> <H lvl={1} margin={'sm'} fullWidth>
{t('heading')} {t('heading')}
</H> </H>
@@ -162,7 +163,7 @@ export const ScreenRecordingSidePanel = () => {
)} )}
</Text> </Text>
</VStack> </VStack>
<VStack gap={0} marginBottom={25}> <VStack gap={0} marginBottom={40}>
<RowWrapper iconName="cloud_download" position="first"> <RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text> <Text variant="sm">{t('details.destination')}</Text>
</RowWrapper> </RowWrapper>
@@ -117,10 +117,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({ await notifyParticipants({
type: NotificationType.TranscriptionStarted, type: NotificationType.TranscriptionStarted,
}) })
posthog.capture('transcript-started', { posthog.capture('transcript-started', {})
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
} }
} catch (error) { } catch (error) {
console.error('Failed to handle transcript:', 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'}> <H lvl={1} margin={'sm'}>
{t('heading')} {t('heading')}
</H> </H>
@@ -198,7 +195,7 @@ export const TranscriptSidePanel = () => {
)} )}
</Text> </Text>
</VStack> </VStack>
<VStack gap={0} marginBottom={25}> <VStack gap={0} marginBottom={40}>
<RowWrapper iconName="article" position="first"> <RowWrapper iconName="article" position="first">
<Text variant="sm"> <Text variant="sm">
{data?.transcription_destination ? ( {data?.transcription_destination ? (
@@ -11,7 +11,6 @@ export const useHumanizeRecordingMaxDuration = () => {
return humanizeDuration(data?.recording?.max_duration, { return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language, language: i18n.language,
delimiter: ' ',
}) })
}, [data]) }, [data])
} }
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react' import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -228,20 +228,10 @@ export const Conference = ({
posthog.captureException(e) posthog.captureException(e)
}} }}
onDisconnected={(e) => { onDisconnected={(e) => {
switch (e) { if (e == DisconnectReason.CLIENT_INITIATED) {
case DisconnectReason.CLIENT_INITIATED: navigateTo('feedback', { duplicateIdentity: false })
navigateTo('feedback') } else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
return navigateTo('feedback', { duplicateIdentity: true })
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
} }
}} }}
onMediaDeviceFailure={(e, kind) => { onMediaDeviceFailure={(e, kind) => {
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square square
size={'sm'} size={'sm'}
onPress={copyRoomUrlToClipboard} onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')} aria-label={t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')} tooltip={t('copyUrl')}
> >
{isRoomUrlCopied ? ( {isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" /> <RiCheckLine aria-hidden="true" />
@@ -138,12 +138,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)} {formatPinCode(roomData?.pin_code)}
</Text> </Text>
</div> </div>
<Button <Button
variant={isCopied ? 'success' : 'secondaryText'} variant={isCopied ? 'success' : 'secondaryText'}
size="sm" size="sm"
fullWidth fullWidth
aria-label={isCopied ? t('copied') : t('copy')} aria-label={t('copy')}
style={{ style={{
justifyContent: 'start', justifyContent: 'start',
}} }}
@@ -174,7 +173,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button <Button
variant={isCopied ? 'success' : 'tertiary'} variant={isCopied ? 'success' : 'tertiary'}
fullWidth fullWidth
aria-label={isCopied ? t('copied') : t('copy')} aria-label={t('copy')}
onPress={copyRoomToClipboard} onPress={copyRoomToClipboard}
data-attr="share-dialog-copy" data-attr="share-dialog-copy"
> >
@@ -117,9 +117,7 @@ export const ParticipantTile: (
'aria-label': t('containerLabel', { name: participantName }), 'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => { onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event) elementProps.onFocus?.(event)
const target = event.target as HTMLElement | null setHasKeyboardFocus(true)
const isFocusVisible = !!target?.matches?.(':focus-visible')
setHasKeyboardFocus(isFocusVisible)
}, },
onBlur: (event: React.FocusEvent<HTMLDivElement>) => { onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event) elementProps.onBlur?.(event)
@@ -5,9 +5,6 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle' import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000 export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300 export const ANIMATION_DISTANCE = 300
@@ -143,53 +140,11 @@ export function ReactionPortal({
) )
} }
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) reactions.map((instance) => (
const { announceReactions } = useSnapshot(accessibilityStore) <ReactionPortal
const [announcement, setAnnouncement] = useState<string | null>(null) key={instance.id}
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null) emoji={instance.emoji}
participant={instance.participant}
const latestReaction = />
reactions.length > 0 ? reactions[reactions.length - 1] : undefined ))
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -13,32 +13,27 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin' import { Admin } from './Admin'
import { Tools } from './Tools' import { Tools } from './Tools'
import { Info } from './Info' import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
ariaLabel: string
children: ReactNode children: ReactNode
onClose: () => void onClose: () => void
isClosed: boolean isClosed: boolean
closeButtonTooltip: string closeButtonTooltip: string
isSubmenu: boolean isSubmenu: boolean
onBack: () => void onBack: () => void
backButtonLabel: string
} }
const StyledSidePanel = ({ const StyledSidePanel = ({
title, title,
ariaLabel,
children, children,
onClose, onClose,
isClosed, isClosed,
closeButtonTooltip, closeButtonTooltip,
isSubmenu = false, isSubmenu = false,
onBack, onBack,
backButtonLabel,
}: StyledSidePanelProps) => ( }: StyledSidePanelProps) => (
<aside <div
className={css({ className={css({
borderWidth: '1px', borderWidth: '1px',
borderStyle: 'solid', borderStyle: 'solid',
@@ -63,37 +58,32 @@ const StyledSidePanel = ({
style={{ style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none', transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}} }}
aria-hidden={isClosed}
aria-label={ariaLabel}
> >
<HStack alignItems="center"> <Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && ( {isSubmenu && (
<Button <Button
variant="secondaryText" variant="secondaryText"
size="sm" size={'sm'}
square square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })} className={css({ marginRight: '0.5rem' })}
aria-label={backButtonLabel}
onPress={onBack} onPress={onBack}
> >
<RiArrowLeftLine size={20} aria-hidden="true" /> <RiArrowLeftLine size={20} />
</Button> </Button>
)} )}
<Heading {title}
slot="title" </Heading>
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
<Div <Div
position="absolute" position="absolute"
top="5" top="5"
@@ -114,7 +104,7 @@ const StyledSidePanel = ({
</Button> </Button>
</Div> </Div>
{children} {children}
</aside> </div>
) )
type PanelProps = { type PanelProps = {
@@ -135,6 +125,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null} {keepAlive || isOpen ? children : null}
</div> </div>
) )
export const SidePanel = () => { export const SidePanel = () => {
const { const {
activePanelId, activePanelId,
@@ -153,7 +144,6 @@ export const SidePanel = () => {
return ( return (
<StyledSidePanel <StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)} title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => { onClose={() => {
layoutStore.activePanelId = null layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null layoutStore.activeSubPanelId = null
@@ -163,7 +153,6 @@ export const SidePanel = () => {
})} })}
isClosed={!isSidePanelOpen} isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen} isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)} onBack={() => (layoutStore.activeSubPanelId = null)}
> >
<Panel isOpen={isParticipantsOpen}> <Panel isOpen={isParticipantsOpen}>
@@ -175,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}> <Panel isOpen={isChatOpen} keepAlive={true}>
<Chat /> <Chat />
</Panel> </Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}> <Panel isOpen={isToolsOpen}>
<Tools /> <Tools />
</Panel> </Panel>
<Panel isOpen={isAdminOpen}> <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 { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { import {
useIsRecordingModeEnabled, useIsRecordingModeEnabled,
RecordingMode, RecordingMode,
@@ -87,7 +86,7 @@ const ToolButton = ({
alignItems: 'center', alignItems: 'center',
})} })}
> >
<Icon type="symbols" name="chevron_forward" /> <span className="material-symbols">chevron_forward</span>
</div> </div>
</RACButton> </RACButton>
) )
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => { export const Tools = () => {
const { data } = useConfig() const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } = const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel() useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' }) 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( const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript RecordingMode.Transcript
) )
@@ -163,7 +146,7 @@ export const Tools = () => {
</Text> </Text>
{isTranscriptEnabled && ( {isTranscriptEnabled && (
<ToolButton <ToolButton
icon={<Icon type="symbols" name="speech_to_text" />} icon={<span className="material-symbols">speech_to_text</span>}
title={t('tools.transcript.title')} title={t('tools.transcript.title')}
description={t('tools.transcript.body')} description={t('tools.transcript.body')}
onPress={() => openTranscript()} onPress={() => openTranscript()}
@@ -171,7 +154,7 @@ export const Tools = () => {
)} )}
{isScreenRecordingEnabled && ( {isScreenRecordingEnabled && (
<ToolButton <ToolButton
icon={<Icon type="symbols" name="mode_standby" />} icon={<span className="material-symbols">mode_standby</span>}
title={t('tools.screenRecording.title')} title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')} description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()} onPress={() => openScreenRecording()}
@@ -9,7 +9,6 @@ export const OptionsButton = () => {
return ( return (
<Menu variant="dark"> <Menu variant="dark">
<Button <Button
id="room-options-trigger"
square square
variant="primaryDark" variant="primaryDark"
aria-label={t('options.buttonLabel')} aria-label={t('options.buttonLabel')}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react' import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react' import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel' import { useSidePanel } from '../../../hooks/useSidePanel'
@@ -20,8 +19,6 @@ export const ParticipantsToggle = ({
*/ */
const participants = useParticipants() const participants = useParticipants()
const numParticipants = participants?.length const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -34,24 +31,21 @@ export const ParticipantsToggle = ({
display: 'inline-block', display: 'inline-block',
})} })}
> >
<VisualOnlyTooltip tooltip={t(tooltipLabel)}> <ToggleButton
<ToggleButton square
square variant="primaryTextDark"
variant="primaryTextDark" aria-label={t(tooltipLabel)}
aria-label={`${t(tooltipLabel)}. ${t('count', { tooltip={t(tooltipLabel)}
count: announcedCount, isSelected={isParticipantsOpen}
})}.`} onPress={(e) => {
isSelected={isParticipantsOpen} toggleParticipants()
onPress={(e) => { onPress?.(e)
toggleParticipants() }}
onPress?.(e) data-attr={`controls-participants-${tooltipLabel}`}
}} {...props}
data-attr={`controls-participants-${tooltipLabel}`} >
{...props} <RiGroupLine />
> </ToggleButton>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',
@@ -10,7 +10,6 @@ import {
ANIMATION_DURATION, ANIMATION_DURATION,
ReactionPortals, ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal' } from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components' import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client' import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter' import useRateLimiter from '@/hooks/useRateLimiter'
@@ -146,7 +145,7 @@ export const ReactionsToggle = () => {
<Button <Button
key={index} key={index}
onPress={() => debouncedSendReaction(emoji)} onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })} aria-label={t('send', { emoji })}
variant="primaryTextDark" variant="primaryTextDark"
size="sm" size="sm"
square square
@@ -9,13 +9,13 @@ import {
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives' import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn' import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong' import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react' import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader' import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay' import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects' import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess' import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,17 +50,11 @@ export const EffectsConfiguration = ({
layout = 'horizontal', layout = 'horizontal',
}: EffectsConfigurationProps) => { }: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' }) const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera }) const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false) const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending) const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess() const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => { useEffect(() => {
const videoElement = videoRef.current const videoElement = videoRef.current
@@ -75,95 +69,16 @@ export const EffectsConfiguration = ({
} }
}, [videoTrack, videoTrack?.isMuted]) }, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
const clearEffect = async () => { const clearEffect = async () => {
await videoTrack.stopProcessor() await videoTrack.stopProcessor()
onSubmit?.(undefined) onSubmit?.(undefined)
} }
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async ( const toggleEffect = async (
type: ProcessorType, type: ProcessorType,
options: BackgroundOptions options: BackgroundOptions
) => { ) => {
setProcessorPending(true) setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) { if (!videoTrack) {
/** /**
* Special case: if no video track is available, then we must pass directly the processor into the * Special case: if no video track is available, then we must pass directly the processor into the
@@ -189,7 +104,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor() const processor = getProcessor()
try { try {
if (wasSelectedBeforeToggle) { if (isSelected(type, options)) {
// Stop processor. // Stop processor.
await clearEffect() await clearEffect()
} else if ( } else if (
@@ -216,8 +131,6 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it. // We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor) onSubmit?.(processor)
} }
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) { } catch (error) {
console.error('Error applying effect:', error) console.error('Error applying effect:', error)
} finally { } finally {
@@ -240,28 +153,8 @@ export const EffectsConfiguration = ({
) )
} }
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => { const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
const strength = return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
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}`)
} }
return ( return (
@@ -372,60 +265,65 @@ export const EffectsConfiguration = ({
> >
{t('blur.title')} {t('blur.title')}
</H> </H>
<div> <div
<div className={css({
className={css({ display: 'flex',
display: 'flex', gap: '1.25rem',
gap: '1.25rem', })}
})} >
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
> >
<ToggleButton <RiProhibited2Line />
ref={blurLightRef} </ToggleButton>
variant="bigSquare" <ToggleButton
aria-label={tooltipBlur(ProcessorType.BLUR, { 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, blurRadius: BlurRadius.LIGHT,
})} })
tooltip={tooltipBlur(ProcessorType.BLUR, { }
blurRadius: BlurRadius.LIGHT, isSelected={isSelected(ProcessorType.BLUR, {
})} blurRadius: BlurRadius.LIGHT,
isDisabled={processorPendingReveal || isDisabled} })}
onChange={async () => data-attr="toggle-blur-light"
await toggleEffect(ProcessorType.BLUR, { >
blurRadius: BlurRadius.LIGHT, <BlurOn />
}) </ToggleButton>
} <ToggleButton
isSelected={isSelected(ProcessorType.BLUR, { variant="bigSquare"
blurRadius: BlurRadius.LIGHT, aria-label={tooltipLabel(ProcessorType.BLUR, {
})} blurRadius: BlurRadius.NORMAL,
data-attr="toggle-blur-light" })}
> tooltip={tooltipLabel(ProcessorType.BLUR, {
<BlurOn /> blurRadius: BlurRadius.NORMAL,
</ToggleButton> })}
<ToggleButton isDisabled={processorPendingReveal || isDisabled}
variant="bigSquare" onChange={async () =>
aria-label={tooltipBlur(ProcessorType.BLUR, { await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL, blurRadius: BlurRadius.NORMAL,
})} })
tooltip={tooltipBlur(ProcessorType.BLUR, { }
blurRadius: BlurRadius.NORMAL, isSelected={isSelected(ProcessorType.BLUR, {
})} blurRadius: BlurRadius.NORMAL,
isDisabled={processorPendingReveal || isDisabled} })}
onChange={async () => data-attr="toggle-blur-normal"
await toggleEffect(ProcessorType.BLUR, { >
blurRadius: BlurRadius.NORMAL, <BlurOnStrong />
}) </ToggleButton>
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div> </div>
<div <div
className={css({ className={css({
@@ -445,37 +343,39 @@ export const EffectsConfiguration = ({
className={css({ className={css({
display: 'flex', display: 'flex',
gap: '1.25rem', gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap', flexWrap: 'wrap',
})} })}
> >
{[...Array(8).keys()].map((i) => { {[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg` const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return ( return (
<VisualOnlyTooltip key={i} tooltip={tooltipText}> <ToggleButton
<ToggleButton key={i}
variant="bigSquare" variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)} aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
isDisabled={processorPendingReveal || isDisabled} imagePath,
onChange={async () => })}
await toggleEffect(ProcessorType.VIRTUAL, { tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath, imagePath,
}) })}
} isDisabled={processorPendingReveal || isDisabled}
isSelected={isSelected(ProcessorType.VIRTUAL, { onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath, imagePath,
})} })
className={css({ }
bgSize: 'cover', isSelected={isSelected(ProcessorType.VIRTUAL, {
})} imagePath,
style={{ })}
backgroundImage: `url(${thumbnailPath})`, className={css({
}} bgSize: 'cover',
data-attr={`toggle-virtual-${i}`} })}
/> style={{
</VisualOnlyTooltip> backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
) )
})} })}
</div> </div>
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core' import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React from 'react' import React, { useEffect } from 'react'
import { import {
formatChatMessageLinks, formatChatMessageLinks,
useChat, useChat,
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client' import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, 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 // Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed. // when the chat panel is closed.
useRestoreFocus(isChatOpen, { const prevIsChatOpenRef = React.useRef(false)
// Avoid layout "jump" during the side panel slide-in animation. const chatTriggerRef = React.useRef<HTMLElement | null>(null)
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => { 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(() => { requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true }) 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. // Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants() const participants = useParticipants()
@@ -73,7 +73,6 @@ export function MobileControlBar({
/> />
<HandToggle /> <HandToggle />
<Button <Button
id="room-options-trigger"
square square
variant="primaryDark" variant="primaryDark"
aria-label={t('options.buttonLabel')} aria-label={t('options.buttonLabel')}
@@ -37,7 +37,6 @@ export const LateralMenu = () => {
return ( return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}> <DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button <Button
id="controlbar-more-options-trigger"
square square
variant="secondaryDark" variant="secondaryDark"
aria-label={t('controls.moreOptions')} aria-label={t('controls.moreOptions')}
@@ -1,24 +0,0 @@
import { useTranslation } from 'react-i18next'
export const getEmojiLabel = (
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
'clapping-hands': t('emojis.clapping-hands', {
defaultValue: 'clapping hands',
}),
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
defaultValue: 'face with tears of joy',
}),
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
defaultValue: 'surprised face',
}),
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
}
return emojiLabels[emoji] ?? emoji
}
@@ -3,9 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx' import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx' import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter' import { useLocation, useSearchParams } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
// fixme - duplicated with home, refactor in a proper style // fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', { const Heading = styled('h1', {
@@ -22,40 +20,25 @@ const Heading = styled('h1', {
}, },
}) })
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => { export const FeedbackRoute = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const [, setLocation] = useLocation() const [, setLocation] = useLocation()
const reasonKey = useMemo(() => { const [searchParams] = useSearchParams()
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
return ( return (
<Screen layout="centered" footer={false}> <Screen layout="centered" footer={false}>
<Center> <Center>
<VStack> <VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading> <Heading>
<HStack> {t(
{showBackButton && ( `feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
)} )}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}> <Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')} {t('feedback.home')}
</Button> </Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx' import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx' import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -11,7 +10,6 @@ import {
RiSettings3Line, RiSettings3Line,
RiSpeakerLine, RiSpeakerLine,
RiVideoOnLine, RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react' } from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab' import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab' import { NotificationsTab } from './tabs/NotificationsTab'
@@ -23,7 +21,6 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type' import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({ const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -109,16 +106,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab> </Tab>
{isAdminOrOwner && ( {isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}> <Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" /> <span className="material-symbols">speech_to_text</span>
{isWideScreen && {isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)} t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab> </Tab>
)} )}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList> </TabList>
</div> </div>
<div className={tabPanelContainerStyle}> <div className={tabPanelContainerStyle}>
@@ -132,7 +124,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} /> <NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */} {/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} /> <TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div> </div>
</Tabs> </Tabs>
</Dialog> </Dialog>
@@ -1,40 +0,0 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -18,14 +18,6 @@ export const useTranscriptionLanguage = () => {
key: RecordingLanguage.ENGLISH, key: RecordingLanguage.ENGLISH,
label: t('language.options.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, key: RecordingLanguage.AUTOMATIC,
label: t('language.options.auto'), label: t('language.options.auto'),
@@ -5,5 +5,4 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general', GENERAL = 'general',
NOTIFICATIONS = 'notifications', NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription', TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
} }
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles' import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css' import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
@@ -13,63 +13,101 @@ export interface TranscriptionSegment {
id: string id: string
text: string text: string
language: string language: string
startTime?: number startTime: number
endTime: number endTime: number
final: boolean final: boolean
firstReceivedTime: number firstReceivedTime: number
lastReceivedTime: number lastReceivedTime: number
} }
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow { export interface TranscriptionRow {
id: string id: string
participant: Participant participant: Participant
segments: TranscriptionSegment[] segments: TranscriptionSegment[]
startTime?: number startTime: number
lastUpdateTime: number lastUpdateTime: number
lastReceivedTime: number
} }
const useTranscriptionState = () => { const useTranscriptionState = () => {
const [transcriptionSegments, setTranscriptionSegments] = useState< const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionSegmentWithParticipant[] TranscriptionRow[]
>([]) >([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptionSegments = ( const updateTranscriptions = (
segments: TranscriptionSegment[], segments: TranscriptionSegment[],
participant?: Participant participant?: Participant
) => { ) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return if (!participant || segments.length === 0) return
if (segments.length > 1) { setTranscriptionRows((prevRows) => {
console.warn('Unexpected error more segments') const updatedRows = [...prevRows]
return const now = Date.now()
}
const segment = segments[0] const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
setTranscriptionSegments((prevSegments) => { if (shouldAppendToLastRow) {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id)) const lastRowIndex = updatedRows.length - 1
if (existingSegmentIds.has(segment.id)) return prevSegments const lastRow = updatedRows[lastRowIndex]
return [
...prevSegments, const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
{ const newSegments = segments.filter(
participant: participant, (segment) => !existingSegmentIds.has(segment.id)
...segment, )
}, const updatedSegments = lastRow.segments.map((existing) => {
] const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
}) })
} }
return { return {
updateTranscriptionSegments, transcriptionRows,
transcriptionSegments, updateTranscriptions,
clearTranscriptions,
updateParticipant,
} }
} }
@@ -154,54 +192,24 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => { export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles() const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext() const room = useRoomContext()
const { transcriptionRows, updateTranscriptions, updateParticipant } =
const { transcriptionSegments, updateTranscriptionSegments } =
useTranscriptionState() useTranscriptionState()
useEffect(() => { useEffect(() => {
if (!room) return if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments) room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
return () => { return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments) room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
} }
}, [room, updateTranscriptionSegments]) }, [room, updateTranscriptions])
const transcriptionRows = useMemo(() => { useEffect(() => {
if (transcriptionSegments.length === 0) return [] if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
const rows: TranscriptionRow[] = [] return () => {
let currentRow: TranscriptionRow | null = null room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
} }
}, [room, updateParticipant])
return rows
}, [transcriptionSegments])
return ( return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}> <SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -2,14 +2,10 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle' import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' 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 = () => { export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore) const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData() const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle() const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -24,18 +20,6 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles 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 { return {
areSubtitlesOpen: layoutSnap.showSubtitles, areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles, 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,
])
}
+6 -3
View File
@@ -44,15 +44,18 @@
}, },
"slide1": { "slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!", "title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere." "body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
}, },
"slide2": { "slide2": {
"title": "Gruppenanrufe ohne Einschränkungen", "title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße." "body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
}, },
"slide3": { "slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI", "title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!" "body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
} }
} }
} }
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "Sie haben das Meeting verlassen", "normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten", "duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
}, },
"home": "Zur Startseite zurückkehren", "home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten" "back": "Dem Meeting erneut beitreten"
@@ -86,7 +85,6 @@
"heading": "Ihr Meeting ist bereit", "heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.", "description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.", "permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": { "phone": {
"call": "Rufen Sie an:", "call": "Rufen Sie an:",
"pinCode": "Code:" "pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Verlassen", "leave": "Verlassen",
"participants": { "participants": {
"open": "Alle ausblenden", "open": "Alle ausblenden",
"closed": "Alle anzeigen", "closed": "Alle anzeigen"
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
}, },
"tools": { "tools": {
"open": "Weitere Tools ausblenden", "open": "Weitere Tools ausblenden",
@@ -209,18 +205,7 @@
"reactions": { "reactions": {
"button": "Reaktion senden", "button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden", "send": "Reaktion {{emoji}} senden",
"announce": "{{name}} : {{emoji}}", "you": "Sie"
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
} }
}, },
"options": { "options": {
@@ -248,34 +233,15 @@
"clear": "Effekt deaktivieren", "clear": "Effekt deaktivieren",
"blur": { "blur": {
"title": "Hintergrundunschärfe", "title": "Hintergrundunschärfe",
"status": { "light": "Leichte Unschärfe",
"none": "Hintergrund scharf.", "normal": "Unschärfe",
"light": "Hintergrund leicht verschwommen.", "apply": "Unschärfe aktivieren",
"strong": "Hintergrund stark verschwommen." "clear": "Unschärfe deaktivieren"
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
}, },
"virtual": { "virtual": {
"title": "Virtueller Hintergrund", "title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:", "apply": "Virtuellen Hintergrund aktivieren",
"apply": "Ersetze deinen Hintergrund:", "clear": "Virtuellen Hintergrund deaktivieren"
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Visuelle Effekte", "title": "Visuelle Effekte",
@@ -290,8 +256,6 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": { "heading": {
"participants": "Teilnehmer", "participants": "Teilnehmer",
"effects": "Effekte", "effects": "Effekte",
@@ -75,8 +75,6 @@
"options": { "options": {
"french": "Französisch (fr)", "french": "Französisch (fr)",
"english": "Englisch (en)", "english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch" "auto": "Automatisch"
} }
} }
@@ -70,42 +70,19 @@
}, },
"article7": { "article7": {
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM", "title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
"sections": { "sections": {
"section1": { "section1": {
"title": "7.1. Verfügbarkeitsniveau", "title": "7.1. Sicherheit und Plattformzugang",
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
"paragraphs": [ "paragraphs": [
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.", "DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.", "DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.", "DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
] ]
}, },
"section2": { "section2": {
"title": "7.2. Sicherheit des Dienstes", "title": "7.2. Open Source und Lizenzen",
"paragraphs": [ "content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
]
},
"section3": {
"title": "7.3. Support-Management",
"paragraphs": [
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
]
},
"section4": {
"title": "7.4. Kontrolle der Dienstenutzung",
"paragraphs": [
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
]
},
"section5": {
"title": "7.5. Open Source und Lizenzen",
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
} }
} }
}, },
+6 -3
View File
@@ -44,15 +44,18 @@
}, },
"slide1": { "slide1": {
"title": "Make the switch to simplicity. Try us now!", "title": "Make the switch to simplicity. Try us now!",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more." "body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
}, },
"slide2": { "slide2": {
"title": "Host group calls without limits", "title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size." "body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
}, },
"slide3": { "slide3": {
"title": "Transform your meetings with AI", "title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!" "body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
} }
} }
} }
+15 -51
View File
@@ -2,8 +2,7 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "You have left the meeting", "normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device", "duplicateIdentity": "You have joined the meeting from another device"
"participantRemoved": "You have been removed from the meeting by an administrator"
}, },
"home": "Return to home", "home": "Return to home",
"back": "Rejoin the meeting" "back": "Rejoin the meeting"
@@ -16,7 +15,7 @@
"audio": "Audio settings", "audio": "Audio settings",
"video": "Video settings" "video": "Video settings"
}, },
"effects": "Backgrounds and Effects", "effects": "Effects",
"videoinput": { "videoinput": {
"choose": "Select camera", "choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed", "permissionsNeeded": "Select camera - permission needed",
@@ -39,7 +38,7 @@
}, },
"join": { "join": {
"effects": { "effects": {
"description": "Apply backgrounds and effects", "description": "Apply effects",
"title": "Effects", "title": "Effects",
"subTitle": "Configure your camera's effects." "subTitle": "Configure your camera's effects."
}, },
@@ -86,7 +85,6 @@
"heading": "Your meeting is ready", "heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.", "description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.", "permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": { "phone": {
"call": "Call:", "call": "Call:",
"pinCode": "Code:" "pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Leave", "leave": "Leave",
"participants": { "participants": {
"open": "Hide everyone", "open": "Hide everyone",
"closed": "See everyone", "closed": "See everyone"
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
}, },
"tools": { "tools": {
"open": "Hide more tools", "open": "Hide more tools",
@@ -209,18 +205,7 @@
"reactions": { "reactions": {
"button": "Send reaction", "button": "Send reaction",
"send": "Send reaction {{emoji}}", "send": "Send reaction {{emoji}}",
"announce": "{{name}} : {{emoji}}", "you": "you"
"you": "you",
"emojis": {
"thumbs-up": "thumbs up",
"thumbs-down": "thumbs down",
"clapping-hands": "clapping hands",
"red-heart": "red heart",
"face-with-tears-of-joy": "face with tears of joy",
"face-with-open-mouth": "surprised face",
"party-popper": "party popper",
"folded-hands": "folded hands"
}
} }
}, },
"options": { "options": {
@@ -231,7 +216,7 @@
"screenRecording": "Screen recording", "screenRecording": "Screen recording",
"transcript": "Transcription", "transcript": "Transcription",
"username": "Update Your Name", "username": "Update Your Name",
"effects": "Backgrounds and Effects", "effects": "Apply effects",
"switchCamera": "Switch camera", "switchCamera": "Switch camera",
"fullscreen": { "fullscreen": {
"enter": "Fullscreen", "enter": "Fullscreen",
@@ -248,34 +233,15 @@
"clear": "Disable effect", "clear": "Disable effect",
"blur": { "blur": {
"title": "Background blur", "title": "Background blur",
"status": { "light": "Light blur",
"none": "Background clear.", "normal": "Blur",
"light": "Background slightly blurred.", "apply": "Enable blur",
"strong": "Background strongly blurred." "clear": "Disable blur"
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
}, },
"virtual": { "virtual": {
"title": "Virtual background", "title": "Virtual background",
"selectedLabel": "Background applied:", "apply": "Enable virtual background",
"apply": "Replace your background:", "clear": "Disable virtual 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"
}
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Visual Effects", "title": "Visual Effects",
@@ -290,11 +256,9 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Backgrounds and Effects", "effects": "Effects",
"chat": "Messages in the chat", "chat": "Messages in the chat",
"transcript": "Transcribe", "transcript": "Transcribe",
"screenRecording": "Record", "screenRecording": "Record",
@@ -304,7 +268,7 @@
}, },
"content": { "content": {
"participants": "participants", "participants": "participants",
"effects": "Backgrounds and Effects", "effects": "effects",
"chat": "messages", "chat": "messages",
"transcript": "transcribe", "transcript": "transcribe",
"screenRecording": "record", "screenRecording": "record",
@@ -579,7 +543,7 @@
"enable": "Pin", "enable": "Pin",
"disable": "Unpin" "disable": "Unpin"
}, },
"effects": "Apply backgrounds and effects", "effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}", "muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen" "fullScreen": "Full screen"
}, },
@@ -75,8 +75,6 @@
"options": { "options": {
"french": "French (fr)", "french": "French (fr)",
"english": "English (en)", "english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic" "auto": "Automatic"
} }
} }
@@ -108,18 +106,12 @@
"label": "Language" "label": "Language"
}, },
"settingsButtonLabel": "Settings", "settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": { "tabs": {
"account": "Profile", "account": "Profile",
"audio": "Audio", "audio": "Audio",
"video": "Video", "video": "Video",
"general": "General", "general": "General",
"notifications": "Notifications", "notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription" "transcription": "Transcription"
} }
} }
@@ -70,42 +70,19 @@
}, },
"article7": { "article7": {
"title": "7. DINUM Commitments and Responsibilities", "title": "7. DINUM Commitments and Responsibilities",
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
"sections": { "sections": {
"section1": { "section1": {
"title": "7.1. Service Availability", "title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"paragraphs": [ "paragraphs": [
"The service is available 24/7, except during maintenance windows.", "DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.", "DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.", "DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
"Service unavailability does not give rise to any compensation of any kind."
] ]
}, },
"section2": { "section2": {
"title": "7.2. Service Security", "title": "7.2 Open Source and Licenses",
"paragraphs": [ "content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
]
},
"section3": {
"title": "7.3. Support Management",
"paragraphs": [
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
"This support can be reached via our online contact form.",
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
]
},
"section4": {
"title": "7.4. Monitoring Service Use",
"paragraphs": [
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
]
},
"section5": {
"title": "7.5. Open Source and Licenses",
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
} }
} }
}, },
+6 -3
View File
@@ -44,15 +44,18 @@
}, },
"slide1": { "slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !", "title": "Passez à la simplicité. Essayez-nous dès maintenant !",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore." "body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
}, },
"slide2": { "slide2": {
"title": "Organisez des appels de groupe sans limite", "title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre." "body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
}, },
"slide3": { "slide3": {
"title": "Transformez vos réunions avec l'IA", "title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !" "body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
} }
} }
} }
+16 -52
View File
@@ -2,8 +2,7 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "Vous avez quitté la réunion", "normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil", "duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
}, },
"home": "Retourner à l'accueil", "home": "Retourner à l'accueil",
"back": "Réintégrer la réunion" "back": "Réintégrer la réunion"
@@ -16,7 +15,7 @@
"audio": "Paramètres audio", "audio": "Paramètres audio",
"video": "Paramètres video" "video": "Paramètres video"
}, },
"effects": "Arrière-plans et effets", "effects": "Effets d'arrière-plan",
"videoinput": { "videoinput": {
"choose": "Choisir la webcam", "choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires", "permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -40,8 +39,8 @@
"join": { "join": {
"heading": "Rejoindre la réunion ?", "heading": "Rejoindre la réunion ?",
"effects": { "effects": {
"description": "Arrière-plans et effets", "description": "Effets d'arrière-plan",
"title": "Arrière-plans et effets", "title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra." "subTitle": "Paramétrez les effets de votre caméra."
}, },
"joinLabel": "Rejoindre", "joinLabel": "Rejoindre",
@@ -86,7 +85,6 @@
"heading": "Votre réunion est prête", "heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.", "description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.", "permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": { "phone": {
"call": "Appelez le :", "call": "Appelez le :",
"pinCode": "Code :" "pinCode": "Code :"
@@ -193,9 +191,7 @@
"leave": "Quitter", "leave": "Quitter",
"participants": { "participants": {
"open": "Masquer les participants", "open": "Masquer les participants",
"closed": "Afficher les participants", "closed": "Afficher les participants"
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
}, },
"tools": { "tools": {
"open": "Masquer les outils de réunion", "open": "Masquer les outils de réunion",
@@ -209,18 +205,7 @@
"reactions": { "reactions": {
"button": "Envoyer une réaction", "button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}", "send": "Envoyer la réaction {{emoji}}",
"announce": "{{name}} : {{emoji}}", "you": "vous"
"you": "vous",
"emojis": {
"thumbs-up": "pouce levé",
"thumbs-down": "pouce baissé",
"clapping-hands": "applaudissements",
"red-heart": "cœur",
"face-with-tears-of-joy": "visage qui rit",
"face-with-open-mouth": "visage étonné",
"party-popper": "confettis",
"folded-hands": "mains jointes"
}
} }
}, },
"options": { "options": {
@@ -231,7 +216,7 @@
"screenRecording": "Enregistrement", "screenRecording": "Enregistrement",
"transcript": "Transcription", "transcript": "Transcription",
"username": "Choisir votre nom", "username": "Choisir votre nom",
"effects": "Arrière-plans et effets", "effects": "Effets d'arrière-plan",
"switchCamera": "Changer de caméra", "switchCamera": "Changer de caméra",
"fullscreen": { "fullscreen": {
"enter": "Plein écran", "enter": "Plein écran",
@@ -245,37 +230,18 @@
"cameraDisabled": "Votre caméra est désactivée.", "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 :(", "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", "heading": "Flou",
"clear": "Désactiver l'effet", "clear": "Désactiver l'effect",
"blur": { "blur": {
"title": "Flou d'arrière-plan", "title": "Flou d'arrière-plan",
"status": { "light": "Flou léger",
"none": "Arrière-plan net.", "normal": "Flou",
"light": "Arrière-plan légèrement flouté.", "apply": "Activer le flou",
"strong": "Arrière-plan fortement flouté." "clear": "Désactiver le flou"
},
"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"
}
}, },
"virtual": { "virtual": {
"title": "Arrière-plan virtuel", "title": "Arrière-plan virtuel",
"selectedLabel": "Arrière-plan appliqué :", "apply": "Activer l'arrière-plan virtuel",
"apply": "Remplacer votre arrière plan :", "clear": "Désactiver l'arrière-plan virtuel"
"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é"
}
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Effets visuels", "title": "Effets visuels",
@@ -290,11 +256,9 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": { "heading": {
"participants": "Participants", "participants": "Participants",
"effects": "Arrière-plans et effets", "effects": "Effets",
"chat": "Messages dans l'appel", "chat": "Messages dans l'appel",
"transcript": "Transcrire", "transcript": "Transcrire",
"screenRecording": "Enregistrer", "screenRecording": "Enregistrer",
@@ -579,7 +543,7 @@
"enable": "Épingler", "enable": "Épingler",
"disable": "Annuler l'épinglage" "disable": "Annuler l'épinglage"
}, },
"effects": "Arrière-plans et effets", "effects": "Effets d'arrière-plan",
"muteParticipant": "Couper le micro de {{name}}", "muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran" "fullScreen": "Plein écran"
}, },
@@ -75,8 +75,6 @@
"options": { "options": {
"french": "Français (fr)", "french": "Français (fr)",
"english": "Anglais (en)", "english": "Anglais (en)",
"dutch": "Hollandais (nl)",
"german": "Allemand (de)",
"auto": "Automatique" "auto": "Automatique"
} }
} }
@@ -108,18 +106,12 @@
"label": "Langue de l'application" "label": "Langue de l'application"
}, },
"settingsButtonLabel": "Paramètres", "settingsButtonLabel": "Paramètres",
"accessibility": {
"announceReactions": {
"label": "Vocaliser les réactions"
}
},
"tabs": { "tabs": {
"account": "Profil", "account": "Profil",
"audio": "Audio", "audio": "Audio",
"video": "Vidéo", "video": "Vidéo",
"general": "Général", "general": "Général",
"notifications": "Notifications", "notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription" "transcription": "Transcription"
} }
} }
@@ -16,7 +16,7 @@
}, },
"article4": { "article4": {
"title": "4. Utilisation", "title": "4. Utilisation",
"content": "L'utilisation du service est libre et gratuite, pour administrer un salon de visioconférence, il est nécessaire de se connecter par ProConnect." "content": "L'utilisation du service est libre et gratuite, pour administration un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
}, },
"article5": { "article5": {
"title": "5. Fonctionnalités", "title": "5. Fonctionnalités",
@@ -70,42 +70,19 @@
}, },
"article7": { "article7": {
"title": "7. Engagements et responsabilités de la DINUM", "title": "7. Engagements et responsabilités de la DINUM",
"content": "La DINUM est responsable de ladministration générale du service. Elle transmet aux agents toutes les informations nécessaires pour faciliter son utilisation. Elle propose une assistance technique et fonctionnelle en vue dassurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
"sections": { "sections": {
"section1": { "section1": {
"title": "7.1. Niveau de disponibilité", "title": "7.1. Sécurité et accès à la plateforme",
"content": "La DINUM s'engage à la sécurisation de Visio, notamment en prenant toutes les mesures nécessaires permettant de garantir la sécurité et la confidentialité des informations fournies.",
"paragraphs": [ "paragraphs": [
"La plage douverture du Service est 24h/24 7j/7, hors période dindisponibilité pour maintenance.", "La DINUM s'engage à fournir les moyens nécessaires et raisonnables pour assurer un accès continu à Visio.",
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas dincident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.", "La DINUM se réserve le droit de faire évoluer, de modifier ou de suspendre, sans préavis, le service pour des raisons de maintenance ou pour tout autre motif jugé nécessaire.",
"Dans le cadre dune maintenance de lenvironnement dexécution du service, La DINUM se réserve le droit de suspendre temporairement le fonctionnement du service. Elle tient informée les utilisateurs au minimum 48h à lavance. En cas durgence, cette suspension peut intervenir sans préavis.", "La DINUM se réserve le droit de suspendre ou supprimer un compte utilisateur ou utilisatrice du service qui aurait méconnu les présentes modalités d'utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l'encontre de l'utilisateur ou l'utilisatrice."
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données en back office, des opérations de mise en production ou des changements darchitecture.",
"Lindisponibilité du service nouvre droit à aucune compensation de quelque nature que ce soit."
] ]
}, },
"section2": { "section2": {
"title": "7.2. Sécurité du service", "title": "7.2 Open Source et Licences",
"paragraphs": [ "content": "Le code source de Visio est libre et disponible ici : https://github.com/suitenumerique/meet\nLes contenus proposés par la DINUM sont sous Licence Ouverte, à l'exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
"La DINUM prend toutes les précautions utiles pour préserver la sécurité de la plateforme et des outils mis en œuvre dans le cadre du service, notamment sagissant de laccès au service, de la gestion des comptes utilisateurs et du traitement des données collectées."
]
},
"section3": {
"title": "7.3. Gestion du support",
"paragraphs": [
"La DINUM assure le support de premier niveau auprès des utilisateurs, exclusivement sur les parties techniques du service.",
"Ce support est joignable sur notre formulaire de contact en ligne.",
"Lobjectif de réponse aux sollicitations est de deux jours ouvrés à compter de lenvoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
]
},
"section4": {
"title": "7.4. Contrôle de lutilisation du service",
"paragraphs": [
"La DINUM se réserve le droit de suspendre ou supprimer un compte agent qui aurait méconnu les présentes modalités dutilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à lencontre de lagent concerné.",
"La suspension ou la révocation dun ou plusieurs comptes ne donne lieu à aucune compensation daucune sorte."
]
},
"section5": {
"title": "7.5. Open Source et Licences",
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte, à lexception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
} }
} }
}, },
+6 -3
View File
@@ -44,15 +44,18 @@
}, },
"slide1": { "slide1": {
"title": "Stap over op eenvoud. Probeer ons nu!", "title": "Stap over op eenvoud. Probeer ons nu!",
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer." "body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
}, },
"slide2": { "slide2": {
"title": "Houdt groepsgesprekken zonder limieten", "title": "Houdt groepsgesprekken zonder limieten",
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte." "body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
}, },
"slide3": { "slide3": {
"title": "Transformeer uw vergaderingen met AI", "title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!" "body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
} }
} }
} }
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": { "feedback": {
"heading": { "heading": {
"normal": "Je hebt de vergadering verlaten", "normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend", "duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
}, },
"home": "Keer terug naar het hoofdscherm", "home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan" "back": "Sluit weer bij de vergadering aan"
@@ -86,7 +85,6 @@
"heading": "Uw vergadering is klaar", "heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.", "description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.", "permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": { "phone": {
"call": "Bel:", "call": "Bel:",
"pinCode": "Code:" "pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Vertrekken", "leave": "Vertrekken",
"participants": { "participants": {
"open": "Verberg iedereen", "open": "Verberg iedereen",
"closed": "Toon iedereen", "closed": "Toon iedereen"
"count_one": "{{count}} deelnemer",
"count_other": "{{count}} deelnemers"
}, },
"tools": { "tools": {
"open": "Meer tools verbergen", "open": "Meer tools verbergen",
@@ -209,18 +205,7 @@
"reactions": { "reactions": {
"button": "Stuur reactie", "button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}", "send": "Stuur reactie {{emoji}}",
"announce": "{{name}} : {{emoji}}", "you": "U"
"you": "U",
"emojis": {
"thumbs-up": "duim omhoog",
"thumbs-down": "duim omlaag",
"clapping-hands": "applaudisserende handen",
"red-heart": "rood hart",
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
"face-with-open-mouth": "verrast gezicht",
"party-popper": "feestknaller",
"folded-hands": "gevouwen handen"
}
} }
}, },
"options": { "options": {
@@ -248,34 +233,15 @@
"clear": "Effect uitschakelen", "clear": "Effect uitschakelen",
"blur": { "blur": {
"title": "Achtergrondvervaging", "title": "Achtergrondvervaging",
"status": { "light": "Lichte vervaging",
"none": "Achtergrond scherp.", "normal": "Vervaging",
"light": "Achtergrond licht vervaagd.", "apply": "Vervaging inschakelen",
"strong": "Achtergrond sterk vervaagd." "clear": "Vervaging uitschakelen"
},
"light": {
"apply": "Vervaag je achtergrond lichtjes",
"clear": "Vervaging uitschakelen"
},
"normal": {
"apply": "Vervaag je achtergrond",
"clear": "Vervaging uitschakelen"
}
}, },
"virtual": { "virtual": {
"title": "Virtuele achtergrond", "title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:", "apply": "Virtuele achtergrond inschakelen",
"apply": "Vervang je achtergrond:", "clear": "Virtuele achtergrond uitschakelen"
"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"
}
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Visuele effecten", "title": "Visuele effecten",
@@ -290,8 +256,6 @@
} }
}, },
"sidePanel": { "sidePanel": {
"ariaLabel": "Zijbalk",
"backToTools": "Terug naar vergadertools",
"heading": { "heading": {
"participants": "Deelnemers", "participants": "Deelnemers",
"effects": "Effecten", "effects": "Effecten",
@@ -75,8 +75,6 @@
"options": { "options": {
"french": "Frans (fr)", "french": "Frans (fr)",
"english": "Engels (en)", "english": "Engels (en)",
"dutch": "Nederlands (nl)",
"german": "Duits (de)",
"auto": "Automatisch" "auto": "Automatisch"
} }
} }
@@ -70,42 +70,19 @@
}, },
"article7": { "article7": {
"title": "7. DINUM Commitments and Responsibilities", "title": "7. DINUM Commitments and Responsibilities",
"content": "De DINUM is verantwoordelijk voor de algemene administratie van de dienst. Zij verstrekt de medewerkers alle nodige informatie om het gebruik te vergemakkelijken. Zij biedt technische en functionele ondersteuning om de goede werking van de dienst te verzekeren. Zij informeert de gebruikers met elk redelijk middel over moeilijkheden die deze goede werking kunnen beïnvloeden.",
"sections": { "sections": {
"section1": { "section1": {
"title": "7.1. Beschikbaarheidsniveau", "title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"paragraphs": [ "paragraphs": [
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.", "DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM streeft naar een jaarlijkse beschikbaarheid van 99,5% van de dienst, buiten geplande onbeschikbaarheden. Bij een incident of onderhoud mikt zij op een hersteltermijn van 72 uur buiten kantooruren.", "DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"In het kader van onderhoud van de uitvoeringsomgeving van de dienst behoudt DINUM zich het recht voor de werking tijdelijk op te schorten. Zij informeert de gebruikers minstens 48 uur vooraf. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.", "DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
"Deze uitzonderlijke stilstanden kunnen bijvoorbeeld nodig zijn voor backoffice-gegevensbeheer, inproductiestellingen of architectuurwijzigingen.",
"Onbeschikbaarheid van de dienst geeft geen recht op enige vorm van compensatie."
] ]
}, },
"section2": { "section2": {
"title": "7.2. Beveiliging van de dienst", "title": "7.2 Open Source and Licenses",
"paragraphs": [ "content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
"DINUM neemt alle nodige voorzorgsmaatregelen om de veiligheid van het platform en de in het kader van de dienst gebruikte hulpmiddelen te bewaren, met name wat betreft de toegang tot de dienst, het beheer van gebruikersaccounts en de verwerking van verzamelde gegevens."
]
},
"section3": {
"title": "7.3. Supportbeheer",
"paragraphs": [
"DINUM verzorgt eerstelijns support voor de gebruikers, uitsluitend over de technische onderdelen van de dienst.",
"Deze support is bereikbaar via ons online contactformulier.",
"De streefantwoordtermijn op verzoeken is twee werkdagen vanaf de verzending; deze termijn kan variëren naargelang de complexiteit en het aantal te behandelen aanvragen."
]
},
"section4": {
"title": "7.4. Controle van het gebruik van de dienst",
"paragraphs": [
"DINUM behoudt zich het recht voor om een agentaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden heeft geschonden, onverminderd eventuele strafrechtelijke en civielrechtelijke stappen tegen de betrokken agent.",
"De schorsing of intrekking van één of meer accounts geeft geen recht op enige vorm van compensatie."
]
},
"section5": {
"title": "7.5. Open Source en licenties",
"content": "De broncode van de dienst is vrij beschikbaar op: https://github.com/suitenumerique/meet. De inhoud die door DINUM wordt aangeboden valt onder een Open License, met uitzondering van logo's en iconografische en fotografische voorstellingen die onder hun eigen licenties kunnen vallen."
} }
} }
}, },
+22 -21
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css' import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe' import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper' import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react' import { ReactNode } from 'react'
import { Loader } from './Loader' import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> & export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,24 +17,25 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode icon?: ReactNode
} }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = ({
({ tooltip, tooltipType = 'instant', ...props }, ref) => { tooltip,
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props) tooltipType = 'instant',
const { className, ...remainingComponentProps } = componentProps ...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return ( return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}> <TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton <RACButton
ref={ref} className={[buttonRecipe(variantProps), className].join(' ')}
className={[buttonRecipe(variantProps), className].join(' ')} {...(remainingComponentProps as RACButtonsProps)}
{...(remainingComponentProps as RACButtonsProps)} >
> {!props.loading && props.icon}
{!props.loading && props.icon} {props.loading && <Loader />}
{props.loading && <Loader />} {componentProps.children as ReactNode}
{componentProps.children as ReactNode} {props.description && <span>{tooltip}</span>}
{props.description && <span>{tooltip}</span>} </RACButton>
</RACButton> </TooltipWrapper>
</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 { import {
ToggleButton as RACToggleButton, ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps, ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components' } from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe' import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper' import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps & export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps & ButtonRecipeProps &
@@ -16,25 +16,24 @@ export type ToggleButtonProps = RACToggleButtonProps &
/** /**
* React aria ToggleButton with our button styles, that can take a tooltip if needed * React aria ToggleButton with our button styles, that can take a tooltip if needed
*/ */
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>( export const ToggleButton = ({
({ tooltip, tooltipType, ...props }, ref) => { tooltip,
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props) tooltipType,
...props
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return ( return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}> <TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton <RACToggleButton
ref={ref} {...componentProps}
{...componentProps} className={[buttonRecipe(variantProps), props.className].join(' ')}
className={[buttonRecipe(variantProps), props.className].join(' ')} >
> <>
<> {componentProps.children as ReactNode}
{componentProps.children as ReactNode} {props.description && <span>{tooltip}</span>}
{props.description && <span>{tooltip}</span>} </>
</> </RACToggleButton>
</RACToggleButton> </TooltipWrapper>
</TooltipWrapper> )
) }
}
)
ToggleButton.displayName = 'ToggleButton'
@@ -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 -2
View File
@@ -112,8 +112,7 @@ export const buttonRecipe = cva({
}, },
transition: 'box-shadow 0.2s ease-in-out', transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': { '&[data-selected]': {
boxShadow: boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
}, },
'&[data-disabled]': { '&[data-disabled]': {
backgroundColor: 'greyscale.100', backgroundColor: 'greyscale.100',
-1
View File
@@ -30,4 +30,3 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter' export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea' export { TextArea } from './TextArea'
export { Switch } from './Switch' export { Switch } from './Switch'
export { Icon } from './Icon'
+5
View File
@@ -43,6 +43,11 @@ export const routes: Record<
feedback: { feedback: {
name: 'feedback', name: 'feedback',
path: '/feedback', path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute, Component: FeedbackRoute,
}, },
legalTerms: { legalTerms: {
-76
View File
@@ -1,76 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
type AccessibilityState = {
announceReactions: boolean
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
}
function getAccessibilityState(): AccessibilityState {
try {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
announceReactions:
typeof parsed.announceReactions === 'boolean'
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
}
}
// Legacy migration: if the setting was previously stored in notifications
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (legacy) {
try {
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
if (typeof parsedLegacy?.announceReactions === 'boolean') {
const migratedState: AccessibilityState = {
...DEFAULT_STATE,
...parsedLegacy,
announceReactions: parsedLegacy.announceReactions,
}
try {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(migratedState)
)
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
} catch {
// ignore persistence issues during migration
}
return migratedState
}
} catch {
// ignore legacy parsing issues
}
}
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const accessibilityStore = proxy<AccessibilityState>(
getAccessibilityState()
)
subscribe(accessibilityStore, () => {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(accessibilityStore)
)
})
-2
View File
@@ -3,8 +3,6 @@ import { proxy } from 'valtio'
export enum RecordingLanguage { export enum RecordingLanguage {
ENGLISH = 'en', ENGLISH = 'en',
FRENCH = 'fr', FRENCH = 'fr',
DUTCH = 'nl',
GERMAN = 'de',
AUTOMATIC = 'auto', AUTOMATIC = 'auto',
} }
-14
View File
@@ -1,14 +0,0 @@
import { css } from '@/styled-system/css'
// Reusable visually hidden style for screen-reader-only content
export const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
})
+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 './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities; @layer reset, base, tokens, recipes, utilities;
html, html,
body, body,
@@ -6,18 +7,6 @@ body,
height: 100%; 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; outline: 2px solid transparent;
} }
@@ -53,36 +42,3 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
transform: translateY(0); 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;
}
-1
View File
@@ -4,5 +4,4 @@
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings', NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences', USER_PREFERENCES: 'app_user_preferences',
ACCESSIBILITY: 'app_accessibility_settings',
} as const } as const
@@ -150,7 +150,7 @@ summary:
APP_NAME: summary-microservice APP_NAME: summary-microservice
APP_API_TOKEN: password APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/ AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False AWS_S3_SECURE_ACCESS: False
@@ -187,7 +187,7 @@ celeryTranscribe:
APP_NAME: summary-microservice APP_NAME: summary-microservice
APP_API_TOKEN: password APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/ AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False AWS_S3_SECURE_ACCESS: False
@@ -225,7 +225,7 @@ celerySummarize:
APP_NAME: summary-microservice APP_NAME: summary-microservice
APP_API_TOKEN: password APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/ AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False AWS_S3_SECURE_ACCESS: False
@@ -267,7 +267,6 @@ agents:
LIVEKIT_API_KEY: {{ $key }} LIVEKIT_API_KEY: {{ $key }}
{{- end }} {{- end }}
{{- end }} {{- end }}
ENABLE_SILERO_VAD: "false"
image: image:
repository: localhost:5001/meet-agents repository: localhost:5001/meet-agents
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.3.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.3.0", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.5.4", "@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.3.0", "version": "1.1.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.3.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.3.0", "version": "1.1.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.3.0", "version": "1.1.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.3.0" version = "1.1.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+1 -1
View File
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
whisperx_max_retries: int = 0 whisperx_max_retries: int = 0
# ISO 639-1 language code (e.g., "en", "fr", "es") # ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None 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_base_url: str
llm_api_key: SecretStr llm_api_key: SecretStr
llm_model: str llm_model: str
+1 -7
View File
@@ -21,14 +21,8 @@ class FileService:
"""Initialize FileService with MinIO client and configuration.""" """Initialize FileService with MinIO client and configuration."""
self._logger = logger self._logger = logger
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio( self._minio_client = Minio(
endpoint, settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id, access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(), secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access, secure=settings.aws_s3_secure_access,