Compare commits
51 Commits
misc-fixes
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bb64532cff | |||
| 692c55ed1b | |||
| df616ae711 | |||
| 021d7a7e06 | |||
| f2a3e7c8de | |||
| cf07ceb67e | |||
| ea7fb5fc27 | |||
| 6e8a6ce82a | |||
| ce960ae330 | |||
| f9dd2e1909 | |||
| 9023e54352 | |||
| 8295574616 | |||
| db15c8b6cc | |||
| e1aeec6053 | |||
| c5aa762e11 | |||
| 8f710a4626 | |||
| 60d1338eff | |||
| f8436d9ae2 | |||
| 39fb273201 | |||
| d101459115 | |||
| 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 |
@@ -37,12 +37,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -8,11 +8,50 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.0] - 2026-01-28
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
|
||||
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
|
||||
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
|
||||
- ♿️(frontend) add global screen reader announcer#922
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
|
||||
|
||||
## [1.4.0] - 2026-01-25
|
||||
|
||||
### 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
|
||||
|
||||
@@ -25,6 +64,7 @@ and this project adheres to
|
||||
### 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
|
||||
|
||||
|
||||
+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 && \
|
||||
|
||||
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
### 🚀 Major roll out to all French public servants
|
||||
|
||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -86,7 +89,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- env.d/postgresql
|
||||
- env.d/common
|
||||
volumes:
|
||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||
- ./data/databases/backend:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
### Step 1: Prepare your working environment:
|
||||
|
||||
```bash
|
||||
mkdir keycloak/env.d && cd keycloak
|
||||
mkdir -p keycloak/env.d && cd keycloak
|
||||
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
|
||||
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
#### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -129,7 +129,7 @@ The following ports will need to be opened:
|
||||
- 7881/tcp - WebRTC ICE over TCP
|
||||
- 7882/udp - for WebRTC multiplexing over UDP
|
||||
|
||||
If you are using ufw, enter the follwoing:
|
||||
If you are using ufw, enter the following:
|
||||
```
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
|
||||
# external: true
|
||||
```
|
||||
|
||||
#### Caddy Reverse Proxy
|
||||
Expose the Frontend port to the host
|
||||
```yaml
|
||||
frontend:
|
||||
…
|
||||
ports:
|
||||
- "8086:8086"
|
||||
```
|
||||
|
||||
## Step 5: Start Meet
|
||||
|
||||
You are ready to start your Meet application !
|
||||
@@ -198,7 +207,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
|
||||
|
||||
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
|
||||
|
||||
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
|
||||
|
||||
## How to upgrade your Meet application
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="https://${MEET_HOST}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.2.0"
|
||||
version = "1.5.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.5.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -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.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.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.5.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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
@@ -141,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
|
||||
|
||||
@@ -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, useRef, useState, useEffect } from 'react'
|
||||
import { useMemo, useRef, useEffect } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import {
|
||||
RecordingMode,
|
||||
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
@@ -21,8 +22,8 @@ export const RecordingStateToast = () => {
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -76,16 +77,9 @@ export const RecordingStateToast = () => {
|
||||
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)
|
||||
announce(message)
|
||||
}
|
||||
}, [key, t])
|
||||
}, [announce, key, t])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
@@ -95,15 +89,6 @@ export const RecordingStateToast = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Screen reader only message to announce state changes once */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{srMessage}
|
||||
</div>
|
||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -104,14 +104,12 @@ export const RecordingDownload = () => {
|
||||
{t('success.title')}
|
||||
</H>
|
||||
<Text centered margin="md" wrap={'balance'}>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('success.body', {
|
||||
room: data.room.name,
|
||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{t('success.body', {
|
||||
room: data.room.name,
|
||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{configData?.recording?.expiration_days && (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||
const FINAL_COUNTDOWN_SECONDS = 10
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||
const lastAnnouncementRef = useRef<number | null>(null)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||
@@ -35,10 +40,42 @@ export const IsIdleDisconnectModal = () => {
|
||||
}
|
||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||
|
||||
const minutes = Math.floor(timeRemaining / 1000 / 60)
|
||||
const seconds = (timeRemaining / 1000) % 60
|
||||
useEffect(() => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||
lastAnnouncementRef.current = null
|
||||
}
|
||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||
|
||||
const remainingSeconds = Math.floor(timeRemaining / 1000)
|
||||
const minutes = Math.floor(remainingSeconds / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
||||
|
||||
const shouldAnnounce =
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
|
||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
||||
|
||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
||||
lastAnnouncementRef.current = remainingSeconds
|
||||
const message = t('countdownAnnouncement', {
|
||||
duration: humanizeDuration(remainingSeconds * 1000, {
|
||||
language: i18n.language,
|
||||
round: false,
|
||||
largest: 2,
|
||||
}),
|
||||
})
|
||||
announce(message, 'assertive', 'idle')
|
||||
}
|
||||
}, [
|
||||
announce,
|
||||
connectionObserverSnap.isIdleDisconnectModalOpen,
|
||||
remainingSeconds,
|
||||
t,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||
@@ -52,6 +89,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
{({ close }) => {
|
||||
return (
|
||||
<div>
|
||||
<ScreenReaderAnnouncer channel="idle" />
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
@@ -65,6 +103,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
color: 'blue.800',
|
||||
margin: 'auto',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,10 @@ 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'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const ANIMATION_DURATION = 3000
|
||||
export const ANIMATION_DISTANCE = 300
|
||||
@@ -140,11 +144,41 @@ 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 [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const latestReaction =
|
||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!announceReactions) {
|
||||
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' })
|
||||
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||
setLastAnnouncedId(latestReaction.id)
|
||||
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -23,6 +24,7 @@ type StyledSidePanelProps = {
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
@@ -34,6 +36,7 @@ const StyledSidePanel = ({
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<aside
|
||||
className={css({
|
||||
@@ -63,31 +66,34 @@ const StyledSidePanel = ({
|
||||
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"
|
||||
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"
|
||||
@@ -129,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const {
|
||||
activePanelId,
|
||||
@@ -158,6 +163,7 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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'
|
||||
@@ -87,7 +87,7 @@ const ToolButton = ({
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className="material-symbols">chevron_forward</span>
|
||||
<Icon type="symbols" name="chevron_forward" />
|
||||
</div>
|
||||
</RACButton>
|
||||
)
|
||||
@@ -163,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()}
|
||||
@@ -171,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()}
|
||||
|
||||
+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
|
||||
|
||||
+46
-34
@@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -56,11 +57,11 @@ export const EffectsConfiguration = ({
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const [blurStatusMessage, setBlurStatusMessage] = useState('')
|
||||
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
)
|
||||
const blurAnnouncementId = useRef(0)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -89,27 +90,24 @@ export const EffectsConfiguration = ({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const announceBlurStatusMessage = (message: string) => {
|
||||
blurAnnouncementId.current += 1
|
||||
const currentId = blurAnnouncementId.current
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (blurAnnouncementTimeout.current) {
|
||||
clearTimeout(blurAnnouncementTimeout.current)
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
// Clear the region first so screen readers drop queued announcements.
|
||||
setBlurStatusMessage('')
|
||||
|
||||
blurAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== blurAnnouncementId.current) return
|
||||
setBlurStatusMessage(message)
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
@@ -118,25 +116,42 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const updateBlurStatusMessage = (
|
||||
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 (type !== ProcessorType.BLUR) return
|
||||
|
||||
let message = ''
|
||||
|
||||
if (wasSelectedBeforeToggle) {
|
||||
message = t('blur.status.none')
|
||||
} else if (options.blurRadius === BlurRadius.LIGHT) {
|
||||
message = t('blur.status.light')
|
||||
} else if (options.blurRadius === BlurRadius.NORMAL) {
|
||||
message = t('blur.status.strong')
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (message) {
|
||||
announceBlurStatusMessage(message)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +215,7 @@ export const EffectsConfiguration = ({
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -406,9 +421,6 @@ export const EffectsConfiguration = ({
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{blurStatusMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
isWeb,
|
||||
log,
|
||||
} from '@livekit/components-core'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Participant, RoomEvent, Track } from 'livekit-client'
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
FocusLayoutContainer,
|
||||
@@ -16,7 +15,9 @@ import {
|
||||
usePinnedTracks,
|
||||
useTracks,
|
||||
useCreateLayoutContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ControlBar } from './ControlBar/ControlBar'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -37,6 +38,8 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -89,7 +92,22 @@ export interface VideoConferenceProps
|
||||
*/
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
if (!participant) return tRooms('participants.unknown')
|
||||
return participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
},
|
||||
[tRooms]
|
||||
)
|
||||
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
@@ -115,9 +133,62 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
(track) => !isEqualTrackRef(track, focusTrack)
|
||||
)
|
||||
|
||||
// handle pin announcements
|
||||
|
||||
useEffect(() => {
|
||||
const participant = focusTrack?.participant
|
||||
|
||||
// 1. unpin
|
||||
if (!participant) {
|
||||
if (!lastPinnedParticipantIdentityRef.current) return
|
||||
|
||||
const lastIdentity = lastPinnedParticipantIdentityRef.current
|
||||
const lastParticipant =
|
||||
room.localParticipant.identity === lastIdentity
|
||||
? room.localParticipant
|
||||
: room.remoteParticipants.get(lastIdentity)
|
||||
const announcementName = getAnnouncementName(lastParticipant)
|
||||
|
||||
announce(
|
||||
lastParticipant?.isLocal
|
||||
? t('self.unpin')
|
||||
: t('unpin', {
|
||||
name: announcementName,
|
||||
})
|
||||
)
|
||||
|
||||
lastPinnedParticipantIdentityRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// 2. same pin → do nothing
|
||||
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. new pin
|
||||
const participantName = participant.isLocal
|
||||
? tRooms('participants.you')
|
||||
: getParticipantName(participant)
|
||||
|
||||
lastPinnedParticipantIdentityRef.current = participant.identity
|
||||
|
||||
announce(
|
||||
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
|
||||
)
|
||||
}, [
|
||||
announce,
|
||||
focusTrack,
|
||||
getAnnouncementName,
|
||||
room.localParticipant,
|
||||
room.remoteParticipants,
|
||||
t,
|
||||
tRooms,
|
||||
])
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||
if (
|
||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||
|
||||
@@ -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
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
announceToScreenReader,
|
||||
type Politeness,
|
||||
type ScreenReaderChannel,
|
||||
} from '@/stores/screenReaderAnnouncer'
|
||||
|
||||
export const useScreenReaderAnnounce = () => {
|
||||
return useCallback(
|
||||
(
|
||||
message: string,
|
||||
politeness: Politeness = 'polite',
|
||||
channel: ScreenReaderChannel = 'global'
|
||||
) => {
|
||||
announceToScreenReader(message, politeness, channel)
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
@@ -90,6 +91,11 @@ export const Header = () => {
|
||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const userLabel = user?.full_name || user?.email
|
||||
const loggedInTooltip = t('loggedInUserTooltip')
|
||||
const loggedInAriaLabel = userLabel
|
||||
? `${loggedInTooltip} ${userLabel}`
|
||||
: loggedInTooltip
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -153,23 +159,24 @@ export const Header = () => {
|
||||
)}
|
||||
{!!user && (
|
||||
<Menu>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondaryText"
|
||||
tooltip={t('loggedInUserTooltip')}
|
||||
tooltipType="delayed"
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '350px',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
<Button size="sm" variant="secondaryText">
|
||||
<VisualOnlyTooltip
|
||||
tooltip={loggedInTooltip}
|
||||
ariaLabel={loggedInAriaLabel}
|
||||
tooltipPosition="bottom"
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '350px',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{user?.full_name || user?.email}
|
||||
</span>
|
||||
</VisualOnlyTooltip>
|
||||
</Button>
|
||||
<MenuList
|
||||
variant={'light'}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Header } from './Header'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Footer } from '@/layout/Footer'
|
||||
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||
|
||||
export type Layout = 'fullpage' | 'centered'
|
||||
|
||||
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<ScreenReaderAnnouncer />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Ihre Aufzeichnung ist bereit!",
|
||||
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
|
||||
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
|
||||
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
||||
"button": "Herunterladen",
|
||||
"warning": {
|
||||
|
||||
@@ -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:"
|
||||
@@ -154,7 +156,8 @@
|
||||
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
|
||||
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
|
||||
"stayButton": "Gespräch fortsetzen",
|
||||
"leaveButton": "Jetzt verlassen"
|
||||
"leaveButton": "Jetzt verlassen",
|
||||
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Mikrofon",
|
||||
@@ -191,7 +194,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 +210,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": {
|
||||
@@ -276,6 +292,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
@@ -488,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "Im Raum",
|
||||
"you": "Du",
|
||||
"unknown": "Unbekannter Teilnehmer",
|
||||
"host": "Host",
|
||||
"contributors": "Mitwirkende",
|
||||
"collapsable": {
|
||||
@@ -535,6 +553,14 @@
|
||||
"ariaLabel": "Hefte {{name}} an"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "Das Video von {{name}} ist angeheftet.",
|
||||
"unpin": "Das Video von {{name}} ist nicht mehr angeheftet.",
|
||||
"self": {
|
||||
"pin": "Dein Video ist angeheftet.",
|
||||
"unpin": "Dein Video ist nicht mehr angeheftet."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transkription läuft",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "You don't have the permission to view this page"
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"loggedInUserTooltip": "Logged in as…",
|
||||
"loggedInUserTooltip": "Logged in as ",
|
||||
"login": {
|
||||
"buttonLabel": "Login",
|
||||
"proconnectButtonLabel": "Login with ProConnect",
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Your recording is ready!",
|
||||
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
||||
"body": "Recording of the meeting {{room}} from {{created_at}}.",
|
||||
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
|
||||
"button": "Download",
|
||||
"warning": {
|
||||
|
||||
@@ -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"
|
||||
@@ -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:"
|
||||
@@ -154,7 +156,8 @@
|
||||
"body": "You are the only participant. This call will end in {{duration}}. Would you like to continue the call?",
|
||||
"settings": "To stop seeing this message, go to Settings > General.",
|
||||
"stayButton": "Continue the call",
|
||||
"leaveButton": "Leave now"
|
||||
"leaveButton": "Leave now",
|
||||
"countdownAnnouncement": "Call ends in {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
@@ -191,7 +194,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 +210,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": {
|
||||
@@ -276,6 +292,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Backgrounds and Effects",
|
||||
@@ -488,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
"unknown": "Unknown participant",
|
||||
"host": "Host",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
@@ -535,6 +553,14 @@
|
||||
"ariaLabel": "Pin {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "The video of {{name}} is pinned.",
|
||||
"unpin": "The video of {{name}} is no longer pinned.",
|
||||
"self": {
|
||||
"pin": "Your video is pinned.",
|
||||
"unpin": "Your video is no longer pinned."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcribing",
|
||||
|
||||
@@ -108,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "Accès interdit"
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"loggedInUserTooltip": "Connecté en tant que…",
|
||||
"loggedInUserTooltip": "Connecté en tant que ",
|
||||
"login": {
|
||||
"buttonLabel": "Se connecter",
|
||||
"proconnectButtonLabel": "S'identifier avec ProConnect",
|
||||
|
||||
@@ -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 !"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Votre enregistrement est prêt !",
|
||||
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
||||
"body": "Enregistrement de la réunion {{room}} du {{created_at}}.",
|
||||
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
|
||||
"button": "Télécharger",
|
||||
"warning": {
|
||||
|
||||
@@ -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"
|
||||
@@ -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 :"
|
||||
@@ -154,7 +156,8 @@
|
||||
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
|
||||
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
|
||||
"stayButton": "Poursuivre l'appel",
|
||||
"leaveButton": "Quitter maintenant"
|
||||
"leaveButton": "Quitter maintenant",
|
||||
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microphone",
|
||||
@@ -191,7 +194,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 +210,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": {
|
||||
@@ -276,6 +292,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Panneau latéral",
|
||||
"backToTools": "Retour aux outils de réunion",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Arrière-plans et effets",
|
||||
@@ -488,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "Dans la réunion",
|
||||
"you": "Vous",
|
||||
"unknown": "Participant inconnu",
|
||||
"contributors": "Contributeurs",
|
||||
"host": "Organisateur de la réunion",
|
||||
"collapsable": {
|
||||
@@ -535,6 +553,14 @@
|
||||
"ariaLabel": "Épingler {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "La vidéo de {{name}} est épinglée.",
|
||||
"unpin": "La vidéo de {{name}} n’est plus épinglée.",
|
||||
"self": {
|
||||
"pin": "Votre vidéo est épinglée.",
|
||||
"unpin": "Votre vidéo n’est plus épinglée."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
|
||||
@@ -108,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"heading": "U hebt geen toestemming om deze pagina te bekijken"
|
||||
},
|
||||
"loading": "Laden ...",
|
||||
"loggedInUserTooltip": "Ingelogd als ...",
|
||||
"loggedInUserTooltip": "Ingelogd als ",
|
||||
"login": {
|
||||
"buttonLabel": "Log in",
|
||||
"proconnectButtonLabel": "Log in met Proconnect",
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Je opname is klaar!",
|
||||
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
|
||||
"body": "Opname van de vergadering {{room}} op {{created_at}}.",
|
||||
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
|
||||
"button": "Downloaden",
|
||||
"warning": {
|
||||
|
||||
@@ -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:"
|
||||
@@ -154,7 +156,8 @@
|
||||
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
|
||||
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
|
||||
"stayButton": "Gesprek voortzetten",
|
||||
"leaveButton": "Nu verlaten"
|
||||
"leaveButton": "Nu verlaten",
|
||||
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Microfoon",
|
||||
@@ -191,7 +194,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 +210,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": {
|
||||
@@ -276,6 +292,7 @@
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Zijbalk",
|
||||
"backToTools": "Terug naar vergadertools",
|
||||
"heading": {
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
@@ -488,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "In de ruimte",
|
||||
"you": "U",
|
||||
"unknown": "Onbekende deelnemer",
|
||||
"host": "Host",
|
||||
"contributors": "Deelnemers",
|
||||
"collapsable": {
|
||||
@@ -535,6 +553,14 @@
|
||||
"ariaLabel": "Maak {{name}} vast"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
"pin": "De video van {{name}} is vastgezet.",
|
||||
"unpin": "De video van {{name}} is niet meer vastgezet.",
|
||||
"self": {
|
||||
"pin": "Uw video is vastgezet.",
|
||||
"unpin": "Uw video is niet meer vastgezet."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcriptie bezig",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
screenReaderAnnouncerStore,
|
||||
type ScreenReaderChannel,
|
||||
} from '@/stores/screenReaderAnnouncer'
|
||||
|
||||
export const ScreenReaderAnnouncer = ({
|
||||
channel = 'global',
|
||||
}: {
|
||||
channel?: ScreenReaderChannel
|
||||
}) => {
|
||||
const { announcements } = useSnapshot(screenReaderAnnouncerStore)
|
||||
const announcement = announcements[channel]
|
||||
|
||||
if (!announcement) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={announcement.politeness}
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
data-announce-id={announcement.id}
|
||||
data-announce-channel={channel}
|
||||
>
|
||||
{announcement.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { type ReactNode, useRef, useState } from 'react'
|
||||
import {
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export type VisualOnlyTooltipProps = {
|
||||
children: ReactNode
|
||||
children: ReactElement
|
||||
tooltip: string
|
||||
ariaLabel?: string
|
||||
tooltipPosition?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,32 +28,50 @@ export type VisualOnlyTooltipProps = {
|
||||
export const VisualOnlyTooltip = ({
|
||||
children,
|
||||
tooltip,
|
||||
ariaLabel,
|
||||
tooltipPosition = 'top',
|
||||
}: VisualOnlyTooltipProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{
|
||||
top: number
|
||||
left: number
|
||||
} | null>(null)
|
||||
|
||||
const getPosition = () => {
|
||||
if (!wrapperRef.current) return null
|
||||
const isBottom = tooltipPosition === 'bottom'
|
||||
|
||||
const showTooltip = () => {
|
||||
if (!wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
return {
|
||||
top: rect.top - 8,
|
||||
setPosition({
|
||||
top: isBottom ? rect.bottom + 8 : rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
}
|
||||
})
|
||||
setIsVisible(true)
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
setIsVisible(false)
|
||||
setPosition(null)
|
||||
}
|
||||
|
||||
const position = getPosition()
|
||||
const tooltipData = isVisible && position ? { isVisible, position } : null
|
||||
const wrappedChild = isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
||||
})
|
||||
: children
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={showTooltip}
|
||||
onBlur={hideTooltip}
|
||||
>
|
||||
{children}
|
||||
{wrappedChild}
|
||||
</div>
|
||||
{tooltipData &&
|
||||
createPortal(
|
||||
@@ -66,17 +92,26 @@ export const VisualOnlyTooltip = ({
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
border: '4px solid transparent',
|
||||
borderTopColor: 'primaryDark.100',
|
||||
...(isBottom
|
||||
? {
|
||||
bottom: '100%',
|
||||
borderBottomColor: 'primaryDark.100',
|
||||
}
|
||||
: {
|
||||
top: '100%',
|
||||
borderTopColor: 'primaryDark.100',
|
||||
}),
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
top: `${tooltipData.position.top}px`,
|
||||
left: `${tooltipData.position.left}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
transform: isBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
{tooltip}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -24,9 +24,11 @@ export { Menu } from './Menu'
|
||||
export { MenuList } from './MenuList'
|
||||
export { P } from './P'
|
||||
export { Popover } from './Popover'
|
||||
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
|
||||
export { Text } from './Text'
|
||||
export { ToggleButton } from './ToggleButton'
|
||||
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)
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export type Politeness = 'polite' | 'assertive'
|
||||
export type ScreenReaderChannel = 'global' | 'idle'
|
||||
|
||||
type ScreenReaderAnnouncement = {
|
||||
message: string
|
||||
politeness: Politeness
|
||||
id: number
|
||||
}
|
||||
|
||||
type ScreenReaderAnnouncerState = {
|
||||
announcements: Record<ScreenReaderChannel, ScreenReaderAnnouncement>
|
||||
}
|
||||
|
||||
export const screenReaderAnnouncerStore = proxy<ScreenReaderAnnouncerState>({
|
||||
announcements: {
|
||||
global: {
|
||||
message: '',
|
||||
politeness: 'polite',
|
||||
id: 0,
|
||||
},
|
||||
idle: {
|
||||
message: '',
|
||||
politeness: 'polite',
|
||||
id: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const channels: ScreenReaderChannel[] = ['global', 'idle']
|
||||
const announcementTokens: Record<ScreenReaderChannel, number> = {
|
||||
global: 0,
|
||||
idle: 0,
|
||||
}
|
||||
const announcementTimers: Record<
|
||||
ScreenReaderChannel,
|
||||
ReturnType<typeof setTimeout> | null
|
||||
> = {
|
||||
global: null,
|
||||
idle: null,
|
||||
}
|
||||
const lastAnnouncementTimes: Record<ScreenReaderChannel, number> = {
|
||||
global: 0,
|
||||
idle: 0,
|
||||
}
|
||||
const MIN_ANNOUNCEMENT_INTERVAL = 300 // Minimum 300ms between announcements
|
||||
|
||||
export const announceToScreenReader = (
|
||||
message: string,
|
||||
politeness: Politeness = 'polite',
|
||||
channel: ScreenReaderChannel = 'global'
|
||||
) => {
|
||||
if (!channels.includes(channel)) return
|
||||
|
||||
const now = Date.now()
|
||||
const timeSinceLastAnnouncement = now - lastAnnouncementTimes[channel]
|
||||
|
||||
announcementTokens[channel] += 1
|
||||
const currentToken = announcementTokens[channel]
|
||||
|
||||
if (announcementTimers[channel]) {
|
||||
clearTimeout(announcementTimers[channel]!)
|
||||
}
|
||||
|
||||
const delay = Math.max(
|
||||
150, // Minimum delay for clear + set sequence
|
||||
MIN_ANNOUNCEMENT_INTERVAL - timeSinceLastAnnouncement
|
||||
)
|
||||
|
||||
screenReaderAnnouncerStore.announcements[channel] = {
|
||||
message: '',
|
||||
politeness,
|
||||
id: currentToken,
|
||||
}
|
||||
|
||||
announcementTimers[channel] = setTimeout(() => {
|
||||
if (currentToken !== announcementTokens[channel]) return
|
||||
screenReaderAnnouncerStore.announcements[channel] = {
|
||||
message,
|
||||
politeness,
|
||||
id: currentToken,
|
||||
}
|
||||
lastAnnouncementTimes[channel] = Date.now()
|
||||
}, delay)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -46,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%);
|
||||
@@ -54,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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.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.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.5.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.2.0"
|
||||
version = "1.5.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user