Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2927f18ca5 | |||
| 88696a23fd | |||
| 13d26a76b3 | |||
| b675517a60 | |||
| a5254ffd59 | |||
| ff82bca9ec | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a | |||
| a50aabeaf8 | |||
| 594bd5a692 | |||
| 69d92e6f30 | |||
| c47e830b40 | |||
| d7f1b7b94c | |||
| 8072d2c950 | |||
| 726f9097f9 | |||
| bbc7fa8012 | |||
| 41db3e766b | |||
| 1ab3ce6d47 | |||
| 3cd5c77f42 | |||
| 3ddb075c6b | |||
| 9ed2500565 | |||
| 1001783d3c | |||
| 97b5e8780c | |||
| 7c7074aa99 | |||
| 35b3bcad63 | |||
| 137a2c7f6f | |||
| d681e25bcc | |||
| 1f1a6371b4 | |||
| bbfbb23be5 | |||
| 6e20bc1f43 | |||
| fed05f2396 | |||
| 865acf2838 | |||
| 6ae68013af | |||
| 394a1be322 | |||
| a71a1fd968 | |||
| 40af264562 | |||
| 8b2d06976e | |||
| 58313666ed | |||
| f3c8aec189 | |||
| 0a0c7ba618 | |||
| d7ad5aed05 | |||
| 4acc9cf40d | |||
| 13d0d3d801 |
@@ -147,6 +147,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -178,6 +186,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
+45
-2
@@ -8,6 +8,49 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
||||
|
||||
### 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
|
||||
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
|
||||
|
||||
## [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
|
||||
@@ -27,7 +70,7 @@ and this project adheres to
|
||||
|
||||
## [1.1.0] - 2025-12-22
|
||||
|
||||
### Added
|
||||
### Added
|
||||
|
||||
- ✨(backend) enable user creation via email for external integrations
|
||||
- ✨(summary) add Langfuse observability for LLM API calls
|
||||
@@ -39,6 +82,6 @@ and this project adheres to
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) hover controls, focus, SR #803
|
||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
|
||||
- ♿️(frontend) Improve focus management when opening and closing chat #807
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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/k8s.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/kubernetes.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||
|
||||
In this example, we assume the following services:
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
|
||||
def create_stt_provider():
|
||||
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
if (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
else:
|
||||
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)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
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_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
"scope": "user.family@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
@@ -336,6 +336,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
"use_french_gov_footer": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.7",
|
||||
"aiohttp==3.13.2",
|
||||
"aiohttp==3.13.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -155,9 +156,34 @@ export const Home = () => {
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
if (!data?.external_home_url) return
|
||||
if (isLoggedIn === false) {
|
||||
try {
|
||||
await fetch(data.external_home_url, {
|
||||
method: 'HEAD', // Use HEAD to avoid downloading the full page
|
||||
mode: 'no-cors', // Needed for cross-origin requests
|
||||
})
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
console.error('Site is not reachable:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSiteAndRedirect()
|
||||
}, [isLoggedIn, data])
|
||||
|
||||
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
|
||||
return <LoadingScreen header={false} footer={false} delay={0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article7.title')}
|
||||
</H>
|
||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||
<P>{t('articles.article7.content')}</P>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('articles.article7.sections.section1.content')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section2.title')}
|
||||
</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>
|
||||
{t('articles.article7.sections.section2.content')
|
||||
{t('articles.article7.sections.section5.content')
|
||||
.split('https://github.com/suitenumerique/meet')[0]
|
||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||
https://github.com/suitenumerique/meet
|
||||
</A>
|
||||
{'. '}
|
||||
{
|
||||
t('articles.article7.sections.section2.content').split(
|
||||
t('articles.article7.sections.section5.content').split(
|
||||
'https://github.com/suitenumerique/meet'
|
||||
)[1]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { Button, Icon, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
@@ -42,6 +42,17 @@ export const ControlsButton = ({
|
||||
}: ControlsButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||
|
||||
// Focus management: focus the primary action button when this side panel opens.
|
||||
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (primaryActionRef.current) {
|
||||
primaryActionRef.current.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
@@ -97,6 +108,7 @@ export const ControlsButton = ({
|
||||
fullWidth
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.stop')}
|
||||
</Button>
|
||||
@@ -129,31 +141,23 @@ export const ControlsButton = ({
|
||||
})}
|
||||
onPress={() => openSidePanel()}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginRight: '1rem',
|
||||
})
|
||||
)}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<Icon
|
||||
className={css({
|
||||
color: 'primary.500',
|
||||
marginRight: '1rem',
|
||||
})}
|
||||
name="info"
|
||||
/>
|
||||
<Text variant={'smNote'}>
|
||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||
</Text>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginLeft: 'auto',
|
||||
})
|
||||
)}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
<Icon
|
||||
className={css({
|
||||
color: 'primary.500',
|
||||
marginLeft: 'auto',
|
||||
})}
|
||||
name="chevron_right"
|
||||
/>
|
||||
</RACButton>
|
||||
)}
|
||||
<Button
|
||||
@@ -162,6 +166,7 @@ export const ControlsButton = ({
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
size="compact"
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.start')}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { H, Text } from '@/primitives'
|
||||
import { H, Text, Icon } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols" aria-hidden={true}>
|
||||
login
|
||||
</span>
|
||||
<Icon type="symbols" name="login" />
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, useState, useEffect } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import {
|
||||
RecordingMode,
|
||||
@@ -21,6 +21,9 @@ export const RecordingStateToast = () => {
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
@@ -67,6 +70,23 @@ export const RecordingStateToast = () => {
|
||||
return `${metadata.recording_mode}.${status}`
|
||||
}, [metadata, isStarted, isStarting, isRecording])
|
||||
|
||||
// Update screen reader message only when the key actually changes
|
||||
// This prevents duplicate announcements caused by re-renders
|
||||
useEffect(() => {
|
||||
if (key && key !== lastKeyRef.current) {
|
||||
lastKeyRef.current = key
|
||||
const message = t(key)
|
||||
setSrMessage(message)
|
||||
|
||||
// Clear message after 3 seconds to prevent it from being announced again
|
||||
const timer = setTimeout(() => {
|
||||
setSrMessage('')
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [key, t])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
@@ -74,61 +94,74 @@ export const RecordingStateToast = () => {
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
<>
|
||||
{/* Screen reader only message to announce state changes once */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{srMessage}
|
||||
</div>
|
||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
|
||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
{!hasScreenRecordingAccessAndActive &&
|
||||
!hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Icon } from '@/primitives'
|
||||
|
||||
interface RecordingStatusIconProps {
|
||||
isStarted: boolean
|
||||
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
|
||||
}
|
||||
|
||||
if (isTranscriptActive) {
|
||||
return <span className="material-symbols">speech_to_text</span>
|
||||
return <Icon type="symbols" name="speech_to_text" />
|
||||
}
|
||||
|
||||
return <span className="material-symbols">screen_record</span>
|
||||
return <Icon type="symbols" name="screen_record" />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, H, Text } from '@/primitives'
|
||||
import { Button, Icon, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -59,7 +59,7 @@ export const RequestRecording = ({
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols">person_raised_hand</span>
|
||||
<Icon type="symbols" name="person_raised_hand" />
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ReactNode } from 'react'
|
||||
import { Icon } from '@/primitives'
|
||||
|
||||
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
||||
|
||||
@@ -45,7 +46,7 @@ export const RowWrapper = ({
|
||||
})}
|
||||
>
|
||||
{/* fixme - doesn't handle properly material-symbols */}
|
||||
<span className="material-icons">{iconName}</span>
|
||||
<Icon name={iconName} />
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
@@ -28,6 +26,7 @@ import { Checkbox } from '@/primitives/Checkbox'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -38,10 +37,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
@@ -98,14 +94,17 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
posthog.capture('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle recording:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
if (!isAdminOrOwner) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
@@ -146,7 +145,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'} fullWidth>
|
||||
{t('heading')}
|
||||
</H>
|
||||
@@ -163,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<VStack gap={0} marginBottom={25}>
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
|
||||
@@ -117,7 +117,10 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {})
|
||||
posthog.capture('transcript-started', {
|
||||
includeScreenRecording: includeScreenRecording,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
@@ -178,7 +181,7 @@ export const TranscriptSidePanel = () => {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
@@ -195,7 +198,7 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<VStack gap={0} marginBottom={25}>
|
||||
<RowWrapper iconName="article" position="first">
|
||||
<Text variant="sm">
|
||||
{data?.transcription_destination ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ export const useHumanizeRecordingMaxDuration = () => {
|
||||
|
||||
return humanizeDuration(data?.recording?.max_duration, {
|
||||
language: i18n.language,
|
||||
delimiter: ' ',
|
||||
})
|
||||
}, [data])
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -228,10 +228,20 @@ export const Conference = ({
|
||||
posthog.captureException(e)
|
||||
}}
|
||||
onDisconnected={(e) => {
|
||||
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||
navigateTo('feedback', { duplicateIdentity: false })
|
||||
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
|
||||
navigateTo('feedback', { duplicateIdentity: true })
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo('feedback')
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: { reason: e },
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}}
|
||||
onMediaDeviceFailure={(e, kind) => {
|
||||
|
||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
|
||||
@@ -117,7 +117,9 @@ export const ParticipantTile: (
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onFocus?.(event)
|
||||
setHasKeyboardFocus(true)
|
||||
const target = event.target as HTMLElement | null
|
||||
const isFocusVisible = !!target?.matches?.(':focus-visible')
|
||||
setHasKeyboardFocus(isFocusVisible)
|
||||
},
|
||||
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onBlur?.(event)
|
||||
|
||||
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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_DISTANCE = 300
|
||||
@@ -140,11 +143,53 @@ export function ReactionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||
reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||
const [announcement, setAnnouncement] = useState<string | null>(null)
|
||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
|
||||
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,27 +13,32 @@ import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<div
|
||||
<aside
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
@@ -58,32 +63,37 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size={'sm'}
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Heading
|
||||
slot="title"
|
||||
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
|
||||
position="absolute"
|
||||
top="5"
|
||||
@@ -104,7 +114,7 @@ const StyledSidePanel = ({
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
@@ -125,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const {
|
||||
activePanelId,
|
||||
@@ -144,6 +153,7 @@ export const SidePanel = () => {
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
@@ -153,6 +163,7 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
@@ -164,7 +175,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { A, Div, Text } from '@/primitives'
|
||||
import { A, Div, Icon, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -86,7 +87,7 @@ const ToolButton = ({
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className="material-symbols">chevron_forward</span>
|
||||
<Icon type="symbols" name="chevron_forward" />
|
||||
</div>
|
||||
</RACButton>
|
||||
)
|
||||
@@ -94,10 +95,26 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
@@ -146,7 +163,7 @@ export const Tools = () => {
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<span className="material-symbols">speech_to_text</span>}
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
@@ -154,7 +171,7 @@ export const Tools = () => {
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<span className="material-symbols">mode_standby</span>}
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
+21
-15
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
@@ -19,6 +20,8 @@ export const ParticipantsToggle = ({
|
||||
*/
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
const announcedCount =
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
@@ -31,21 +34,24 @@ export const ParticipantsToggle = ({
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||
count: announcedCount,
|
||||
})}.`}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
</VisualOnlyTooltip>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
@@ -145,7 +146,7 @@ export const ReactionsToggle = () => {
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
|
||||
+185
-85
@@ -9,13 +9,13 @@ import {
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { RiProhibited2Line } from '@remixicon/react'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
|
||||
@@ -50,11 +50,17 @@ export const EffectsConfiguration = ({
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const blurLightRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const [effectStatusMessage, setEffectStatusMessage] = useState('')
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -69,16 +75,95 @@ export const EffectsConfiguration = ({
|
||||
}
|
||||
}, [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 () => {
|
||||
await videoTrack.stopProcessor()
|
||||
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 (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
@@ -104,7 +189,7 @@ export const EffectsConfiguration = ({
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (isSelected(type, options)) {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await clearEffect()
|
||||
} else if (
|
||||
@@ -131,6 +216,8 @@ export const EffectsConfiguration = ({
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -153,8 +240,28 @@ export const EffectsConfiguration = ({
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const strength =
|
||||
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
|
||||
const action = isSelected(type, options) ? 'clear' : 'apply'
|
||||
|
||||
return t(`${type}.${strength}.${action}`)
|
||||
}
|
||||
|
||||
const ariaLabelVirtualBackground = (
|
||||
index: number,
|
||||
imagePath: string
|
||||
): string => {
|
||||
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.descriptions.${index}`)
|
||||
return `${t(`virtual.${prefix}`)} ${backgroundName}`
|
||||
}
|
||||
|
||||
const tooltipVirtualBackground = (index: number): string => {
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -265,65 +372,60 @@ export const EffectsConfiguration = ({
|
||||
>
|
||||
{t('blur.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={t('clear')}
|
||||
onPress={async () => {
|
||||
await clearEffect()
|
||||
}}
|
||||
isSelected={!getProcessor()}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<RiProhibited2Line />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
<ToggleButton
|
||||
ref={blurLightRef}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{effectStatusMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
@@ -343,39 +445,37 @@ export const EffectsConfiguration = ({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
paddingBottom: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
const tooltipText = tooltipVirtualBackground(i)
|
||||
return (
|
||||
<ToggleButton
|
||||
key={i}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={ariaLabelVirtualBackground(i, imagePath)}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import React, { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) {
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
// when the chat panel is closed.
|
||||
const prevIsChatOpenRef = React.useRef(false)
|
||||
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wasChatOpen = prevIsChatOpenRef.current
|
||||
const isChatPanelOpen = isChatOpen
|
||||
|
||||
// Chat just opened
|
||||
if (!wasChatOpen && isChatPanelOpen) {
|
||||
chatTriggerRef.current = document.activeElement as HTMLElement | null
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
useRestoreFocus(isChatOpen, {
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ 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])
|
||||
},
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MobileControlBar({
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
id="controlbar-more-options-trigger"
|
||||
square
|
||||
variant="secondaryDark"
|
||||
aria-label={t('controls.moreOptions')}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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,7 +3,9 @@ import { Button } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||
import { useLocation, useSearchParams } from 'wouter'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useMemo } from 'react'
|
||||
import { DisconnectReason } from 'livekit-client'
|
||||
|
||||
// fixme - duplicated with home, refactor in a proper style
|
||||
const Heading = styled('h1', {
|
||||
@@ -20,25 +22,40 @@ const Heading = styled('h1', {
|
||||
},
|
||||
})
|
||||
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
}
|
||||
|
||||
export const FeedbackRoute = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
const reasonKey = useMemo(() => {
|
||||
const state = window.history.state
|
||||
|
||||
if (!state?.reason) return
|
||||
switch (state.reason) {
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
return DisconnectReasonKey.DuplicateIdentity
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
return DisconnectReasonKey.ParticipantRemoved
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<Heading>
|
||||
{t(
|
||||
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
|
||||
)}
|
||||
</Heading>
|
||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||
<HStack>
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
{showBackButton && (
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||
{t('feedback.home')}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Dialog, type DialogProps } from '@/primitives'
|
||||
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { text } from '@/primitives/Text.tsx'
|
||||
import { Icon } from '@/primitives/Icon'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -21,6 +23,7 @@ import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -106,11 +109,16 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
</Tab>
|
||||
{isAdminOrOwner && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||
<span className="material-symbols">speech_to_text</span>
|
||||
<Icon type="symbols" name="speech_to_text" />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
@@ -124,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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,6 +18,14 @@ export const useTranscriptionLanguage = () => {
|
||||
key: RecordingLanguage.ENGLISH,
|
||||
label: t('language.options.english'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.GERMAN,
|
||||
label: t('language.options.german'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.DUTCH,
|
||||
label: t('language.options.dutch'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.AUTOMATIC,
|
||||
label: t('language.options.auto'),
|
||||
|
||||
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSubtitles } from '../hooks/useSubtitles'
|
||||
import { css, cva } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
|
||||
id: string
|
||||
text: string
|
||||
language: string
|
||||
startTime: number
|
||||
startTime?: number
|
||||
endTime: number
|
||||
final: boolean
|
||||
firstReceivedTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
export interface TranscriptionSegmentWithParticipant
|
||||
extends TranscriptionSegment {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export interface TranscriptionRow {
|
||||
id: string
|
||||
participant: Participant
|
||||
segments: TranscriptionSegment[]
|
||||
startTime: number
|
||||
startTime?: number
|
||||
lastUpdateTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
const useTranscriptionState = () => {
|
||||
const [transcriptionRows, setTranscriptionRows] = useState<
|
||||
TranscriptionRow[]
|
||||
const [transcriptionSegments, setTranscriptionSegments] = useState<
|
||||
TranscriptionSegmentWithParticipant[]
|
||||
>([])
|
||||
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
|
||||
useState<string | null>(null)
|
||||
|
||||
const updateTranscriptions = (
|
||||
const updateTranscriptionSegments = (
|
||||
segments: TranscriptionSegment[],
|
||||
participant?: Participant
|
||||
) => {
|
||||
console.log(participant, segments)
|
||||
|
||||
if (!participant || segments.length === 0) return
|
||||
|
||||
setTranscriptionRows((prevRows) => {
|
||||
const updatedRows = [...prevRows]
|
||||
const now = Date.now()
|
||||
if (segments.length > 1) {
|
||||
console.warn('Unexpected error more segments')
|
||||
return
|
||||
}
|
||||
|
||||
const shouldAppendToLastRow =
|
||||
lastActiveParticipantIdentity === participant.identity &&
|
||||
updatedRows.length > 0
|
||||
const segment = segments[0]
|
||||
|
||||
if (shouldAppendToLastRow) {
|
||||
const lastRowIndex = updatedRows.length - 1
|
||||
const lastRow = updatedRows[lastRowIndex]
|
||||
|
||||
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
|
||||
const newSegments = segments.filter(
|
||||
(segment) => !existingSegmentIds.has(segment.id)
|
||||
)
|
||||
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
|
||||
})
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
transcriptionRows,
|
||||
updateTranscriptions,
|
||||
clearTranscriptions,
|
||||
updateParticipant,
|
||||
updateTranscriptionSegments,
|
||||
transcriptionSegments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
|
||||
export const Subtitles = () => {
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const room = useRoomContext()
|
||||
const { transcriptionRows, updateTranscriptions, updateParticipant } =
|
||||
|
||||
const { transcriptionSegments, updateTranscriptionSegments } =
|
||||
useTranscriptionState()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
return () => {
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
}
|
||||
}, [room, updateTranscriptions])
|
||||
}, [room, updateTranscriptionSegments])
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
const transcriptionRows = useMemo(() => {
|
||||
if (transcriptionSegments.length === 0) return []
|
||||
|
||||
const rows: TranscriptionRow[] = []
|
||||
let currentRow: TranscriptionRow | null = null
|
||||
|
||||
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 (
|
||||
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useStartSubtitle } from '../api/startSubtitle'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
export const useSubtitles = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
|
||||
const room = useRoomContext()
|
||||
const apiRoomData = useRoomData()
|
||||
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
|
||||
|
||||
@@ -20,6 +24,18 @@ export const useSubtitles = () => {
|
||||
layoutStore.showSubtitles = !layoutSnap.showSubtitles
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
|
||||
const closeSubtitles = () => {
|
||||
layoutStore.showSubtitles = false
|
||||
}
|
||||
room.on(RoomEvent.Disconnected, closeSubtitles)
|
||||
return () => {
|
||||
room.off(RoomEvent.Disconnected, closeSubtitles)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return {
|
||||
areSubtitlesOpen: layoutSnap.showSubtitles,
|
||||
toggleSubtitles,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
])
|
||||
}
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"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.",
|
||||
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Gruppenanrufe ohne Einschränkungen",
|
||||
"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"
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße."
|
||||
},
|
||||
"slide3": {
|
||||
"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!",
|
||||
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"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",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
@@ -85,6 +86,7 @@
|
||||
"heading": "Ihr Meeting ist bereit",
|
||||
"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.",
|
||||
"closeDialog": "Dialogfenster schließen",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
@@ -191,7 +193,9 @@
|
||||
"leave": "Verlassen",
|
||||
"participants": {
|
||||
"open": "Alle ausblenden",
|
||||
"closed": "Alle anzeigen"
|
||||
"closed": "Alle anzeigen",
|
||||
"count_one": "{{count}} Teilnehmer",
|
||||
"count_other": "{{count}} Teilnehmer"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Weitere Tools ausblenden",
|
||||
@@ -205,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Reaktion senden",
|
||||
"send": "Reaktion {{emoji}} senden",
|
||||
"you": "Sie"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"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": {
|
||||
@@ -233,15 +248,34 @@
|
||||
"clear": "Effekt deaktivieren",
|
||||
"blur": {
|
||||
"title": "Hintergrundunschärfe",
|
||||
"light": "Leichte Unschärfe",
|
||||
"normal": "Unschärfe",
|
||||
"apply": "Unschärfe aktivieren",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
"status": {
|
||||
"none": "Hintergrund scharf.",
|
||||
"light": "Hintergrund leicht verschwommen.",
|
||||
"strong": "Hintergrund stark verschwommen."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Hintergrund leicht verwischen",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Hintergrund verwischen",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtueller Hintergrund",
|
||||
"apply": "Virtuellen Hintergrund aktivieren",
|
||||
"clear": "Virtuellen Hintergrund deaktivieren"
|
||||
"selectedLabel": "Hintergrund angewendet:",
|
||||
"apply": "Ersetze deinen Hintergrund:",
|
||||
"descriptions": {
|
||||
"0": "Gerilltes Holzmöbel",
|
||||
"1": "Besprechungsraum",
|
||||
"2": "Loft mit schwarzer Glaswand",
|
||||
"3": "Esszimmer",
|
||||
"4": "Holzregale",
|
||||
"5": "Holztreppe",
|
||||
"6": "Graue Bibliothek",
|
||||
"7": "Kaffeetheke"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visuelle Effekte",
|
||||
@@ -256,6 +290,8 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "Französisch (fr)",
|
||||
"english": "Englisch (en)",
|
||||
"dutch": "Niederländisch (nl)",
|
||||
"german": "Deutsch (de)",
|
||||
"auto": "Automatisch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"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": {
|
||||
"section1": {
|
||||
"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.",
|
||||
"title": "7.1. Verfügbarkeitsniveau",
|
||||
"paragraphs": [
|
||||
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
|
||||
"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.",
|
||||
"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."
|
||||
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "7.2. Open Source und Lizenzen",
|
||||
"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."
|
||||
"title": "7.2. Sicherheit des Dienstes",
|
||||
"paragraphs": [
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"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.",
|
||||
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
|
||||
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Host group calls without limits",
|
||||
"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"
|
||||
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Transform your meetings with AI",
|
||||
"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"
|
||||
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"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",
|
||||
"back": "Rejoin the meeting"
|
||||
@@ -15,7 +16,7 @@
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
},
|
||||
"effects": "Effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"videoinput": {
|
||||
"choose": "Select camera",
|
||||
"permissionsNeeded": "Select camera - permission needed",
|
||||
@@ -38,7 +39,7 @@
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"description": "Apply backgrounds and effects",
|
||||
"title": "Effects",
|
||||
"subTitle": "Configure your camera's effects."
|
||||
},
|
||||
@@ -85,6 +86,7 @@
|
||||
"heading": "Your meeting is ready",
|
||||
"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.",
|
||||
"closeDialog": "Close dialog",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
@@ -191,7 +193,9 @@
|
||||
"leave": "Leave",
|
||||
"participants": {
|
||||
"open": "Hide everyone",
|
||||
"closed": "See everyone"
|
||||
"closed": "See everyone",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Hide more tools",
|
||||
@@ -205,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Send reaction",
|
||||
"send": "Send reaction {{emoji}}",
|
||||
"you": "you"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"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": {
|
||||
@@ -216,7 +231,7 @@
|
||||
"screenRecording": "Screen recording",
|
||||
"transcript": "Transcription",
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
@@ -233,15 +248,34 @@
|
||||
"clear": "Disable effect",
|
||||
"blur": {
|
||||
"title": "Background blur",
|
||||
"light": "Light blur",
|
||||
"normal": "Blur",
|
||||
"apply": "Enable blur",
|
||||
"clear": "Disable blur"
|
||||
"status": {
|
||||
"none": "Background clear.",
|
||||
"light": "Background slightly blurred.",
|
||||
"strong": "Background strongly blurred."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Slightly blur your background",
|
||||
"clear": "Disable blur"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Blur your background",
|
||||
"clear": "Disable blur"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtual background",
|
||||
"apply": "Enable virtual background",
|
||||
"clear": "Disable virtual background"
|
||||
"selectedLabel": "Background applied:",
|
||||
"apply": "Replace your background:",
|
||||
"descriptions": {
|
||||
"0": "Fluted wooden furniture",
|
||||
"1": "Meeting room",
|
||||
"2": "Loft with black glass partition",
|
||||
"3": "Dining room",
|
||||
"4": "Wooden shelves",
|
||||
"5": "Wooden staircase",
|
||||
"6": "Gray library",
|
||||
"7": "Coffee counter"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visual Effects",
|
||||
@@ -256,9 +290,11 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "Transcribe",
|
||||
"screenRecording": "Record",
|
||||
@@ -268,7 +304,7 @@
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"chat": "messages",
|
||||
"transcript": "transcribe",
|
||||
"screenRecording": "record",
|
||||
@@ -543,7 +579,7 @@
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
},
|
||||
"effects": "Apply visual effects",
|
||||
"effects": "Apply backgrounds and effects",
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"fullScreen": "Full screen"
|
||||
},
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "French (fr)",
|
||||
"english": "English (en)",
|
||||
"dutch": "Dutch (nl)",
|
||||
"german": "German (de)",
|
||||
"auto": "Automatic"
|
||||
}
|
||||
}
|
||||
@@ -106,12 +108,18 @@
|
||||
"label": "Language"
|
||||
},
|
||||
"settingsButtonLabel": "Settings",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"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": {
|
||||
"section1": {
|
||||
"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.",
|
||||
"title": "7.1. Service Availability",
|
||||
"paragraphs": [
|
||||
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
|
||||
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
|
||||
"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."
|
||||
"The service is available 24/7, except during maintenance windows.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "7.2 Open Source and Licenses",
|
||||
"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."
|
||||
"title": "7.2. Service Security",
|
||||
"paragraphs": [
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"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.",
|
||||
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
|
||||
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
|
||||
},
|
||||
"slide2": {
|
||||
"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.",
|
||||
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
|
||||
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
|
||||
},
|
||||
"slide3": {
|
||||
"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 !",
|
||||
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
|
||||
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"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",
|
||||
"back": "Réintégrer la réunion"
|
||||
@@ -15,7 +16,7 @@
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
},
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"videoinput": {
|
||||
"choose": "Choisir la webcam",
|
||||
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||
@@ -39,8 +40,8 @@
|
||||
"join": {
|
||||
"heading": "Rejoindre la réunion ?",
|
||||
"effects": {
|
||||
"description": "Effets d'arrière-plan",
|
||||
"title": "Effets",
|
||||
"description": "Arrière-plans et effets",
|
||||
"title": "Arrière-plans et effets",
|
||||
"subTitle": "Paramétrez les effets de votre caméra."
|
||||
},
|
||||
"joinLabel": "Rejoindre",
|
||||
@@ -85,6 +86,7 @@
|
||||
"heading": "Votre réunion est prête",
|
||||
"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.",
|
||||
"closeDialog": "Fermer la fenêtre modale",
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
@@ -191,7 +193,9 @@
|
||||
"leave": "Quitter",
|
||||
"participants": {
|
||||
"open": "Masquer les participants",
|
||||
"closed": "Afficher les participants"
|
||||
"closed": "Afficher les participants",
|
||||
"count_one": "{{count}} participant",
|
||||
"count_other": "{{count}} participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Masquer les outils de réunion",
|
||||
@@ -205,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Envoyer une réaction",
|
||||
"send": "Envoyer la réaction {{emoji}}",
|
||||
"you": "vous"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"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": {
|
||||
@@ -216,7 +231,7 @@
|
||||
"screenRecording": "Enregistrement",
|
||||
"transcript": "Transcription",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
@@ -230,18 +245,37 @@
|
||||
"cameraDisabled": "Votre caméra est désactivée.",
|
||||
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
|
||||
"heading": "Flou",
|
||||
"clear": "Désactiver l'effect",
|
||||
"clear": "Désactiver l'effet",
|
||||
"blur": {
|
||||
"title": "Flou d'arrière-plan",
|
||||
"light": "Flou léger",
|
||||
"normal": "Flou",
|
||||
"apply": "Activer le flou",
|
||||
"clear": "Désactiver le flou"
|
||||
"status": {
|
||||
"none": "Arrière-plan net.",
|
||||
"light": "Arrière-plan légèrement flouté.",
|
||||
"strong": "Arrière-plan fortement flouté."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Flouter l'arrière-plan",
|
||||
"clear": "Désactiver le flou"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Flouter encore plus l'arrière-plan",
|
||||
"clear": "Désactiver le flou"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Arrière-plan virtuel",
|
||||
"apply": "Activer l'arrière-plan virtuel",
|
||||
"clear": "Désactiver l'arrière-plan virtuel"
|
||||
"selectedLabel": "Arrière-plan appliqué :",
|
||||
"apply": "Remplacer votre arrière plan :",
|
||||
"descriptions": {
|
||||
"0": "Meuble cannelé en bois",
|
||||
"1": "Salle de réunion",
|
||||
"2": "Loft avec verrière noire",
|
||||
"3": "Salle à manger",
|
||||
"4": "Étagères en bois",
|
||||
"5": "Escalier en bois",
|
||||
"6": "Bibliothèque grise",
|
||||
"7": "Comptoir de café"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Effets visuels",
|
||||
@@ -256,9 +290,11 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Panneau latéral",
|
||||
"backToTools": "Retour aux outils de réunion",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effets",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"chat": "Messages dans l'appel",
|
||||
"transcript": "Transcrire",
|
||||
"screenRecording": "Enregistrer",
|
||||
@@ -543,7 +579,7 @@
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
},
|
||||
"effects": "Effets d'arrière-plan",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "Français (fr)",
|
||||
"english": "Anglais (en)",
|
||||
"dutch": "Hollandais (nl)",
|
||||
"german": "Allemand (de)",
|
||||
"auto": "Automatique"
|
||||
}
|
||||
}
|
||||
@@ -106,12 +108,18 @@
|
||||
"label": "Langue de l'application"
|
||||
},
|
||||
"settingsButtonLabel": "Paramètres",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Vocaliser les réactions"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profil",
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Utilisation",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Fonctionnalités",
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Engagements et responsabilités de la DINUM",
|
||||
"content": "La DINUM est responsable de l’administration 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 d’assurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"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.",
|
||||
"title": "7.1. Niveau de disponibilité",
|
||||
"paragraphs": [
|
||||
"La DINUM s'engage à fournir les moyens nécessaires et raisonnables pour assurer un accès continu à Visio.",
|
||||
"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.",
|
||||
"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."
|
||||
"La plage d’ouverture du Service est 24h/24 7j/7, hors période d’indisponibilité pour maintenance.",
|
||||
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas d’incident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
|
||||
"Dans le cadre d’une maintenance de l’environnement d’exé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 à l’avance. En cas d’urgence, cette suspension peut intervenir sans préavis.",
|
||||
"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 d’architecture.",
|
||||
"L’indisponibilité du service n’ouvre droit à aucune compensation de quelque nature que ce soit."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2 Open Source et Licences",
|
||||
"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."
|
||||
"title": "7.2. Sécurité du service",
|
||||
"paragraphs": [
|
||||
"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 s’agissant de l’accè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.",
|
||||
"L’objectif de réponse aux sollicitations est de deux jours ouvrés à compter de l’envoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Contrôle de l’utilisation 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 d’utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l’encontre de l’agent concerné.",
|
||||
"La suspension ou la révocation d’un ou plusieurs comptes ne donne lieu à aucune compensation d’aucune 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, à l’exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -44,18 +44,15 @@
|
||||
},
|
||||
"slide1": {
|
||||
"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.",
|
||||
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
|
||||
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Houdt groepsgesprekken zonder limieten",
|
||||
"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"
|
||||
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
|
||||
},
|
||||
"slide3": {
|
||||
"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!",
|
||||
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
|
||||
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"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",
|
||||
"back": "Sluit weer bij de vergadering aan"
|
||||
@@ -85,6 +86,7 @@
|
||||
"heading": "Uw vergadering is klaar",
|
||||
"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.",
|
||||
"closeDialog": "Sluit het dialoogvenster",
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
"pinCode": "Code:"
|
||||
@@ -191,7 +193,9 @@
|
||||
"leave": "Vertrekken",
|
||||
"participants": {
|
||||
"open": "Verberg iedereen",
|
||||
"closed": "Toon iedereen"
|
||||
"closed": "Toon iedereen",
|
||||
"count_one": "{{count}} deelnemer",
|
||||
"count_other": "{{count}} deelnemers"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Meer tools verbergen",
|
||||
@@ -205,7 +209,18 @@
|
||||
"reactions": {
|
||||
"button": "Stuur reactie",
|
||||
"send": "Stuur reactie {{emoji}}",
|
||||
"you": "U"
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"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": {
|
||||
@@ -233,15 +248,34 @@
|
||||
"clear": "Effect uitschakelen",
|
||||
"blur": {
|
||||
"title": "Achtergrondvervaging",
|
||||
"light": "Lichte vervaging",
|
||||
"normal": "Vervaging",
|
||||
"apply": "Vervaging inschakelen",
|
||||
"clear": "Vervaging uitschakelen"
|
||||
"status": {
|
||||
"none": "Achtergrond scherp.",
|
||||
"light": "Achtergrond licht vervaagd.",
|
||||
"strong": "Achtergrond sterk vervaagd."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Vervaag je achtergrond lichtjes",
|
||||
"clear": "Vervaging uitschakelen"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Vervaag je achtergrond",
|
||||
"clear": "Vervaging uitschakelen"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtuele achtergrond",
|
||||
"apply": "Virtuele achtergrond inschakelen",
|
||||
"clear": "Virtuele achtergrond uitschakelen"
|
||||
"selectedLabel": "Achtergrond toegepast:",
|
||||
"apply": "Vervang je achtergrond:",
|
||||
"descriptions": {
|
||||
"0": "Geprofileerd houten meubel",
|
||||
"1": "Vergaderruimte",
|
||||
"2": "Loft met zwarte glaswand",
|
||||
"3": "Eetkamer",
|
||||
"4": "Houten planken",
|
||||
"5": "Houten trap",
|
||||
"6": "Grijze bibliotheek",
|
||||
"7": "Koffiebar"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visuele effecten",
|
||||
@@ -256,6 +290,8 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Zijbalk",
|
||||
"backToTools": "Terug naar vergadertools",
|
||||
"heading": {
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "Frans (fr)",
|
||||
"english": "Engels (en)",
|
||||
"dutch": "Nederlands (nl)",
|
||||
"german": "Duits (de)",
|
||||
"auto": "Automatisch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"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": {
|
||||
"section1": {
|
||||
"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.",
|
||||
"title": "7.1. Beschikbaarheidsniveau",
|
||||
"paragraphs": [
|
||||
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
|
||||
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
|
||||
"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."
|
||||
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "7.2 Open Source and Licenses",
|
||||
"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."
|
||||
"title": "7.2. Beveiliging van de dienst",
|
||||
"paragraphs": [
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, forwardRef } from 'react'
|
||||
import { Loader } from './Loader'
|
||||
|
||||
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
@@ -17,25 +17,24 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
tooltip,
|
||||
tooltipType = 'instant',
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
const { className, ...remainingComponentProps } = componentProps
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
const { className, ...remainingComponentProps } = componentProps
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
className={[buttonRecipe(variantProps), className].join(' ')}
|
||||
{...(remainingComponentProps as RACButtonsProps)}
|
||||
>
|
||||
{!props.loading && props.icon}
|
||||
{props.loading && <Loader />}
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</RACButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACButton
|
||||
ref={ref}
|
||||
className={[buttonRecipe(variantProps), className].join(' ')}
|
||||
{...(remainingComponentProps as RACButtonsProps)}
|
||||
>
|
||||
{!props.loading && props.icon}
|
||||
{props.loading && <Loader />}
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</RACButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { forwardRef, ReactNode } from 'react'
|
||||
import {
|
||||
ToggleButton as RACToggleButton,
|
||||
ToggleButtonProps as RACToggleButtonProps,
|
||||
} from 'react-aria-components'
|
||||
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
|
||||
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type ToggleButtonProps = RACToggleButtonProps &
|
||||
ButtonRecipeProps &
|
||||
@@ -16,24 +16,25 @@ export type ToggleButtonProps = RACToggleButtonProps &
|
||||
/**
|
||||
* React aria ToggleButton with our button styles, that can take a tooltip if needed
|
||||
*/
|
||||
export const ToggleButton = ({
|
||||
tooltip,
|
||||
tooltipType,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
|
||||
({ tooltip, tooltipType, ...props }, ref) => {
|
||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACToggleButton
|
||||
{...componentProps}
|
||||
className={[buttonRecipe(variantProps), props.className].join(' ')}
|
||||
>
|
||||
<>
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</>
|
||||
</RACToggleButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||
<RACToggleButton
|
||||
ref={ref}
|
||||
{...componentProps}
|
||||
className={[buttonRecipe(variantProps), props.className].join(' ')}
|
||||
>
|
||||
<>
|
||||
{componentProps.children as ReactNode}
|
||||
{props.description && <span>{tooltip}</span>}
|
||||
</>
|
||||
</RACToggleButton>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ToggleButton.displayName = 'ToggleButton'
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -112,7 +112,8 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
'&[data-selected]': {
|
||||
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
|
||||
boxShadow:
|
||||
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
backgroundColor: 'greyscale.100',
|
||||
|
||||
@@ -30,3 +30,4 @@ export { Ul } from './Ul'
|
||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||
export { TextArea } from './TextArea'
|
||||
export { Switch } from './Switch'
|
||||
export { Icon } from './Icon'
|
||||
|
||||
@@ -43,11 +43,6 @@ export const routes: Record<
|
||||
feedback: {
|
||||
name: 'feedback',
|
||||
path: '/feedback',
|
||||
to: (params: { duplicateIdentity?: false }) =>
|
||||
'/feedback' +
|
||||
(params.duplicateIdentity
|
||||
? `?duplicateIdentity=${params?.duplicateIdentity}`
|
||||
: ''),
|
||||
Component: FeedbackRoute,
|
||||
},
|
||||
legalTerms: {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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)
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { proxy } from 'valtio'
|
||||
export enum RecordingLanguage {
|
||||
ENGLISH = 'en',
|
||||
FRENCH = 'fr',
|
||||
DUTCH = 'nl',
|
||||
GERMAN = 'de',
|
||||
AUTOMATIC = 'auto',
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
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,
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
@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,5 +1,4 @@
|
||||
@import './livekit.css';
|
||||
@import './icons.css';
|
||||
@layer reset, base, tokens, recipes, utilities;
|
||||
html,
|
||||
body,
|
||||
@@ -7,6 +6,18 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
@@ -34,6 +45,10 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html:has(.lk-video-conference) {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
@keyframes slide-full {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
@@ -42,3 +57,36 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Generated using the Advanced installation guidelines from
|
||||
* https://fontsource.org/fonts/material-icons-outlined/install
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url(@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2)
|
||||
format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Generated using the Advanced installation guidelines from
|
||||
* https://fontsource.org/fonts/material-symbols-outlined/install
|
||||
*/
|
||||
/* material-symbols-outlined-latin-wght-normal */
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined Variable';
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
font-weight: 100 700;
|
||||
src: url(@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2)
|
||||
format('woff2-variations');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
export const STORAGE_KEYS = {
|
||||
NOTIFICATIONS: 'app_notification_settings',
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
} as const
|
||||
|
||||
@@ -267,6 +267,7 @@ agents:
|
||||
LIVEKIT_API_KEY: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ENABLE_SILERO_VAD: "false"
|
||||
|
||||
image:
|
||||
repository: localhost:5001/meet-agents
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -44,7 +44,7 @@ class Settings(BaseSettings):
|
||||
whisperx_max_retries: int = 0
|
||||
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
||||
whisperx_default_language: Optional[str] = None
|
||||
whisperx_allowed_languages: Set[str] = {"en", "fr"}
|
||||
whisperx_allowed_languages: Set[str] = {"en", "fr", "de", "nl"}
|
||||
llm_base_url: str
|
||||
llm_api_key: SecretStr
|
||||
llm_model: str
|
||||
|
||||
Reference in New Issue
Block a user