Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26d97558d4 | |||
| f11aaf62e8 | |||
| 9cced10c64 | |||
| a5457ad853 | |||
| a1795d47ce | |||
| 81b17b941b | |||
| 881e695c1c | |||
| 039e42af0d | |||
| 3d90494cc0 | |||
| 3ba5bf92eb | |||
| f79f488cbc | |||
| ddba0e8b77 | |||
| 9f73c7f07d | |||
| 17f1280d45 | |||
| 27b5b956c6 | |||
| 6b65e1772c | |||
| b81b48b46b | |||
| fd3bbf2122 | |||
| e9f2f98269 | |||
| 2565e57c50 | |||
| c8c3af6c9a | |||
| 716e11b5b3 | |||
| 88a1136dfd | |||
| 90633928a8 | |||
| fd894eb61f | |||
| 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 | |||
| bbc7fa8012 | |||
| 41db3e766b | |||
| 1ab3ce6d47 | |||
| 3cd5c77f42 | |||
| 3ddb075c6b | |||
| 9ed2500565 | |||
| 1001783d3c | |||
| 97b5e8780c | |||
| 7c7074aa99 | |||
| 35b3bcad63 | |||
| 137a2c7f6f | |||
| d681e25bcc | |||
| 1f1a6371b4 | |||
| bbfbb23be5 | |||
| 6e20bc1f43 | |||
| fed05f2396 | |||
| 865acf2838 | |||
| 6ae68013af | |||
| 394a1be322 | |||
| a71a1fd968 | |||
| 40af264562 | |||
| 8b2d06976e | |||
| 58313666ed | |||
| f3c8aec189 | |||
| 0a0c7ba618 | |||
| d7ad5aed05 | |||
| 4acc9cf40d | |||
| 13d0d3d801 | |||
| 47cd3eff74 | |||
| 5769203705 | |||
| cadc186c62 | |||
| 5be7595533 |
@@ -147,6 +147,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -178,6 +186,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
+73
-3
@@ -8,19 +8,89 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) fix form labels and autocomplete wiring #932
|
||||
|
||||
## [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
|
||||
- ✨(frontend) add keyboard shortcuts display
|
||||
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) track new recording's modes
|
||||
- ♿️(frontend) improve accessibility of the background and effects menu
|
||||
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
||||
- 💄(frontend) adjust spacing in the recording side panels
|
||||
- 🚸(frontend) remove the default comma delimiter in humanized durations
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Kyutai client for subtitle
|
||||
- ✨(frontend) remove the beta badge
|
||||
- ✨(all) support starting transcription and recording simultaneously
|
||||
- ✨(backend) persist options on a recording
|
||||
- ✨(all) support choosing the transcription language
|
||||
- ✨(summary) add a download link to the audio/video file
|
||||
- ✨(frontend) allow unprivileged users to request a recording
|
||||
|
||||
### Changed
|
||||
|
||||
- 🚸(frontend) remove the beta badge
|
||||
- ♻️(summary) extract file handling in a robust service
|
||||
- ♻️(all) manage recording state on the backend side
|
||||
|
||||
## [1.1.0] - 2025-12-22
|
||||
|
||||
### Added
|
||||
### Added
|
||||
|
||||
- ✨(backend) enable user creation via email for external integrations
|
||||
- ✨(summary) add Langfuse observability for LLM API calls
|
||||
@@ -32,6 +102,6 @@ and this project adheres to
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) hover controls, focus, SR #803
|
||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
|
||||
- ♿️(frontend) Improve focus management when opening and closing chat #807
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,8 @@ redis:
|
||||
address: redis:6379
|
||||
keys:
|
||||
devkey: secret
|
||||
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
|
||||
def create_stt_provider():
|
||||
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.1.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
|
||||
@@ -28,7 +28,7 @@ from . import authentication, permissions, serializers
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
class ApplicationViewSet(viewsets.ViewSet):
|
||||
"""API endpoints for application authentication and token generation."""
|
||||
|
||||
@decorators.action(
|
||||
@@ -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.1.0"
|
||||
version = "1.5.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.7",
|
||||
"aiohttp==3.13.2",
|
||||
"aiohttp==3.13.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
|
||||
type="font/woff2"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.1.0",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.1.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.1.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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
||||
<SlideContainer>
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -155,9 +156,34 @@ export const Home = () => {
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
if (!data?.external_home_url) return
|
||||
if (isLoggedIn === false) {
|
||||
try {
|
||||
await fetch(data.external_home_url, {
|
||||
method: 'HEAD', // Use HEAD to avoid downloading the full page
|
||||
mode: 'no-cors', // Needed for cross-origin requests
|
||||
})
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
console.error('Site is not reachable:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSiteAndRedirect()
|
||||
}, [isLoggedIn, data])
|
||||
|
||||
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
|
||||
return <LoadingScreen header={false} footer={false} delay={0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article7.title')}
|
||||
</H>
|
||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||
<P>{t('articles.article7.content')}</P>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('articles.article7.sections.section1.content')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section2.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section2.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.3 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section3.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section3.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.4 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section4.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section4.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.5 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section5.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('articles.article7.sections.section2.content')
|
||||
{t('articles.article7.sections.section5.content')
|
||||
.split('https://github.com/suitenumerique/meet')[0]
|
||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||
https://github.com/suitenumerique/meet
|
||||
</A>
|
||||
{'. '}
|
||||
{
|
||||
t('articles.article7.sections.section2.content').split(
|
||||
t('articles.article7.sections.section5.content').split(
|
||||
'https://github.com/suitenumerique/meet'
|
||||
)[1]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { Button, Icon, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
@@ -42,6 +42,17 @@ export const ControlsButton = ({
|
||||
}: ControlsButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||
|
||||
// Focus management: focus the primary action button when this side panel opens.
|
||||
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (primaryActionRef.current) {
|
||||
primaryActionRef.current.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
@@ -97,6 +108,7 @@ export const ControlsButton = ({
|
||||
fullWidth
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.stop')}
|
||||
</Button>
|
||||
@@ -129,31 +141,23 @@ export const ControlsButton = ({
|
||||
})}
|
||||
onPress={() => openSidePanel()}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginRight: '1rem',
|
||||
})
|
||||
)}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<Icon
|
||||
className={css({
|
||||
color: 'primary.500',
|
||||
marginRight: '1rem',
|
||||
})}
|
||||
name="info"
|
||||
/>
|
||||
<Text variant={'smNote'}>
|
||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||
</Text>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginLeft: 'auto',
|
||||
})
|
||||
)}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
<Icon
|
||||
className={css({
|
||||
color: 'primary.500',
|
||||
marginLeft: 'auto',
|
||||
})}
|
||||
name="chevron_right"
|
||||
/>
|
||||
</RACButton>
|
||||
)}
|
||||
<Button
|
||||
@@ -162,6 +166,7 @@ export const ControlsButton = ({
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
size="compact"
|
||||
ref={primaryActionRef}
|
||||
>
|
||||
{t('button.start')}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { H, Text } from '@/primitives'
|
||||
import { H, Text, Icon } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols" aria-hidden={true}>
|
||||
login
|
||||
</span>
|
||||
<Icon type="symbols" name="login" />
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useRef, 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,6 +22,9 @@ export const RecordingStateToast = () => {
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const lastKeyRef = useRef('')
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
@@ -67,6 +71,16 @@ export const RecordingStateToast = () => {
|
||||
return `${metadata.recording_mode}.${status}`
|
||||
}, [metadata, isStarted, isStarting, isRecording])
|
||||
|
||||
// Update screen reader message only when the key actually changes
|
||||
// This prevents duplicate announcements caused by re-renders
|
||||
useEffect(() => {
|
||||
if (key && key !== lastKeyRef.current) {
|
||||
lastKeyRef.current = key
|
||||
const message = t(key)
|
||||
announce(message)
|
||||
}
|
||||
}, [announce, key, t])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
@@ -74,61 +88,65 @@ export const RecordingStateToast = () => {
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
<>
|
||||
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
|
||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
{!hasScreenRecordingAccessAndActive &&
|
||||
!hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Icon } from '@/primitives'
|
||||
|
||||
interface RecordingStatusIconProps {
|
||||
isStarted: boolean
|
||||
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
|
||||
}
|
||||
|
||||
if (isTranscriptActive) {
|
||||
return <span className="material-symbols">speech_to_text</span>
|
||||
return <Icon type="symbols" name="speech_to_text" />
|
||||
}
|
||||
|
||||
return <span className="material-symbols">screen_record</span>
|
||||
return <Icon type="symbols" name="screen_record" />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, H, Text } from '@/primitives'
|
||||
import { Button, Icon, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -59,7 +59,7 @@ export const RequestRecording = ({
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols">person_raised_hand</span>
|
||||
<Icon type="symbols" name="person_raised_hand" />
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ReactNode } from 'react'
|
||||
import { Icon } from '@/primitives'
|
||||
|
||||
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
||||
|
||||
@@ -45,7 +46,7 @@ export const RowWrapper = ({
|
||||
})}
|
||||
>
|
||||
{/* fixme - doesn't handle properly material-symbols */}
|
||||
<span className="material-icons">{iconName}</span>
|
||||
<Icon name={iconName} />
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
@@ -28,6 +26,7 @@ import { Checkbox } from '@/primitives/Checkbox'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -38,10 +37,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
@@ -98,14 +94,17 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
posthog.capture('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle recording:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
if (!isAdminOrOwner) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
@@ -146,7 +145,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'} fullWidth>
|
||||
{t('heading')}
|
||||
</H>
|
||||
@@ -163,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<VStack gap={0} marginBottom={25}>
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
|
||||
@@ -117,7 +117,10 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {})
|
||||
posthog.capture('transcript-started', {
|
||||
includeScreenRecording: includeScreenRecording,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
@@ -178,7 +181,7 @@ export const TranscriptSidePanel = () => {
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<VStack gap={0} marginBottom={15}>
|
||||
<H lvl={1} margin={'sm'}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
@@ -195,7 +198,7 @@ export const TranscriptSidePanel = () => {
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<VStack gap={0} marginBottom={25}>
|
||||
<RowWrapper iconName="article" position="first">
|
||||
<Text variant="sm">
|
||||
{data?.transcription_destination ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ export const useHumanizeRecordingMaxDuration = () => {
|
||||
|
||||
return humanizeDuration(data?.recording?.max_duration, {
|
||||
language: i18n.language,
|
||||
delimiter: ' ',
|
||||
})
|
||||
}, [data])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -446,16 +446,14 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,7 +31,16 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { ShortcutHelpTooltip } from './ShortcutHelpTooltip'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { getShortcutById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { participantLayoutStore } from '@/stores/participantLayout'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -109,6 +118,26 @@ export const ParticipantTile: (
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const openShortcutDefault = getShortcutById('open-shortcuts')?.shortcut
|
||||
const openShortcutEffective =
|
||||
overrides.get('open-shortcuts') ?? openShortcutDefault
|
||||
const openShortcutLabel =
|
||||
formatShortcutLabel(openShortcutEffective) ??
|
||||
`${isMacintosh() ? '⌘' : 'Ctrl'} + /`
|
||||
|
||||
// Check if we should show the shortcut helper tooltip
|
||||
// Only show on the first tile when in grid layout (not in carousel/left column, not in focus mode)
|
||||
const { layoutType, firstGridTileTrackId } = useSnapshot(participantLayoutStore)
|
||||
const currentTrackId = isTrackReference(trackReference)
|
||||
? `${trackReference.participant.sid}-${trackReference.source}`
|
||||
: null
|
||||
|
||||
const shouldShowShortcutHelper =
|
||||
hasKeyboardFocus &&
|
||||
layoutType === 'grid' && // Only show in grid layout
|
||||
currentTrackId === firstGridTileTrackId // Only show on first tile
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
@@ -117,7 +146,9 @@ export const ParticipantTile: (
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onFocus?.(event)
|
||||
setHasKeyboardFocus(true)
|
||||
const target = event.target as HTMLElement | null
|
||||
const isFocusVisible = !!target?.matches?.(':focus-visible')
|
||||
setHasKeyboardFocus(isFocusVisible)
|
||||
},
|
||||
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onBlur?.(event)
|
||||
@@ -227,8 +258,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
{shouldShowShortcutHelper && (
|
||||
<ShortcutHelpTooltip
|
||||
triggerLabel={t('toolbarHint', {
|
||||
binding: openShortcutLabel,
|
||||
})}
|
||||
isVisible={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||
import { ShortcutCategorySection } from '@/features/shortcuts/components'
|
||||
import { useFocusTrap } from '@/features/shortcuts/useFocusTrap'
|
||||
import {
|
||||
closeShortcutHelp,
|
||||
shortcutHelpStore,
|
||||
toggleShortcutHelp,
|
||||
} from '@/stores/shortcutHelp'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
type ShortcutHelpTooltipProps = {
|
||||
triggerLabel: string
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
const containerStyle = css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
backgroundColor: 'rgba(0,0,0,0.65)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.25rem',
|
||||
fontSize: '0.875rem',
|
||||
minWidth: '200px',
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.25)',
|
||||
zIndex: 10,
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
'&[data-visible="true"]': {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const triggerStyle = css({
|
||||
all: 'unset',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
gap: '0.5rem',
|
||||
outline: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid rgba(255,255,255,0.5)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
})
|
||||
|
||||
const panelStyle = css({
|
||||
overflow: 'hidden',
|
||||
maxHeight: '0px',
|
||||
opacity: 0,
|
||||
marginTop: '0',
|
||||
transition:
|
||||
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
|
||||
'&[data-open="true"]': {
|
||||
maxHeight: '400px',
|
||||
opacity: 1,
|
||||
marginTop: '0.35rem',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const emptyStateStyle = css({
|
||||
fontSize: '0.85rem',
|
||||
opacity: 0.8,
|
||||
})
|
||||
|
||||
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
|
||||
triggerLabel,
|
||||
isVisible = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { isOpen } = useSnapshot(shortcutHelpStore)
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const isContainerVisible = isVisible || isOpen
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
useFocusTrap(panelRef, { isActive: isOpen, fallbackRef: panelRef })
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return shortcutCatalog.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
item: (typeof shortcutCatalog)[number]
|
||||
effective: Shortcut | undefined
|
||||
}[]
|
||||
>
|
||||
>((acc, item) => {
|
||||
const effective = overrides.get(item.id) ?? item.shortcut
|
||||
acc[item.category] = acc[item.category] || []
|
||||
acc[item.category].push({ item, effective })
|
||||
return acc
|
||||
}, {})
|
||||
}, [overrides])
|
||||
|
||||
const entries = Object.entries(grouped)
|
||||
const hasItems = entries.length > 0
|
||||
|
||||
const getCategoryLabel = useCallback(
|
||||
(category: string) => t(`shortcutsPanel.categories.${category}`),
|
||||
[t]
|
||||
)
|
||||
|
||||
const getActionLabel = useCallback(
|
||||
(id: string) => t(`shortcutsPanel.actions.${id}`),
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
toggleShortcutHelp()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeShortcutHelp()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
panelRef.current?.focus()
|
||||
} else if (wasOpenRef.current && !isOpen && triggerRef.current) {
|
||||
triggerRef.current.focus()
|
||||
}
|
||||
wasOpenRef.current = isOpen
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div data-visible={isContainerVisible} className={containerStyle}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="shortcut-help-panel"
|
||||
onClick={handleToggle}
|
||||
ref={triggerRef}
|
||||
className={triggerStyle}
|
||||
>
|
||||
<span>{triggerLabel}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})}
|
||||
>
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="shortcut-help-panel"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="shortcut-help-title"
|
||||
data-open={isOpen}
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={panelStyle}
|
||||
>
|
||||
<h2 id="shortcut-help-title" className="sr-only">
|
||||
{t('shortcutsPanel.title')}
|
||||
</h2>
|
||||
{hasItems ? (
|
||||
entries.map(([category, items]) => (
|
||||
<ShortcutCategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
items={items}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
getActionLabel={getActionLabel}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={emptyStateStyle}>
|
||||
{t('shortcutsPanel.noShortcuts')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,27 +13,32 @@ import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<div
|
||||
<aside
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
@@ -58,32 +63,37 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size={'sm'}
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
@@ -104,7 +114,7 @@ const StyledSidePanel = ({
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
@@ -125,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const {
|
||||
activePanelId,
|
||||
@@ -144,6 +153,7 @@ export const SidePanel = () => {
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
@@ -153,6 +163,7 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
@@ -164,7 +175,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { A, Div, Text } from '@/primitives'
|
||||
import { A, Div, Icon, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -86,7 +87,7 @@ const ToolButton = ({
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className="material-symbols">chevron_forward</span>
|
||||
<Icon type="symbols" name="chevron_forward" />
|
||||
</div>
|
||||
</RACButton>
|
||||
)
|
||||
@@ -94,10 +95,26 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
@@ -146,7 +163,7 @@ export const Tools = () => {
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<span className="material-symbols">speech_to_text</span>}
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
@@ -154,7 +171,7 @@ export const Tools = () => {
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<span className="material-symbols">mode_standby</span>}
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ToggleButton } from '@/primitives'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ChatToggle = ({
|
||||
onPress,
|
||||
@@ -18,6 +19,11 @@ export const ChatToggle = ({
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-chat',
|
||||
handler: async () => toggleChat(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
+18
-4
@@ -1,7 +1,10 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import {
|
||||
appendShortcutLabel,
|
||||
getEffectiveShortcut,
|
||||
} from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
@@ -16,8 +19,10 @@ import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||
import { getShortcutById } from '@/features/shortcuts/catalog'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { shortcutOverridesStore } from '@/stores/shortcutOverrides'
|
||||
|
||||
type ToggleDeviceStyleProps = {
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
@@ -85,10 +90,12 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const shortcutId =
|
||||
kind === 'audioinput' ? 'toggle-microphone' : 'toggle-camera'
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: deviceShortcut,
|
||||
shortcutId,
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
@@ -99,6 +106,13 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
const deviceShortcut = useMemo(() => {
|
||||
const override = overrides.get(shortcutId)
|
||||
if (override) return override
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
return catalogItem?.shortcut
|
||||
}, [shortcutId, overrides])
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
|
||||
+9
@@ -17,6 +17,7 @@ import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
@@ -99,6 +100,14 @@ export const VideoDeviceControl = ({
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'V', ctrlKey: true, shiftKey: true },
|
||||
handler: async () => {
|
||||
if (!canPublishTrack || cannotUseDevice) return
|
||||
await toggleWithProcessor()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
@@ -28,6 +29,19 @@ export const HandToggle = () => {
|
||||
setHasShownToast(false)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'raise-hand',
|
||||
handler: async () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isHandRaised) return
|
||||
closeLowerHandToasts()
|
||||
@@ -68,10 +82,7 @@ export const HandToggle = () => {
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}}
|
||||
onPress={handleToggle}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
+27
-15
@@ -1,10 +1,12 @@
|
||||
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'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
@@ -19,11 +21,18 @@ export const ParticipantsToggle = ({
|
||||
*/
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
const announcedCount =
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-participants',
|
||||
handler: async () => toggleParticipants(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -31,21 +40,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,9 +10,11 @@ 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'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export enum Emoji {
|
||||
@@ -40,6 +42,11 @@ export const ReactionsToggle = () => {
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'reaction',
|
||||
handler: async () => setIsVisible((prev) => !prev),
|
||||
})
|
||||
|
||||
const sendReaction = async (emoji: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
@@ -145,7 +152,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
|
||||
|
||||
+180
-85
@@ -9,15 +9,16 @@ import {
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
import { Loader } from '@/primitives/Loader'
|
||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { RiProhibited2Line } from '@remixicon/react'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -50,11 +51,17 @@ export const EffectsConfiguration = ({
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const blurLightRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -69,16 +76,92 @@ export const EffectsConfiguration = ({
|
||||
}
|
||||
}, [videoTrack, videoTrack?.isMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!blurLightRef.current) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
blurLightRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const clearEffect = async () => {
|
||||
await videoTrack.stopProcessor()
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffect = async (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
@@ -104,7 +187,7 @@ export const EffectsConfiguration = ({
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (isSelected(type, options)) {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await clearEffect()
|
||||
} else if (
|
||||
@@ -131,6 +214,8 @@ export const EffectsConfiguration = ({
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -153,8 +238,28 @@ export const EffectsConfiguration = ({
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const strength =
|
||||
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
|
||||
const action = isSelected(type, options) ? 'clear' : 'apply'
|
||||
|
||||
return t(`${type}.${strength}.${action}`)
|
||||
}
|
||||
|
||||
const ariaLabelVirtualBackground = (
|
||||
index: number,
|
||||
imagePath: string
|
||||
): string => {
|
||||
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.descriptions.${index}`)
|
||||
return `${t(`virtual.${prefix}`)} ${backgroundName}`
|
||||
}
|
||||
|
||||
const tooltipVirtualBackground = (index: number): string => {
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -265,65 +370,57 @@ export const EffectsConfiguration = ({
|
||||
>
|
||||
{t('blur.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={t('clear')}
|
||||
onPress={async () => {
|
||||
await clearEffect()
|
||||
}}
|
||||
isSelected={!getProcessor()}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<RiProhibited2Line />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
<ToggleButton
|
||||
ref={blurLightRef}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
@@ -343,39 +440,37 @@ export const EffectsConfiguration = ({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
paddingBottom: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
const tooltipText = tooltipVirtualBackground(i)
|
||||
return (
|
||||
<ToggleButton
|
||||
key={i}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={ariaLabelVirtualBackground(i, imagePath)}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { setCarouselLayout, resetLayout } from '@/stores/participantLayout'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -79,6 +80,15 @@ export function CarouselLayout({
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
|
||||
// Update store when carousel layout is active
|
||||
React.useEffect(() => {
|
||||
setCarouselLayout()
|
||||
|
||||
return () => {
|
||||
resetLayout()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||
import { PaginationControl } from '../controls/PaginationControl'
|
||||
import { setGridLayout, resetLayout } from '@/stores/participantLayout'
|
||||
import { isTrackReference } from '@livekit/components-core'
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
@@ -35,6 +37,11 @@ export interface GridLayoutProps
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
const getTrackId = (trackRef: TrackReferenceOrPlaceholder): string | null => {
|
||||
if (!isTrackReference(trackRef)) return null
|
||||
return `${trackRef.participant.sid}-${trackRef.source}`
|
||||
}
|
||||
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
|
||||
@@ -50,6 +57,17 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
onRightSwipe: pagination.prevPage,
|
||||
})
|
||||
|
||||
// Update store when tracks change - set first tile ID for grid layout
|
||||
React.useEffect(() => {
|
||||
const firstTrack = pagination.tracks[0]
|
||||
const firstTrackId = firstTrack ? getTrackId(firstTrack) : null
|
||||
setGridLayout(firstTrackId)
|
||||
|
||||
return () => {
|
||||
resetLayout()
|
||||
}
|
||||
}, [pagination.tracks])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridEl}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import React, { useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) {
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
// when the chat panel is closed.
|
||||
const prevIsChatOpenRef = React.useRef(false)
|
||||
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wasChatOpen = prevIsChatOpenRef.current
|
||||
const isChatPanelOpen = isChatOpen
|
||||
|
||||
// Chat just opened
|
||||
if (!wasChatOpen && isChatPanelOpen) {
|
||||
chatTriggerRef.current = document.activeElement as HTMLElement | null
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
useRestoreFocus(isChatOpen, {
|
||||
// Avoid layout "jump" during the side panel slide-in animation.
|
||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
// Chat just closed
|
||||
if (wasChatOpen && !isChatPanelOpen) {
|
||||
const trigger = chatTriggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
trigger.focus({ preventScroll: true })
|
||||
}
|
||||
chatTriggerRef.current = null
|
||||
}
|
||||
|
||||
prevIsChatOpenRef.current = isChatPanelOpen
|
||||
}, [isChatOpen])
|
||||
},
|
||||
preventScroll: true,
|
||||
})
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
@@ -12,17 +12,33 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { openShortcutHelp } from '@/stores/shortcutHelp'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
const { toggleParticipants, toggleChat, openScreenRecording } = useSidePanel()
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'F2' },
|
||||
shortcutId: 'open-shortcuts',
|
||||
handler: () => {
|
||||
openShortcutHelp()
|
||||
},
|
||||
})
|
||||
|
||||
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'focus-toolbar',
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
@@ -32,6 +48,34 @@ export function DesktopControlBar({
|
||||
firstButton?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-participants',
|
||||
handler: () => toggleParticipants(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-chat',
|
||||
handler: () => toggleChat(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'fullscreen',
|
||||
handler: () => {
|
||||
if (!isFullscreenAvailable) return
|
||||
toggleFullScreen()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'recording',
|
||||
handler: () => openScreenRecording(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'open-shortcuts-settings',
|
||||
handler: () => openSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS),
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MobileControlBar({
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button
|
||||
id="controlbar-more-options-trigger"
|
||||
square
|
||||
variant="secondaryDark"
|
||||
aria-label={t('controls.moreOptions')}
|
||||
|
||||
@@ -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,14 +2,17 @@ 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 {
|
||||
RiAccountCircleLine,
|
||||
RiKeyboardBoxLine,
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -21,6 +24,8 @@ 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 ShortcutTab from './tabs/ShortcutTab'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -104,13 +109,22 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
|
||||
<RiKeyboardBoxLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
|
||||
</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}>
|
||||
@@ -122,8 +136,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||
{/* 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
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Edit and reset feature, not used yet
|
||||
*
|
||||
* This component handles edit and reset actions for keyboard shortcuts.
|
||||
* To use it, uncomment the import and usage in ShortcutTab.tsx
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
||||
import { removeOverride, setOverride } from '@/stores/shortcutOverrides'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' })
|
||||
|
||||
export interface ShortcutEditActionsProps {
|
||||
shortcutId: string
|
||||
actionLabel: string
|
||||
srShortcut: string
|
||||
hasOverride: boolean
|
||||
onConfirmationChange?: (message: string) => void
|
||||
}
|
||||
|
||||
export const ShortcutEditActions = ({
|
||||
shortcutId,
|
||||
actionLabel,
|
||||
srShortcut,
|
||||
hasOverride,
|
||||
onConfirmationChange,
|
||||
}: ShortcutEditActionsProps) => {
|
||||
const { t } = useTranslation(['settings'])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setEditingId(shortcutId)
|
||||
}, [shortcutId])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null)
|
||||
}, [])
|
||||
|
||||
const handleEditButtonClick = useCallback(() => {
|
||||
// If already in edit mode, cancel it
|
||||
if (editingId === shortcutId) {
|
||||
handleCancelEdit()
|
||||
return
|
||||
}
|
||||
// Otherwise, start edit mode
|
||||
handleStartEdit()
|
||||
}, [editingId, shortcutId, handleCancelEdit, handleStartEdit])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
removeOverride(shortcutId)
|
||||
const message = t('shortcutsEditor.resetConfirmation', {
|
||||
defaultValue: 'Shortcut reset',
|
||||
})
|
||||
onConfirmationChange?.(message)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onConfirmationChange?.('')
|
||||
timeoutRef.current = null
|
||||
}, 3000)
|
||||
}, [shortcutId, t, onConfirmationChange])
|
||||
|
||||
const handleKeyCapture = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
const { key, ctrlKey, shiftKey, altKey, metaKey } = e
|
||||
// Ignore modifier-only keys
|
||||
if (
|
||||
!key ||
|
||||
key === 'Control' ||
|
||||
key === 'Meta' ||
|
||||
key === 'Shift' ||
|
||||
key === 'Alt' ||
|
||||
key === 'Tab' ||
|
||||
key === 'Escape'
|
||||
)
|
||||
return
|
||||
const normalized: Shortcut = {
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
}
|
||||
setOverride(shortcutId, normalized)
|
||||
setEditingId(null)
|
||||
const message = t('shortcutsEditor.modifiedConfirmation', {
|
||||
defaultValue: 'Shortcut modified',
|
||||
})
|
||||
onConfirmationChange?.(message)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onConfirmationChange?.('')
|
||||
timeoutRef.current = null
|
||||
}, 3000)
|
||||
},
|
||||
[shortcutId, t, onConfirmationChange]
|
||||
)
|
||||
|
||||
const handleEditButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
// If already in edit mode, capture the key
|
||||
if (editingId === shortcutId) {
|
||||
handleKeyCapture(e)
|
||||
return
|
||||
}
|
||||
// Otherwise, if it's Enter or Space, start edit mode (like a click)
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleStartEdit()
|
||||
}
|
||||
},
|
||||
[editingId, shortcutId, handleKeyCapture, handleStartEdit]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const editButtonLabel =
|
||||
editingId === shortcutId
|
||||
? t('shortcutsEditor.capture')
|
||||
: t('shortcutsEditor.edit')
|
||||
const editButtonAriaLabel =
|
||||
editingId === shortcutId
|
||||
? t('shortcutsEditor.captureAria', {
|
||||
defaultValue: 'Press keys to set shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
: t('shortcutsEditor.editAria', {
|
||||
defaultValue: 'Edit shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
const resetButtonAriaLabel = t('shortcutsEditor.resetAria', {
|
||||
defaultValue: 'Reset shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('shortcutsEditor.actionsGroupAria', {
|
||||
defaultValue: 'Actions for {{action}}',
|
||||
action: actionLabel,
|
||||
})}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.35rem',
|
||||
justifyContent: 'flex-end',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonLink}
|
||||
onKeyDown={handleEditButtonKeyDown}
|
||||
onClick={handleEditButtonClick}
|
||||
aria-pressed={editingId === shortcutId}
|
||||
aria-label={editButtonAriaLabel}
|
||||
aria-describedby={`shortcut-${shortcutId}-description`}
|
||||
>
|
||||
{editButtonLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonLink}
|
||||
onClick={handleReset}
|
||||
aria-disabled={!hasOverride}
|
||||
disabled={!hasOverride}
|
||||
aria-label={resetButtonAriaLabel}
|
||||
aria-describedby={`shortcut-${shortcutId}-description`}
|
||||
style={{ opacity: !hasOverride ? 0.5 : 1 }}
|
||||
>
|
||||
{t('shortcutsEditor.reset')}
|
||||
</button>
|
||||
<span id={`shortcut-${shortcutId}-description`} className="sr-only">
|
||||
{t('shortcutsEditor.currentShortcut', {
|
||||
defaultValue: 'Current shortcut: {{shortcut}}',
|
||||
shortcut: srShortcut,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||
import { ShortcutRow } from '@/features/shortcuts/components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
|
||||
const { t } = useTranslation(['settings', 'rooms'])
|
||||
const tRooms = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) =>
|
||||
t(key, { ns: 'rooms', ...options }),
|
||||
[t]
|
||||
)
|
||||
useEffect(() => {
|
||||
loadShortcutOverrides()
|
||||
}, [])
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return shortcutCatalog.map((item) => {
|
||||
const override = overrides.get(item.id)
|
||||
const effectiveShortcut = override ?? item.shortcut
|
||||
return {
|
||||
item,
|
||||
override,
|
||||
effectiveShortcut,
|
||||
}
|
||||
})
|
||||
}, [overrides])
|
||||
|
||||
return (
|
||||
<TabPanel
|
||||
id={id}
|
||||
padding="md"
|
||||
flex
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div className={text({ variant: 'h2' })}>{t('tabs.shortcuts')}</div>
|
||||
<div
|
||||
role="list"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
aria-label={t('shortcutsEditor.listLabel', {
|
||||
defaultValue: 'List of keyboard shortcuts',
|
||||
})}
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.35rem',
|
||||
outline: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid rgba(255,255,255,0.5)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{rows.map(({ item, override, effectiveShortcut }) => (
|
||||
<ShortcutRow
|
||||
key={item.id}
|
||||
descriptor={item}
|
||||
effectiveShortcut={effectiveShortcut}
|
||||
override={override}
|
||||
actionLabel={tRooms(`shortcutsPanel.actions.${item.id}`)}
|
||||
customLabel={t('shortcutsEditor.custom')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShortcutTab
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { text } from '@/primitives/Text'
|
||||
|
||||
/**
|
||||
* Hook to manage confirmation messages for edit/reset actions
|
||||
* Use in the parent component to display confirmation messages
|
||||
*/
|
||||
export const useShortcutConfirmation = () => {
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string>('')
|
||||
|
||||
const confirmationElement = useMemo(() => {
|
||||
if (!confirmationMessage) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={text({ variant: 'smNote' })}
|
||||
>
|
||||
{confirmationMessage}
|
||||
</div>
|
||||
)
|
||||
}, [confirmationMessage])
|
||||
|
||||
return {
|
||||
confirmationMessage,
|
||||
setConfirmationMessage,
|
||||
ConfirmationMessage: confirmationElement,
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,14 @@ export const useTranscriptionLanguage = () => {
|
||||
key: RecordingLanguage.ENGLISH,
|
||||
label: t('language.options.english'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.GERMAN,
|
||||
label: t('language.options.german'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.DUTCH,
|
||||
label: t('language.options.dutch'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.AUTOMATIC,
|
||||
label: t('language.options.auto'),
|
||||
|
||||
@@ -5,4 +5,6 @@ export enum SettingsDialogExtendedKey {
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
SHORTCUTS = 'shortcuts',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Shortcut } from './types'
|
||||
|
||||
// Central list of current keyboard shortcuts. This will feed the future
|
||||
// tooltip/panel so there is a single source of truth for display and, later,
|
||||
// customization.
|
||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
| 'push-to-talk'
|
||||
| 'reaction'
|
||||
| 'fullscreen'
|
||||
| 'recording'
|
||||
| 'raise-hand'
|
||||
| 'toggle-chat'
|
||||
| 'toggle-participants'
|
||||
| 'open-shortcuts-settings'
|
||||
|
||||
export const getShortcutById = (id: ShortcutId) =>
|
||||
shortcutCatalog.find((item) => item.id === id)
|
||||
|
||||
export type ShortcutDescriptor = {
|
||||
id: ShortcutId
|
||||
category: ShortcutCategory
|
||||
shortcut?: Shortcut
|
||||
kind?: 'press' | 'longPress'
|
||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
{
|
||||
id: 'open-shortcuts',
|
||||
category: 'navigation',
|
||||
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'F2' },
|
||||
},
|
||||
{
|
||||
id: 'toggle-microphone',
|
||||
category: 'media',
|
||||
shortcut: { key: 'd', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-camera',
|
||||
category: 'media',
|
||||
shortcut: { key: 'e', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'push-to-talk',
|
||||
category: 'media',
|
||||
kind: 'longPress',
|
||||
code: 'KeyV',
|
||||
},
|
||||
{
|
||||
id: 'reaction',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'fullscreen',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'recording',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'raise-hand',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-chat',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-participants',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'open-shortcuts-settings',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'K', ctrlKey: true, altKey: true },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { text } from '@/primitives/Text'
|
||||
|
||||
type ShortcutBadgeProps = {
|
||||
visualLabel: string
|
||||
isCustom?: boolean
|
||||
customLabel?: string
|
||||
srLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const badgeStyle = css({
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingInline: '0.4rem',
|
||||
paddingBlock: '0.2rem',
|
||||
borderRadius: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: '5.5rem',
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
|
||||
visualLabel,
|
||||
isCustom = false,
|
||||
customLabel,
|
||||
srLabel,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
<span aria-hidden="true">{visualLabel}</span>
|
||||
{isCustom && customLabel && (
|
||||
<span
|
||||
className={text({ variant: 'smNote' })}
|
||||
style={{ marginLeft: '0.4rem' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
({customLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ShortcutDescriptor } from '../catalog'
|
||||
import { Shortcut } from '../types'
|
||||
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
|
||||
|
||||
type ShortcutCategorySectionProps = {
|
||||
category: string
|
||||
items: Array<{ item: ShortcutDescriptor; effective: Shortcut | undefined }>
|
||||
getCategoryLabel: (category: string) => string
|
||||
getActionLabel: (id: string) => string
|
||||
}
|
||||
|
||||
const sectionStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.35rem',
|
||||
mb: '0.5rem',
|
||||
})
|
||||
|
||||
const headingStyle = css({
|
||||
textTransform: 'capitalize',
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})
|
||||
|
||||
const listStyle = css({
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})
|
||||
|
||||
const listItemStyle = css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.85rem',
|
||||
})
|
||||
|
||||
const badgeStyle = css({
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingInline: '0.35rem',
|
||||
paddingBlock: '0.15rem',
|
||||
borderRadius: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
})
|
||||
|
||||
export const ShortcutCategorySection: React.FC<
|
||||
ShortcutCategorySectionProps
|
||||
> = ({ category, items, getCategoryLabel, getActionLabel }) => {
|
||||
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting()
|
||||
|
||||
return (
|
||||
<section className={sectionStyle}>
|
||||
<h3
|
||||
id={`shortcut-section-${category}`}
|
||||
data-shortcuts-heading
|
||||
tabIndex={-1}
|
||||
className={headingStyle}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</h3>
|
||||
<ul
|
||||
aria-labelledby={`shortcut-section-${category}`}
|
||||
className={listStyle}
|
||||
>
|
||||
{items.map(({ item, effective }) => {
|
||||
const visualShortcut = formatVisual(
|
||||
effective,
|
||||
item.code,
|
||||
item.kind === 'longPress' ? getHoldTemplate('visual') : undefined
|
||||
)
|
||||
const srShortcut = formatForSR(effective, item.code)
|
||||
const actionLabel = getActionLabel(item.id)
|
||||
|
||||
return (
|
||||
<li key={item.id} className={listItemStyle}>
|
||||
<span>
|
||||
{actionLabel}
|
||||
<span className="sr-only">, {srShortcut}</span>
|
||||
</span>
|
||||
<span aria-hidden className={badgeStyle}>
|
||||
{visualShortcut}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { ShortcutDescriptor } from '../catalog'
|
||||
import { Shortcut } from '../types'
|
||||
import { ShortcutBadge } from './ShortcutBadge'
|
||||
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
|
||||
|
||||
type ShortcutRowProps = {
|
||||
descriptor: ShortcutDescriptor
|
||||
effectiveShortcut?: Shortcut
|
||||
override?: Shortcut
|
||||
actionLabel: string
|
||||
customLabel: string
|
||||
}
|
||||
|
||||
const rowStyle = css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.25fr auto',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.65rem 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
})
|
||||
|
||||
export const ShortcutRow: React.FC<ShortcutRowProps> = ({
|
||||
descriptor,
|
||||
effectiveShortcut,
|
||||
override,
|
||||
actionLabel,
|
||||
customLabel,
|
||||
}) => {
|
||||
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting({
|
||||
namespace: 'rooms',
|
||||
})
|
||||
|
||||
const visualShortcut = formatVisual(
|
||||
effectiveShortcut,
|
||||
descriptor.code,
|
||||
descriptor.kind === 'longPress' ? getHoldTemplate('visual') : undefined
|
||||
)
|
||||
const srShortcut = formatForSR(effectiveShortcut, descriptor.code)
|
||||
const srCustomLabel = override ? ` (${customLabel})` : ''
|
||||
|
||||
return (
|
||||
<div role="listitem" className={rowStyle}>
|
||||
<div className={text({ variant: 'body' })}>{actionLabel}</div>
|
||||
<ShortcutBadge
|
||||
visualLabel={visualShortcut}
|
||||
isCustom={!!override}
|
||||
customLabel={customLabel}
|
||||
srLabel={`${srShortcut}${srCustomLabel}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ShortcutBadge } from './ShortcutBadge'
|
||||
export { ShortcutRow } from './ShortcutRow'
|
||||
export { ShortcutCategorySection } from './ShortcutCategorySection'
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Shortcut } from './types'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed).
|
||||
export const formatShortcutLabel = (shortcut?: Shortcut) => {
|
||||
if (!shortcut) return '—'
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
if (!key) return '—'
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||
if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||
if (shortcut.shiftKey) parts.push('Shift')
|
||||
parts.push(key)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
// SR-friendly label for a shortcut (reads “Control plus D”).
|
||||
export const formatShortcutLabelForSR = (
|
||||
shortcut: Shortcut | undefined,
|
||||
{
|
||||
controlLabel,
|
||||
commandLabel,
|
||||
plusLabel,
|
||||
noShortcutLabel,
|
||||
}: {
|
||||
controlLabel: string
|
||||
commandLabel: string
|
||||
plusLabel: string
|
||||
noShortcutLabel: string
|
||||
}
|
||||
) => {
|
||||
if (!shortcut) return noShortcutLabel
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
if (!key) return noShortcutLabel
|
||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) parts.push(ctrlWord)
|
||||
if (shortcut.altKey) parts.push('Alt')
|
||||
if (shortcut.shiftKey) parts.push('Shift')
|
||||
parts.push(key)
|
||||
return parts.join(` ${plusLabel} `)
|
||||
}
|
||||
|
||||
// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V).
|
||||
export const getKeyLabelFromCode = (code?: string) => {
|
||||
if (!code) return ''
|
||||
if (code.startsWith('Key') && code.length === 4) return code.slice(3)
|
||||
if (code.startsWith('Digit') && code.length === 6) return code.slice(5)
|
||||
if (code === 'Space') return '␣'
|
||||
if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right
|
||||
return code
|
||||
}
|
||||
|
||||
// Long-press label (visual or SR), e.g. “Hold V”.
|
||||
export const formatLongPressLabel = (
|
||||
codeLabel: string,
|
||||
holdTemplate: string
|
||||
) => {
|
||||
if (!codeLabel) return holdTemplate.replace('{{key}}', '?')
|
||||
return holdTemplate.replace('{{key}}', codeLabel)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useShortcutFormatting } from './useShortcutFormatting'
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shortcut } from '../types'
|
||||
import {
|
||||
formatShortcutLabel,
|
||||
formatShortcutLabelForSR,
|
||||
formatLongPressLabel,
|
||||
getKeyLabelFromCode,
|
||||
} from '../formatLabels'
|
||||
|
||||
type UseShortcutFormattingOptions = {
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export const useShortcutFormatting = (
|
||||
options: UseShortcutFormattingOptions = {}
|
||||
) => {
|
||||
const { namespace = 'rooms' } = options
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
const formatVisual = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, holdTemplate?: string) => {
|
||||
if (code && holdTemplate) {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return formatLongPressLabel(label, holdTemplate)
|
||||
}
|
||||
return formatShortcutLabel(shortcut)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const formatForSR = useCallback(
|
||||
(shortcut?: Shortcut, code?: string) => {
|
||||
if (code) {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return formatLongPressLabel(
|
||||
label,
|
||||
t('shortcutsPanel.sr.hold', { key: '{{key}}' })
|
||||
)
|
||||
}
|
||||
return formatShortcutLabelForSR(shortcut, {
|
||||
controlLabel: t('shortcutsPanel.sr.control'),
|
||||
commandLabel: t('shortcutsPanel.sr.command'),
|
||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||
})
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const getHoldTemplate = useCallback(
|
||||
(type: 'visual' | 'sr' = 'visual') => {
|
||||
return t(`shortcutsPanel.${type}.hold`, { key: '{{key}}' })
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
return {
|
||||
formatVisual,
|
||||
formatForSR,
|
||||
getHoldTemplate,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Shortcut = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useEffect } from 'react'
|
||||
|
||||
type UseFocusTrapOptions = {
|
||||
isActive: boolean
|
||||
fallbackRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
const focusableSelector =
|
||||
'button:not([disabled]):not([hidden]):not([aria-hidden="true"]), [href]:not([disabled]):not([hidden]):not([aria-hidden="true"]), input:not([disabled]):not([hidden]):not([aria-hidden="true"]), select:not([disabled]):not([hidden]):not([aria-hidden="true"]), textarea:not([disabled]):not([hidden]):not([aria-hidden="true"]), [tabindex]:not([tabindex="-1"]):not([disabled]):not([hidden]):not([aria-hidden="true"])'
|
||||
|
||||
// Adds a simple focus trap on the given container: Tab/Shift+Tab loop inside.
|
||||
export const useFocusTrap = (
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
{ isActive, fallbackRef }: UseFocusTrapOptions
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const focusable =
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector)
|
||||
const fallback = fallbackRef?.current ?? container
|
||||
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault()
|
||||
fallback.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const active = document.activeElement
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === container) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [containerRef, fallbackRef, isActive])
|
||||
}
|
||||
@@ -3,19 +3,23 @@ import { useSnapshot } from 'valtio'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { formatShortcutKey } from './utils'
|
||||
import { loadShortcutOverrides } from '@/stores/shortcutOverrides'
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
|
||||
|
||||
useEffect(() => {
|
||||
loadShortcutOverrides()
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import {
|
||||
formatShortcutKey,
|
||||
getEffectiveShortcut,
|
||||
} from '@/features/shortcuts/utils'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { ShortcutId, getShortcutById } from './catalog'
|
||||
import {
|
||||
getOverride,
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
shortcutId?: ShortcutId
|
||||
fallbackShortcut?: Shortcut
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
shortcutId,
|
||||
fallbackShortcut,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const previousKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
const formattedKey = formatShortcutKey(shortcut)
|
||||
let effectiveShortcut: Shortcut | undefined
|
||||
|
||||
if (shortcutId) {
|
||||
// Try override first, then fallback to catalog default
|
||||
effectiveShortcut = getOverride(shortcutId)
|
||||
if (!effectiveShortcut) {
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
effectiveShortcut = catalogItem?.shortcut
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to provided shortcuts if no shortcutId or catalog item found
|
||||
effectiveShortcut = effectiveShortcut || fallbackShortcut || shortcut
|
||||
|
||||
if (!effectiveShortcut) {
|
||||
// Clean up previous shortcut if exists
|
||||
if (previousKeyRef.current) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
previousKeyRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const formattedKey = formatShortcutKey(effectiveShortcut)
|
||||
|
||||
// Clean up previous shortcut if the key changed
|
||||
if (previousKeyRef.current && previousKeyRef.current !== formattedKey) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
previousKeyRef.current = null
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
previousKeyRef.current = formattedKey
|
||||
}
|
||||
}, [handler, shortcut, isDisabled])
|
||||
|
||||
// Cleanup function: remove shortcut when component unmounts or dependencies change
|
||||
return () => {
|
||||
if (previousKeyRef.current) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
previousKeyRef.current = null
|
||||
}
|
||||
}
|
||||
}, [handler, shortcutId, shortcut, fallbackShortcut, isDisabled, overrides])
|
||||
}
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { ShortcutId, ShortcutDescriptor } from './catalog'
|
||||
|
||||
export const CTRL = 'ctrl'
|
||||
|
||||
export const formatShortcutKey = (shortcut: Shortcut) => {
|
||||
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
|
||||
return shortcut.key.toUpperCase()
|
||||
const parts = []
|
||||
if (shortcut.ctrlKey) parts.push(CTRL)
|
||||
if (shortcut.altKey) parts.push('alt')
|
||||
if (shortcut.shiftKey) parts.push('shift')
|
||||
parts.push(shortcut.key.toUpperCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
||||
if (!shortcut.key) return
|
||||
let formattedKeyLabel = shortcut.key.toLowerCase()
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) {
|
||||
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
|
||||
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||
}
|
||||
if (shortcut.altKey) {
|
||||
parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||
}
|
||||
if (shortcut.shiftKey) {
|
||||
parts.push('Shift')
|
||||
}
|
||||
parts.push(shortcut.key.toLowerCase())
|
||||
const formattedKeyLabel = parts.join('+')
|
||||
return `${label} (${formattedKeyLabel})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective shortcut for a given shortcutId by checking overrides first,
|
||||
* then falling back to the catalog default.
|
||||
* @param shortcutId - The shortcut identifier
|
||||
* @param overrides - Map of shortcut overrides
|
||||
* @param getShortcutById - Function to lookup shortcuts from the catalog
|
||||
* @returns The effective shortcut (override if present, otherwise catalog default)
|
||||
*/
|
||||
export const getEffectiveShortcut = (
|
||||
shortcutId: ShortcutId,
|
||||
overrides: Map<string, Shortcut>,
|
||||
getShortcutById: (id: ShortcutId) => ShortcutDescriptor | undefined
|
||||
): Shortcut | undefined => {
|
||||
const override = overrides.get(shortcutId)
|
||||
if (override) return override
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
return catalogItem?.shortcut
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSubtitles } from '../hooks/useSubtitles'
|
||||
import { css, cva } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
|
||||
id: string
|
||||
text: string
|
||||
language: string
|
||||
startTime: number
|
||||
startTime?: number
|
||||
endTime: number
|
||||
final: boolean
|
||||
firstReceivedTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
export interface TranscriptionSegmentWithParticipant
|
||||
extends TranscriptionSegment {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export interface TranscriptionRow {
|
||||
id: string
|
||||
participant: Participant
|
||||
segments: TranscriptionSegment[]
|
||||
startTime: number
|
||||
startTime?: number
|
||||
lastUpdateTime: number
|
||||
lastReceivedTime: number
|
||||
}
|
||||
|
||||
const useTranscriptionState = () => {
|
||||
const [transcriptionRows, setTranscriptionRows] = useState<
|
||||
TranscriptionRow[]
|
||||
const [transcriptionSegments, setTranscriptionSegments] = useState<
|
||||
TranscriptionSegmentWithParticipant[]
|
||||
>([])
|
||||
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
|
||||
useState<string | null>(null)
|
||||
|
||||
const updateTranscriptions = (
|
||||
const updateTranscriptionSegments = (
|
||||
segments: TranscriptionSegment[],
|
||||
participant?: Participant
|
||||
) => {
|
||||
console.log(participant, segments)
|
||||
|
||||
if (!participant || segments.length === 0) return
|
||||
|
||||
setTranscriptionRows((prevRows) => {
|
||||
const updatedRows = [...prevRows]
|
||||
const now = Date.now()
|
||||
if (segments.length > 1) {
|
||||
console.warn('Unexpected error more segments')
|
||||
return
|
||||
}
|
||||
|
||||
const shouldAppendToLastRow =
|
||||
lastActiveParticipantIdentity === participant.identity &&
|
||||
updatedRows.length > 0
|
||||
const segment = segments[0]
|
||||
|
||||
if (shouldAppendToLastRow) {
|
||||
const lastRowIndex = updatedRows.length - 1
|
||||
const lastRow = updatedRows[lastRowIndex]
|
||||
|
||||
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
|
||||
const newSegments = segments.filter(
|
||||
(segment) => !existingSegmentIds.has(segment.id)
|
||||
)
|
||||
const updatedSegments = lastRow.segments.map((existing) => {
|
||||
const update = segments.find((s) => s.id === existing.id)
|
||||
return update && update.final ? update : existing
|
||||
})
|
||||
|
||||
updatedRows[lastRowIndex] = {
|
||||
...lastRow,
|
||||
segments: [...updatedSegments, ...newSegments],
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
} else {
|
||||
const newRow: TranscriptionRow = {
|
||||
id: `${participant.identity}-${now}`,
|
||||
participant,
|
||||
segments: [...segments],
|
||||
startTime: Math.min(...segments.map((s) => s.startTime)),
|
||||
lastUpdateTime: now,
|
||||
}
|
||||
updatedRows.push(newRow)
|
||||
}
|
||||
|
||||
return updatedRows
|
||||
})
|
||||
|
||||
setLastActiveParticipantIdentity(participant.identity)
|
||||
}
|
||||
|
||||
const clearTranscriptions = () => {
|
||||
setTranscriptionRows([])
|
||||
setLastActiveParticipantIdentity(null)
|
||||
}
|
||||
|
||||
const updateParticipant = (_name: string, participant: Participant) => {
|
||||
setTranscriptionRows((prevRows) => {
|
||||
return prevRows.map((row) => {
|
||||
if (row.participant.identity === participant.identity) {
|
||||
return {
|
||||
...row,
|
||||
participant,
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
transcriptionRows,
|
||||
updateTranscriptions,
|
||||
clearTranscriptions,
|
||||
updateParticipant,
|
||||
updateTranscriptionSegments,
|
||||
transcriptionSegments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
|
||||
export const Subtitles = () => {
|
||||
const { areSubtitlesOpen } = useSubtitles()
|
||||
const room = useRoomContext()
|
||||
const { transcriptionRows, updateTranscriptions, updateParticipant } =
|
||||
|
||||
const { transcriptionSegments, updateTranscriptionSegments } =
|
||||
useTranscriptionState()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
return () => {
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||
}
|
||||
}, [room, updateTranscriptions])
|
||||
}, [room, updateTranscriptionSegments])
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
|
||||
const transcriptionRows = useMemo(() => {
|
||||
if (transcriptionSegments.length === 0) return []
|
||||
|
||||
const rows: TranscriptionRow[] = []
|
||||
let currentRow: TranscriptionRow | null = null
|
||||
|
||||
for (const segment of transcriptionSegments) {
|
||||
const shouldStartNewRow =
|
||||
!currentRow ||
|
||||
currentRow.participant.identity !== segment.participant.identity
|
||||
|
||||
if (shouldStartNewRow) {
|
||||
currentRow = {
|
||||
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
|
||||
participant: segment.participant,
|
||||
segments: [segment],
|
||||
startTime: segment.startTime,
|
||||
lastUpdateTime: segment.lastReceivedTime,
|
||||
lastReceivedTime: segment.lastReceivedTime,
|
||||
}
|
||||
rows.push(currentRow)
|
||||
} else if (currentRow) {
|
||||
currentRow.segments.push(segment)
|
||||
currentRow.lastUpdateTime = Math.max(
|
||||
currentRow.lastUpdateTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
currentRow.lastReceivedTime = Math.max(
|
||||
currentRow.lastReceivedTime,
|
||||
segment.lastReceivedTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [room, updateParticipant])
|
||||
|
||||
return rows
|
||||
}, [transcriptionSegments])
|
||||
|
||||
return (
|
||||
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useStartSubtitle } from '../api/startSubtitle'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
|
||||
export const useSubtitles = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
|
||||
const room = useRoomContext()
|
||||
const apiRoomData = useRoomData()
|
||||
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
|
||||
|
||||
@@ -20,6 +24,18 @@ export const useSubtitles = () => {
|
||||
layoutStore.showSubtitles = !layoutSnap.showSubtitles
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
|
||||
const closeSubtitles = () => {
|
||||
layoutStore.showSubtitles = false
|
||||
}
|
||||
room.on(RoomEvent.Disconnected, closeSubtitles)
|
||||
return () => {
|
||||
room.off(RoomEvent.Disconnected, closeSubtitles)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
return {
|
||||
areSubtitlesOpen: layoutSnap.showSubtitles,
|
||||
toggleSubtitles,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export type RestoreFocusOptions = {
|
||||
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||
onOpened?: () => void
|
||||
onClosed?: () => void
|
||||
restoreFocusRaf?: boolean
|
||||
preventScroll?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
|
||||
* when the panel/menu closes.
|
||||
*/
|
||||
export function useRestoreFocus(
|
||||
isOpen: boolean,
|
||||
options: RestoreFocusOptions = {}
|
||||
) {
|
||||
const {
|
||||
resolveTrigger,
|
||||
onOpened,
|
||||
onClosed,
|
||||
restoreFocusRaf = false,
|
||||
preventScroll = true,
|
||||
} = options
|
||||
|
||||
const prevIsOpenRef = useRef(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevIsOpenRef.current
|
||||
|
||||
// Just opened
|
||||
if (!wasOpen && isOpen) {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||
onOpened?.()
|
||||
}
|
||||
|
||||
// Just closed
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
const focus = () => trigger.focus({ preventScroll })
|
||||
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||
else focus()
|
||||
}
|
||||
triggerRef.current = null
|
||||
onClosed?.()
|
||||
}
|
||||
|
||||
prevIsOpenRef.current = isOpen
|
||||
}, [
|
||||
isOpen,
|
||||
onClosed,
|
||||
onOpened,
|
||||
preventScroll,
|
||||
resolveTrigger,
|
||||
restoreFocusRaf,
|
||||
])
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -233,15 +249,34 @@
|
||||
"clear": "Effekt deaktivieren",
|
||||
"blur": {
|
||||
"title": "Hintergrundunschärfe",
|
||||
"light": "Leichte Unschärfe",
|
||||
"normal": "Unschärfe",
|
||||
"apply": "Unschärfe aktivieren",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
"status": {
|
||||
"none": "Hintergrund scharf.",
|
||||
"light": "Hintergrund leicht verschwommen.",
|
||||
"strong": "Hintergrund stark verschwommen."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Hintergrund leicht verwischen",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Hintergrund verwischen",
|
||||
"clear": "Unschärfe deaktivieren"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtueller Hintergrund",
|
||||
"apply": "Virtuellen Hintergrund aktivieren",
|
||||
"clear": "Virtuellen Hintergrund deaktivieren"
|
||||
"selectedLabel": "Hintergrund angewendet:",
|
||||
"apply": "Ersetze deinen Hintergrund:",
|
||||
"descriptions": {
|
||||
"0": "Gerilltes Holzmöbel",
|
||||
"1": "Besprechungsraum",
|
||||
"2": "Loft mit schwarzer Glaswand",
|
||||
"3": "Esszimmer",
|
||||
"4": "Holzregale",
|
||||
"5": "Holztreppe",
|
||||
"6": "Graue Bibliothek",
|
||||
"7": "Kaffeetheke"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visuelle Effekte",
|
||||
@@ -256,6 +291,8 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
@@ -468,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "Im Raum",
|
||||
"you": "Du",
|
||||
"unknown": "Unbekannter Teilnehmer",
|
||||
"host": "Host",
|
||||
"contributors": "Mitwirkende",
|
||||
"collapsable": {
|
||||
@@ -515,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",
|
||||
@@ -538,7 +584,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
||||
"toolbarHint": "{{binding}}: Tastenkürzel anzeigen.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
@@ -547,6 +593,38 @@
|
||||
"muteParticipant": "{{name}} stummschalten",
|
||||
"fullScreen": "Vollbild"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Tastenkombinationen",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Medien",
|
||||
"interaction": "Interaktion"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
|
||||
"focus-toolbar": "Fokus auf die untere Symbolleiste",
|
||||
"toggle-microphone": "Mikrofon umschalten",
|
||||
"toggle-camera": "Kamera umschalten",
|
||||
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
|
||||
"reaction": "Reaktionspanel",
|
||||
"fullscreen": "Vollbild umschalten",
|
||||
"recording": "Aufnahmepanel umschalten",
|
||||
"raise-hand": "Hand heben oder senken",
|
||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
|
||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Steuerung",
|
||||
"command": "Befehl",
|
||||
"plus": "plus",
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"noShortcut": "Kein Tastenkürzel"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Halte {{key}} gedrückt"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
||||
"stop": "Präsentation beenden",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "Französisch (fr)",
|
||||
"english": "Englisch (en)",
|
||||
"dutch": "Niederländisch (nl)",
|
||||
"german": "Deutsch (de)",
|
||||
"auto": "Automatisch"
|
||||
}
|
||||
}
|
||||
@@ -112,6 +114,27 @@
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"transcription": "Transkription"
|
||||
"transcription": "Transkription",
|
||||
"shortcuts": "Tastenkürzel"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Tastenkürzel anpassen (lokal auf diesem Gerät).",
|
||||
"pressHint": "Drücken Sie die gewünschten Tasten, um ein Kürzel zu setzen.",
|
||||
"longPressHint": "Halten Sie eine Taste gedrückt, um ein Long-Press-Kürzel zu setzen.",
|
||||
"edit": "Bearbeiten",
|
||||
"capture": "Tasten drücken …",
|
||||
"reset": "Zurücksetzen",
|
||||
"custom": "benutzerdefiniert",
|
||||
"limitations": "Die Kürzel werden lokal gespeichert und gelten nicht zwingend für alle Aktionen.",
|
||||
"listLabel": "Liste der Tastenkürzel",
|
||||
"shortcutAria": "Tastenkürzel für {{action}}: {{shortcut}}",
|
||||
"customAria": "benutzerdefiniert",
|
||||
"editAria": "Tastenkürzel für {{action}} bearbeiten",
|
||||
"captureAria": "Tasten drücken, um Tastenkürzel für {{action}} festzulegen",
|
||||
"resetAria": "Tastenkürzel für {{action}} zurücksetzen",
|
||||
"actionsGroupAria": "Aktionen für {{action}}",
|
||||
"currentShortcut": "Aktuelles Tastenkürzel: {{shortcut}}",
|
||||
"modifiedConfirmation": "Tastenkürzel geändert",
|
||||
"resetConfirmation": "Tastenkürzel zurückgesetzt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
|
||||
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Sicherheit und Plattformzugang",
|
||||
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
|
||||
"title": "7.1. Verfügbarkeitsniveau",
|
||||
"paragraphs": [
|
||||
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
|
||||
"DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
|
||||
"DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
|
||||
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
|
||||
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
|
||||
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
|
||||
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
|
||||
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Open Source und Lizenzen",
|
||||
"content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
|
||||
"title": "7.2. Sicherheit des Dienstes",
|
||||
"paragraphs": [
|
||||
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support-Management",
|
||||
"paragraphs": [
|
||||
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
|
||||
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
|
||||
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Kontrolle der Dienstenutzung",
|
||||
"paragraphs": [
|
||||
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
|
||||
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source und Lizenzen",
|
||||
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -15,7 +16,7 @@
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
},
|
||||
"effects": "Effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"videoinput": {
|
||||
"choose": "Select camera",
|
||||
"permissionsNeeded": "Select camera - permission needed",
|
||||
@@ -38,7 +39,7 @@
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Apply effects",
|
||||
"description": "Apply backgrounds and effects",
|
||||
"title": "Effects",
|
||||
"subTitle": "Configure your camera's effects."
|
||||
},
|
||||
@@ -85,6 +86,7 @@
|
||||
"heading": "Your meeting is ready",
|
||||
"description": "Share this link with people you want to invite to the meeting.",
|
||||
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||
"closeDialog": "Close dialog",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
@@ -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": {
|
||||
@@ -216,7 +232,7 @@
|
||||
"screenRecording": "Screen recording",
|
||||
"transcript": "Transcription",
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
@@ -233,15 +249,34 @@
|
||||
"clear": "Disable effect",
|
||||
"blur": {
|
||||
"title": "Background blur",
|
||||
"light": "Light blur",
|
||||
"normal": "Blur",
|
||||
"apply": "Enable blur",
|
||||
"clear": "Disable blur"
|
||||
"status": {
|
||||
"none": "Background clear.",
|
||||
"light": "Background slightly blurred.",
|
||||
"strong": "Background strongly blurred."
|
||||
},
|
||||
"light": {
|
||||
"apply": "Slightly blur your background",
|
||||
"clear": "Disable blur"
|
||||
},
|
||||
"normal": {
|
||||
"apply": "Blur your background",
|
||||
"clear": "Disable blur"
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtual background",
|
||||
"apply": "Enable virtual background",
|
||||
"clear": "Disable virtual background"
|
||||
"selectedLabel": "Background applied:",
|
||||
"apply": "Replace your background:",
|
||||
"descriptions": {
|
||||
"0": "Fluted wooden furniture",
|
||||
"1": "Meeting room",
|
||||
"2": "Loft with black glass partition",
|
||||
"3": "Dining room",
|
||||
"4": "Wooden shelves",
|
||||
"5": "Wooden staircase",
|
||||
"6": "Gray library",
|
||||
"7": "Coffee counter"
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
"title": "Visual Effects",
|
||||
@@ -256,9 +291,11 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "Transcribe",
|
||||
"screenRecording": "Record",
|
||||
@@ -268,7 +305,7 @@
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"chat": "messages",
|
||||
"transcript": "transcribe",
|
||||
"screenRecording": "record",
|
||||
@@ -468,6 +505,7 @@
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
"unknown": "Unknown participant",
|
||||
"host": "Host",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
@@ -515,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",
|
||||
@@ -538,15 +584,47 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
||||
"toolbarHint": "{{binding}} : show keyboard shortcuts.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
},
|
||||
"effects": "Apply visual effects",
|
||||
"effects": "Apply backgrounds and effects",
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"fullScreen": "Full screen"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Media",
|
||||
"interaction": "Interaction"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Open shortcuts help",
|
||||
"focus-toolbar": "Focus bottom toolbar",
|
||||
"toggle-microphone": "Toggle microphone",
|
||||
"toggle-camera": "Toggle camera",
|
||||
"push-to-talk": "Push-to-talk (hold to unmute)",
|
||||
"reaction": "Emoji reaction panel",
|
||||
"fullscreen": "Toggle fullscreen",
|
||||
"recording": "Toggle recording panel",
|
||||
"raise-hand": "Raise or lower hand",
|
||||
"toggle-chat": "Toggle chat",
|
||||
"toggle-participants": "Toggle participants",
|
||||
"open-shortcuts-settings": "Open shortcuts settings"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
"command": "Command",
|
||||
"plus": "plus",
|
||||
"hold": "Hold {{key}}",
|
||||
"noShortcut": "No shortcut"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Hold {{key}}"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
||||
"stop": "Stop presenting",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"options": {
|
||||
"french": "French (fr)",
|
||||
"english": "English (en)",
|
||||
"dutch": "Dutch (nl)",
|
||||
"german": "German (de)",
|
||||
"auto": "Automatic"
|
||||
}
|
||||
}
|
||||
@@ -106,12 +108,39 @@
|
||||
"label": "Language"
|
||||
},
|
||||
"settingsButtonLabel": "Settings",
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Profile",
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications",
|
||||
"transcription": "Transcription"
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Shortcuts"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Customize keyboard shortcuts (local to this device).",
|
||||
"pressHint": "Press the desired keys to set a new shortcut.",
|
||||
"longPressHint": "Hold a key to set a long-press shortcut.",
|
||||
"edit": "Edit",
|
||||
"capture": "Press keys…",
|
||||
"reset": "Reset",
|
||||
"custom": "custom",
|
||||
"limitations": "Shortcuts are saved locally and may not apply to all actions yet.",
|
||||
"listLabel": "List of keyboard shortcuts",
|
||||
"shortcutAria": "Shortcut for {{action}}: {{shortcut}}",
|
||||
"customAria": "custom",
|
||||
"editAria": "Edit shortcut for {{action}}",
|
||||
"captureAria": "Press keys to set shortcut for {{action}}",
|
||||
"resetAria": "Reset shortcut for {{action}}",
|
||||
"actionsGroupAria": "Actions for {{action}}",
|
||||
"currentShortcut": "Current shortcut: {{shortcut}}",
|
||||
"modifiedConfirmation": "Shortcut modified",
|
||||
"resetConfirmation": "Shortcut reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. DINUM Commitments and Responsibilities",
|
||||
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Security and Platform Access",
|
||||
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
|
||||
"title": "7.1. Service Availability",
|
||||
"paragraphs": [
|
||||
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
|
||||
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
|
||||
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
|
||||
"The service is available 24/7, except during maintenance windows.",
|
||||
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.",
|
||||
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.",
|
||||
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
|
||||
"Service unavailability does not give rise to any compensation of any kind."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2 Open Source and Licenses",
|
||||
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
"title": "7.2. Service Security",
|
||||
"paragraphs": [
|
||||
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support Management",
|
||||
"paragraphs": [
|
||||
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
|
||||
"This support can be reached via our online contact form.",
|
||||
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Monitoring Service Use",
|
||||
"paragraphs": [
|
||||
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
|
||||
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source and Licenses",
|
||||
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 !"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user