Compare commits

...

42 Commits

Author SHA1 Message Date
Cyril cb2af9da96 🎨(frontend) fix prettier formatting for room ui components
align formatting with prettier checks to keep ci passing consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:52:04 +01:00
Cyril ed97f09aad 🐛(frontend) fix focus restore for chevron menus
Ensure focus returns to camera chevron after effects panel closes.
2026-02-05 10:31:24 +01:00
Cyril c803bd9170 fixup! ♻️(frontend) replace side panel context with valtio store 2026-02-05 10:05:53 +01:00
Cyril daa3d6bcc6 ️(frontend) centralize popover focus and reuse side panel close
move popover focus handling into primitive and reuse close helper
2026-02-05 10:05:52 +01:00
Cyril 096968c222 ♻️(frontend) replace side panel context with valtio store
centralize refs/triggers in layout store to keep focus behavior
2026-02-05 10:05:52 +01:00
Cyril 71637dc199 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:52 +01:00
Cyril 8c60cfeccd 📝(docs) update changelog
Document focus management pattern entry
2026-02-05 10:05:51 +01:00
Cyril f711346073 ️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
2026-02-05 10:03:28 +01:00
Cyril 5fa287c2b0 ️(frontend) add escape key hook for overlays
Use shared hook to close side panel and reactions
2026-02-05 10:03:27 +01:00
Cyril f5cc76861d 📝(docs) add side panel focus pattern
Document trigger ref and panel ref focus workflow
2026-02-05 10:03:27 +01:00
Cyril 87bc5ff6ed ️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
2026-02-05 10:03:26 +01:00
Cyril 17602ab548 ♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
2026-02-05 10:03:26 +01:00
Cyril 4fbcf8d64e (frontend) add auto refocus for effects menu
Restore focus to options button on close, focus first element on open
2026-02-05 10:03:25 +01:00
Cyril d4d484b9b1 🔧(git) ignore personal rules.md file
Allow developers to keep personal rules.md files without committing them
2026-02-05 10:03:25 +01:00
Cyril cfc6b90bd7 ️(frontend) add visible focus ring on restore focus
Show focus ring when restoring focus, keyboard navigation only
2026-02-05 10:03:25 +01:00
Cyril 716e11b5b3 ️(frontend) fix form labels and autocomplete wiring
Ensure labels map to inputs and avoid empty describedby output
2026-02-04 09:28:15 +01:00
lebaudantoine 88a1136dfd ♻️(backend) refactor ApplicationViewSet to use a basic ViewSet
This endpoint only exposes a custom action for token generation and does not
rely on serializers or querysets. Using ViewSet is more appropriate here, as
it provides routing without enforcing standard CRUD patterns or requiring a
serializer_class.

This removes unnecessary constraints and avoids warnings related to missing
serializer configuration, while better reflecting the actual responsibility of
this view.

I noticed this bug from Sentry issue 241308
2026-02-03 16:22:06 +01:00
lebaudantoine 90633928a8 💚(backend) reactivate trivy scan on backend image
Protobuff has been patched, rebuilding the backend image should be
enough with pip to pull its latest version, which fixes the CVE.
2026-02-03 11:57:02 +01:00
lebaudantoine fd894eb61f 🔧(compose) configure LiveKit webhooks in the local Docker Compose stack
Without this configuration, LiveKit does not notify the backend when a recording
starts, leaving it stuck in a “starting recording” state.

Thanks to @leobouloc for spotting the issue.
2026-01-29 18:22:00 +01:00
lebaudantoine bb64532cff 🔖(minor) bump release to 1.5.0 2026-01-28 21:28:55 +01:00
Cyril 692c55ed1b Merge branch 'refactor/issue-921-generic-sr-announcer' 2026-01-28 17:07:43 +01:00
lebaudantoine df616ae711 🩹(doc) fix github rendering of docker compose doc
The docker compose rendering was broken because of a recent merge.
Fix it. I've also fixed other minor issues.
2026-01-28 16:17:53 +01:00
Cyril 021d7a7e06 ️(frontend) centralize aria-live announcements in store
avoid per-feature live regions and reduce a11y duplication.
2026-01-28 14:01:35 +01:00
Andrew Hunter f2a3e7c8de 📝(doc) Fix typo 2026-01-28 12:13:19 +01:00
Andrew Hunter cf07ceb67e 🔧(docker) Fix incorrect env variable
Incorrect capitalization prevents correct MEET_HOST variable
subsitution.
2026-01-28 12:13:19 +01:00
Andrew Hunter ea7fb5fc27 📝(doc) Use an empty directory for postgres
Use an empty directory for postgres data, otherwise it will complain the
directory is not empty and fail to start.
2026-01-28 12:13:19 +01:00
Andrew Hunter 6e8a6ce82a 📝(doc) Add -p swich to mkdir
Add the -p switch to create the parent directory before we try to cd
into it.
2026-01-28 12:13:19 +01:00
Andrew Hunter ce960ae330 📝 (doc) Add key gen example
Add a API key generation example using OpenSSL.
2026-01-28 12:13:19 +01:00
Cyril f9dd2e1909 ️(frontend) add global screen reader announcer
centralize live region rendering with a shared announce hook.
2026-01-28 11:44:39 +01:00
Cyril 9023e54352 ️(frontend) add screen reader announcer store
create shared state for screen reader announcements.
2026-01-28 11:40:54 +01:00
Cyril 8295574616 (frontend) sr pin/unpin announcements with dedicated messages
improves accessibility by announcing pin/unpin on state change
2026-01-28 11:13:09 +01:00
Cyril db15c8b6cc ️(frontend) adjust visual-only tooltip a11y labels
Ensure tooltips stay visual while exposing correct aria-labels.
2026-01-28 10:08:01 +01:00
Cyril e1aeec6053 ️(frontend) adjust sr announcements for idle disconnect timer
reduces screen reader noise while keeping key countdown cues
2026-01-27 22:12:55 +01:00
lebaudantoine c5aa762e11 📝(doc) update mosacloud link in the list of saas instances
Link has changed. Update it.
2026-01-27 18:38:34 +01:00
lebaudantoine 8f710a4626 🔒️(frontend) fix an XSS vulnerability on the recording page
An XSS vulnerability was identified by an open-source contributor. While the
impact was limited, only a room owner could inject the content and then view the
recording page, it is important to address, especially before introducing
multi-owner support.
2026-01-27 14:12:45 +01:00
virgile-deville 60d1338eff 📝(readme) mention french state wide deployment
To indicate product maturity to reusers

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2026-01-26 12:04:16 +01:00
lebaudantoine f8436d9ae2 🔖(minor) bump release to 1.4.0 2026-01-25 20:02:37 +01:00
lebaudantoine 39fb273201 💩(ci) disable temporarily Trivy scan step for backend image
A new vulnerability (CVE-2026-0994) was reported and is not yet fixed.
It affects protobuf libraries used by the livekit-api Python package.

A fix is in progress upstream, but the related PR has not yet been merged or
released. Since a release is required tonight, the Trivy scan step is
temporarily disabled to allow the build to proceed. This should be re-enabled
once a patched version is available.

https://github.com/protocolbuffers/protobuf/pull/25239
2026-01-25 18:01:13 +01:00
lebaudantoine d101459115 (frontend) add configurable external redirect for unauthenticated users
Offer a way to redirect unauthenticated users to an external home page when they
visit the app, allowing a more marketing-focused entry point with a clearer
value proposition.

In many self-hosted deployments, the default unauthenticated home page is not
accessible or already redirects elsewhere. To ensure resilience, the client
briefly checks that the target page is reachable and falls back to the default
page if not.
2026-01-25 16:49:56 +01:00
aleb_the_flash 88696a23fd 🩹(doc) update link to the environment variables
Link was invalid. Update it to point to the chart's README file.
Please note this file might be removed.
2026-01-25 00:17:50 +01:00
Cyril 13d26a76b3 (frontend) scope scrollbar gutter override to video rooms
limit scrollbar gutter override to video conference context
2026-01-25 00:07:51 +01:00
lebaudantoine b675517a60 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:43:29 +01:00
76 changed files with 1230 additions and 233 deletions
+3
View File
@@ -77,6 +77,9 @@ db.sqlite3
*.iml
.devcontainer
# Personal rules/config files
rules.md
# Egress output
docker/livekit/out
+25
View File
@@ -10,6 +10,30 @@ and this project adheres to
### Changed
- ♿️(frontend) fix form labels and autocomplete wiring #932
- ♿(frontend) improve menu focus management #869
## [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) improve menu focus management #869
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
@@ -24,6 +48,7 @@ and this project adheres to
- 📝(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
+4 -1
View File
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were 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, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments 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/
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+12 -3
View File
@@ -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 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.3.0"
version = "1.5.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+1 -1
View File
@@ -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(
+3
View File
@@ -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
),
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.3.0"
version = "1.5.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+34
View File
@@ -28,3 +28,37 @@ export default {
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
## Side Panel Focus Pattern
We use a consistent focus management pattern for side panels:
- **Open**: focus the first actionable element inside the panel.
- **Close**: restore focus to the button that opened the panel.
Implementation summary:
1. A provider stores a `panelRef` and a registry of trigger refs (`setTrigger/getTrigger`).
2. Each trigger button registers itself with `setTrigger("key", el)`.
3. Panel content uses `useRestoreFocus` with:
- `resolveTrigger` → returns `getTrigger("key")`.
- `onOpened` → finds the first actionable element inside `panelRef`.
Example:
```tsx
// Trigger button
;<ToggleButton ref={(el) => setTrigger('tools', el)} />
// Panel content
useRestoreFocus(isOpen, {
resolveTrigger: (activeEl) => getTrigger('tools') ?? activeEl,
onOpened: () => {
const first = panelRef.current?.querySelector(
'[data-attr="tools-list"] button'
)
// Leading semicolon avoids ASI issues when a line starts with '('
;(first as HTMLElement | null)?.focus({ preventScroll: true })
},
})
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.3.0",
"version": "1.5.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.3.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -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
+27 -1
View File
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { LoadingScreen } from '@/components/LoadingScreen'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -155,9 +156,34 @@ export const Home = () => {
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig()
useEffect(() => {
const checkSiteAndRedirect = async () => {
if (!data?.external_home_url) return
if (isLoggedIn === false) {
try {
await fetch(data.external_home_url, {
method: 'HEAD', // Use HEAD to avoid downloading the full page
mode: 'no-cors', // Needed for cross-origin requests
})
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
console.error('Site is not reachable:', error)
}
}
}
checkSiteAndRedirect()
}, [isLoggedIn, data])
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
return <LoadingScreen header={false} footer={false} delay={0} />
}
return (
<UserAware>
<Screen>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useMemo, useRef, useEffect } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
@@ -21,8 +22,8 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const announce = useScreenReaderAnnounce()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -76,16 +77,9 @@ export const RecordingStateToast = () => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
announce(message)
}
}, [key, t])
}, [announce, key, t])
if (!key) return null
@@ -95,15 +89,6 @@ export const RecordingStateToast = () => {
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
@@ -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 && (
<>
@@ -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>
@@ -10,9 +10,16 @@ import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
const { isAdminOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const { roomId } = useParams()
@@ -38,6 +45,29 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
// Restore focus to the element that opened the Admin panel
useRestoreFocus(isAdminOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('admin') ?? activeEl
},
// Focus the first focusable element when the panel opens (first Field switch)
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first switch in the moderation section
const firstSwitch =
panel.querySelector<HTMLElement>('[role="switch"]')
if (firstSwitch) {
firstSwitch.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -5,6 +5,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggerRef } from '../hooks/useSidePanelTriggerRef'
export const AdminToggle = ({
variant = 'primaryTextDark',
@@ -15,9 +16,10 @@ export const AdminToggle = ({
const { isAdminOpen, toggleAdmin } = useSidePanel()
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useSidePanelTriggerRef('admin')
const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return
if (!hasAdminAccess) return null
return (
<div
@@ -32,6 +34,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -9,9 +9,16 @@ import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const { isInfoOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +31,27 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
// Restore focus to the element that opened the Info panel
useRestoreFocus(isInfoOpen, {
resolveTrigger: (activeEl) => getTrigger('info') ?? activeEl,
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="copy-info-sidepanel"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -71,7 +99,7 @@ export const Info = () => {
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard}
data-attr="copy-info-sidepannel"
data-attr="copy-info-sidepanel"
style={{
marginLeft: '-8px',
}}
@@ -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>
@@ -8,6 +8,7 @@ import { Reaction } from '@/features/rooms/livekit/components/controls/Reactions
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
@@ -146,15 +147,14 @@ export function ReactionPortal({
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
@@ -166,12 +166,9 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
@@ -182,14 +179,6 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -13,6 +13,8 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useEscapeKey } from '@/hooks/useEscapeKey'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
}
@@ -36,9 +39,11 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
ref={panelRef}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -135,7 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const SidePanelContent = () => {
const {
activePanelId,
isParticipantsOpen,
@@ -147,17 +152,27 @@ export const SidePanel = () => {
isInfoOpen,
isSubPanelOpen,
activeSubPanelId,
closeSidePanel,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
closeSidePanel()
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closeSidePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -165,7 +180,9 @@ export const SidePanel = () => {
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
{/* keepAlive stays only for Info to reduce memory footprint */}
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
@@ -187,3 +204,7 @@ export const SidePanel = () => {
</StyledSidePanel>
)
}
export const SidePanel = () => {
return <SidePanelContent />
}
@@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -98,6 +100,8 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
@@ -106,10 +110,30 @@ export const Tools = () => {
// 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')
return getTrigger('options') ?? activeEl
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
return getTrigger('tools') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToolButton in the tools list (transcript or screen recording button)
const toolsList = panel.querySelector<HTMLElement>(
'[data-attr="tools-list"]'
)
if (toolsList) {
const firstToolButton = toolsList.querySelector<HTMLElement>(
'button:first-of-type'
)
if (firstToolButton) {
firstToolButton.focus({ preventScroll: true })
}
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
@@ -141,6 +165,7 @@ export const Tools = () => {
flexDirection="column"
alignItems="start"
gap={0.5}
data-attr="tools-list"
>
<Text
variant="note"
@@ -1,3 +1,4 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
@@ -9,13 +10,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import Source = Track.Source
import { isSafari } from '@/utils/livekit'
import { AUDIO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,7 @@ export const AudioDevicesControl = ({
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -76,7 +78,16 @@ export const AudioDevicesControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: AUDIO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
@@ -100,6 +111,7 @@ export const AudioDevicesControl = ({
})}
>
<div
data-attr="audio-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -10,6 +10,7 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
@@ -17,10 +18,12 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { VIDEO_INPUT_FOCUS_SELECTOR } from './deviceFocusSelectors'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const setEffectsTriggerRef = useSidePanelTriggerRef('effects')
return (
<Button
size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={setEffectsTriggerRef}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -50,6 +54,8 @@ export const VideoDeviceControl = ({
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const setCameraMenuTriggerRef = useSidePanelTriggerRef('cameraMenu')
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -120,12 +126,22 @@ export const VideoDeviceControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
focusOnOpen={{
selector: VIDEO_INPUT_FOCUS_SELECTOR,
delayMs: 250,
}}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={setCameraMenuTriggerRef}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -136,6 +152,7 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
data-attr="camera-menu-popover"
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -144,6 +161,7 @@ export const VideoDeviceControl = ({
})}
>
<div
data-attr="video-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -0,0 +1,5 @@
export const AUDIO_INPUT_FOCUS_SELECTOR =
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]'
export const VIDEO_INPUT_FOCUS_SELECTOR =
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]'
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const InfoToggle = ({
onPress,
@@ -13,6 +14,7 @@ export const InfoToggle = ({
const { isInfoOpen, toggleInfo } = useSidePanel()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const setInfoTriggerRef = useSidePanelTriggerRef('info')
return (
<div
@@ -27,6 +29,7 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
ref={setInfoTriggerRef}
onPress={(e) => {
toggleInfo()
onPress?.(e)
@@ -2,9 +2,11 @@ import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const setOptionsTriggerRef = useSidePanelTriggerRef('options')
return (
<Menu variant="dark">
@@ -14,6 +16,7 @@ export const OptionsButton = () => {
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
ref={setOptionsTriggerRef}
>
<RiMoreFill />
</Button>
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
<ToggleHeader
isSelected={isOpen}
aria-label={label}
data-focus-target="list-header"
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
@@ -12,10 +12,17 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const { isParticipantsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
@@ -48,6 +55,31 @@ export const ParticipantsList = () => {
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
// Restore focus to the element that opened the Participants panel
useRestoreFocus(isParticipantsOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('participants') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
// Use setTimeout + RAF to ensure DOM is fully rendered and transition completed
setTimeout(() => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToggleHeader (collapsable list header) in the participants panel
const firstListHeader = panel.querySelector<HTMLElement>(
'button[data-focus-target="list-header"]'
)
firstListHeader?.focus({ preventScroll: true })
}
})
}, 100) // Wait for panel slide-in animation to complete
},
restoreFocusRaf: true,
preventScroll: true,
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
@@ -6,6 +6,7 @@ import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../../hooks/useSidePanelTriggerRef'
export const ParticipantsToggle = ({
onPress,
@@ -24,6 +25,7 @@ export const ParticipantsToggle = ({
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const setParticipantsTriggerRef = useSidePanelTriggerRef('participants')
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
@@ -42,6 +44,7 @@ export const ParticipantsToggle = ({
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
ref={setParticipantsTriggerRef}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
@@ -14,6 +14,7 @@ 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 { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -37,6 +38,7 @@ export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false)
@@ -102,6 +104,22 @@ export const ReactionsToggle = () => {
}
}, [isVisible, isRendered])
useEscapeKey(
() => {
// Mirror the trigger button behavior (Enter toggles open/close)
triggerRef.current?.click()
requestAnimationFrame(() => {
triggerRef.current?.focus({ preventScroll: true })
})
},
{
isActive: isVisible,
capture: true,
preventDefault: true,
stopPropagation: true,
}
)
return (
<>
<div
@@ -114,6 +132,7 @@ export const ReactionsToggle = () => {
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggerRef } from '../../hooks/useSidePanelTriggerRef'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -14,6 +15,7 @@ export const ToolsToggle = ({
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useSidePanelTriggerRef('tools')
return (
<div
@@ -28,6 +30,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -5,14 +5,51 @@ import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const { isEffectsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
useRestoreFocus(isEffectsOpen, {
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return getTrigger('options') ?? activeEl
}
if (activeEl?.closest('[data-attr="camera-menu-popover"]')) {
return getTrigger('cameraMenu') ?? activeEl
}
// For direct button clicks, use the active element as is
return activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first toggle button (blur light button)
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="toggle-blur-light"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<div
className={css({
@@ -18,6 +18,7 @@ import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
enum BlurRadius {
NONE = 0,
@@ -56,7 +57,7 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const announce = useScreenReaderAnnounce()
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
@@ -104,12 +105,9 @@ export const EffectsConfiguration = ({
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
announce(message)
}, 80)
}
@@ -423,9 +421,6 @@ export const EffectsConfiguration = ({
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div>
<div
className={css({
@@ -1,5 +1,7 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -19,6 +21,41 @@ export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
const prevPanelIdRef = useRef<PanelId | null>(activePanelId)
const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) {
return layoutStore.sidePanelTriggers.options ?? activeEl
}
}
if (
panelId === PanelId.EFFECTS &&
activeEl?.closest('[data-attr="camera-menu-popover"]')
) {
return layoutStore.sidePanelTriggers.cameraMenu ?? activeEl
}
const triggerKeyByPanel: Partial<Record<PanelId, SidePanelTriggerKey>> = {
[PanelId.PARTICIPANTS]: 'participants',
[PanelId.TOOLS]: 'tools',
[PanelId.INFO]: 'info',
[PanelId.ADMIN]: 'admin',
[PanelId.EFFECTS]: 'effects',
}
const triggerKey = triggerKeyByPanel[panelId]
return triggerKey
? (layoutStore.sidePanelTriggers[triggerKey] ?? activeEl)
: activeEl
}
const storeLastTrigger = (panelId: PanelId) => {
const activeEl = document.activeElement as HTMLElement | null
layoutStore.lastSidePanelTriggerRef.current = resolveTrigger(
panelId,
activeEl
)
}
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
@@ -32,45 +69,101 @@ export const useSidePanel = () => {
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
const closeSidePanel = () => {
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
}
useEffect(() => {
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevPanelIdRef.current
if (wasOpen && !activePanelId) {
const trigger = layoutStore.lastSidePanelTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
}
}
}
prevPanelIdRef.current = activePanelId
}, [activePanelId])
return {
activePanelId,
activeSubPanelId,
@@ -82,6 +175,7 @@ export const useSidePanel = () => {
toggleInfo,
openTranscript,
openScreenRecording,
closeSidePanel,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -0,0 +1,5 @@
import { layoutStore } from '@/stores/layout'
export const useSidePanelRef = () => {
return layoutStore.sidePanelRef
}
@@ -0,0 +1,13 @@
import { useCallback } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
import { useSidePanelTriggers } from './useSidePanelTriggers'
export const useSidePanelTriggerRef = (key: SidePanelTriggerKey) => {
const { setTrigger } = useSidePanelTriggers()
return useCallback(
(el: HTMLElement | null) => {
setTrigger(key, el)
},
[key, setTrigger]
)
}
@@ -0,0 +1,13 @@
import { layoutStore } from '@/stores/layout'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export const useSidePanelTriggers = () => {
return {
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => {
layoutStore.sidePanelTriggers[key] = el
},
getTrigger: (key: SidePanelTriggerKey) => {
return layoutStore.sidePanelTriggers[key] ?? null
},
}
}
@@ -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,8 @@
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
@@ -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'
@@ -20,6 +20,11 @@ export interface TranscriptionSegment {
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
@@ -30,88 +35,41 @@ export interface TranscriptionRow {
}
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],
lastReceivedTime: Math.min(
...segments.map((s) => s.lastReceivedTime)
),
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,
}
}
@@ -196,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}>
@@ -232,11 +220,7 @@ export const Subtitles = () => {
>
{transcriptionRows
.slice()
.sort(
(a, b) =>
(b.startTime ?? b.lastUpdateTime) -
(a.startTime ?? a.lastUpdateTime)
)
.reverse()
.map((row) => (
<Transcription key={row.id} row={row} />
))}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react'
type UseEscapeKeyOptions = {
isActive: boolean
capture?: boolean
preventDefault?: boolean
stopPropagation?: boolean
}
export const useEscapeKey = (
handler: () => void,
{
isActive,
capture = false,
preventDefault = false,
stopPropagation = false,
}: UseEscapeKeyOptions
) => {
const handleRef = useRef(handler)
handleRef.current = handler
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation()
handleRef.current()
}
document.addEventListener('keydown', onKeyDown, capture)
return () => {
document.removeEventListener('keydown', onKeyDown, capture)
}
}, [capture, isActive, preventDefault, stopPropagation])
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
type UseFocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
export const useFocusOnOpen = (
isOpen: boolean,
containerRef: React.RefObject<HTMLElement>,
{ selector, delayMs = 0, preventScroll = true }: UseFocusOnOpenOptions
) => {
useEffect(() => {
if (!isOpen) return
const timer = setTimeout(() => {
requestAnimationFrame(() => {
const first = containerRef.current?.querySelector<HTMLElement>(selector)
first?.focus({ preventScroll })
})
}, delayMs)
return () => clearTimeout(timer)
}, [containerRef, delayMs, isOpen, preventScroll, selector])
}
+48 -1
View File
@@ -26,6 +26,26 @@ export function useRestoreFocus(
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
useEffect(() => {
// Track last interaction type (like native :focus-visible behavior)
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevIsOpenRef.current
@@ -41,7 +61,28 @@ export function useRestoreFocus(
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
const focus = () => {
trigger.focus({ preventScroll })
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
// Remove focus ring only when the trigger loses focus
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
// Store cleanup for unmount case
cleanupRef.current?.()
cleanupRef.current = () => {
trigger.removeEventListener('blur', handleBlur)
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
}
}
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
@@ -50,6 +91,12 @@ export function useRestoreFocus(
}
prevIsOpenRef.current = isOpen
// Cleanup: remove focus ring if component unmounts before focus changes
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [
isOpen,
onClosed,
@@ -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)
},
[]
)
}
+23 -16
View File
@@ -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'}
+2
View File
@@ -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>
+1 -1
View File
@@ -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": {
+11 -1
View File
@@ -156,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",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "Im Raum",
"you": "Du",
"unknown": "Unbekannter Teilnehmer",
"host": "Host",
"contributors": "Mitwirkende",
"collapsable": {
@@ -551,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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+11 -1
View File
@@ -156,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",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "In room",
"you": "You",
"unknown": "Unknown participant",
"host": "Host",
"contributors": "Contributors",
"collapsable": {
@@ -551,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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"body": "Enregistrement de la réunion {{room}} du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "Vous êtes le seul participant. Cet appel va donc se terminer dans {{duration}}. Voulez-vous poursuivre l'appel ?",
"settings": "Pour ne plus voir ce message, accèder à Paramètres > Général.",
"stayButton": "Poursuivre l'appel",
"leaveButton": "Quitter maintenant"
"leaveButton": "Quitter maintenant",
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
},
"controls": {
"microphone": "Microphone",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "Dans la réunion",
"you": "Vous",
"unknown": "Participant inconnu",
"contributors": "Contributeurs",
"host": "Organisateur de la réunion",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Épingler {{name}}"
}
},
"pinAnnouncements": {
"pin": "La vidéo de {{name}} est épinglée.",
"unpin": "La vidéo de {{name}} nest plus épinglée.",
"self": {
"pin": "Votre vidéo est épinglée.",
"unpin": "Votre vidéo nest plus épinglée."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcription en cours",
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "U hebt geen toestemming om deze pagina te bekijken"
},
"loading": "Laden ...",
"loggedInUserTooltip": "Ingelogd als ...",
"loggedInUserTooltip": "Ingelogd als ",
"login": {
"buttonLabel": "Log in",
"proconnectButtonLabel": "Log in met Proconnect",
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Je opname is klaar!",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"body": "Opname van de vergadering {{room}} op {{created_at}}.",
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
"button": "Downloaden",
"warning": {
+11 -1
View File
@@ -156,7 +156,8 @@
"body": "Je bent de enige deelnemer. Dit gesprek wordt over {{duration}} beëindigd. Wil je het gesprek voortzetten?",
"settings": "Om dit bericht niet meer te zien, ga naar Instellingen > Algemeen.",
"stayButton": "Gesprek voortzetten",
"leaveButton": "Nu verlaten"
"leaveButton": "Nu verlaten",
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
},
"controls": {
"microphone": "Microfoon",
@@ -504,6 +505,7 @@
"participants": {
"subheading": "In de ruimte",
"you": "U",
"unknown": "Onbekende deelnemer",
"host": "Host",
"contributors": "Deelnemers",
"collapsable": {
@@ -551,6 +553,14 @@
"ariaLabel": "Maak {{name}} vast"
}
},
"pinAnnouncements": {
"pin": "De video van {{name}} is vastgezet.",
"unpin": "De video van {{name}} is niet meer vastgezet.",
"self": {
"pin": "Uw video is vastgezet.",
"unpin": "Uw video is niet meer vastgezet."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcriptie bezig",
+3 -1
View File
@@ -138,7 +138,9 @@ export const Field = <T extends object>({
const LabelAndDescription = (
<>
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
{description ? (
<FieldDescription slot="description">{description}</FieldDescription>
) : null}
</>
)
const RACFieldErrors = (
+90 -3
View File
@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import type { RefObject } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import {
DialogProps,
DialogTrigger,
@@ -60,6 +61,40 @@ const StyledOverlayArrow = styled(OverlayArrow, {
},
})
type FocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
type FocusOnCloseOptions = {
ref?: RefObject<HTMLElement>
selector?: string
delayMs?: number
preventScroll?: boolean
}
const scheduleFocus = (
target: HTMLElement,
{
delayMs = 0,
preventScroll = true,
}: { delayMs?: number; preventScroll?: boolean }
) => {
const timer = setTimeout(() => {
requestAnimationFrame(() => {
target.focus({ preventScroll })
})
}, delayMs)
return () => clearTimeout(timer)
}
const resolveFocusTarget = (options: FocusOnCloseOptions) => {
if (options.ref?.current) return options.ref.current
if (!options.selector) return null
return document.querySelector<HTMLElement>(options.selector)
}
/**
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
*
@@ -70,6 +105,11 @@ export const Popover = ({
children,
variant = 'light',
withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
focusOnOpen,
focusOnClose,
...dialogProps
}: {
children: [
@@ -80,10 +120,52 @@ export const Popover = ({
]
variant?: 'dark' | 'light'
withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
focusOnOpen?: FocusOnOpenOptions
focusOnClose?: FocusOnCloseOptions
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
const popoverContentRef = useRef<HTMLDivElement>(null)
const isControlled = isOpen !== undefined
const [internalOpen, setInternalOpen] = useState(!!defaultOpen)
const effectiveOpen = isControlled ? isOpen : internalOpen
const prevOpenRef = useRef(effectiveOpen)
const handleOpenChange = (nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen)
}
onOpenChange?.(nextOpen)
}
useEffect(() => {
const wasOpen = prevOpenRef.current
if (wasOpen || !effectiveOpen || !focusOnOpen) return
const first = popoverContentRef.current?.querySelector<HTMLElement>(
focusOnOpen.selector
)
if (!first) return
return scheduleFocus(first, focusOnOpen)
}, [effectiveOpen, focusOnOpen])
useEffect(() => {
const wasOpen = prevOpenRef.current
let cleanup: (() => void) | undefined
if (wasOpen && !effectiveOpen && focusOnClose) {
const target = resolveFocusTarget(focusOnClose)
if (target) cleanup = scheduleFocus(target, focusOnClose)
}
prevOpenRef.current = effectiveOpen
return cleanup
}, [effectiveOpen, focusOnClose])
return (
<DialogTrigger>
<DialogTrigger
isOpen={effectiveOpen}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
>
{trigger}
<StyledPopover>
{withArrow && (
@@ -95,7 +177,12 @@ export const Popover = ({
)}
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover" variant={variant}>
<Box
size="sm"
type="popover"
variant={variant}
ref={popoverContentRef}
>
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
@@ -0,0 +1,29 @@
import { useSnapshot } from 'valtio'
import {
screenReaderAnnouncerStore,
type ScreenReaderChannel,
} from '@/stores/screenReaderAnnouncer'
export const ScreenReaderAnnouncer = ({
channel = 'global',
}: {
channel?: ScreenReaderChannel
}) => {
const { announcements } = useSnapshot(screenReaderAnnouncerStore)
const announcement = announcements[channel]
if (!announcement) return null
return (
<div
role="status"
aria-live={announcement.politeness}
aria-atomic="true"
className="sr-only"
data-announce-id={announcement.id}
data-announce-channel={channel}
>
{announcement.message}
</div>
)
}
@@ -1,10 +1,18 @@
import { type ReactNode, useRef, useState } from 'react'
import {
type ReactElement,
cloneElement,
isValidElement,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
children: ReactElement
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
}
/**
@@ -20,32 +28,50 @@ export type VisualOnlyTooltipProps = {
export const VisualOnlyTooltip = ({
children,
tooltip,
ariaLabel,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const getPosition = () => {
if (!wrapperRef.current) return null
const isBottom = tooltipPosition === 'bottom'
const showTooltip = () => {
if (!wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
left: rect.left + rect.width / 2,
}
})
setIsVisible(true)
}
const hideTooltip = () => {
setIsVisible(false)
setPosition(null)
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
const wrappedChild = isValidElement(children)
? cloneElement(children, {
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
})
: children
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
{wrappedChild}
</div>
{tooltipData &&
createPortal(
@@ -66,17 +92,26 @@ export const VisualOnlyTooltip = ({
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
...(isBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
}
: {
top: '100%',
borderTopColor: 'primaryDark.100',
}),
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
}}
>
{tooltip}
+1
View File
@@ -24,6 +24,7 @@ export { Menu } from './Menu'
export { MenuList } from './MenuList'
export { P } from './P'
export { Popover } from './Popover'
export { ScreenReaderAnnouncer } from './ScreenReaderAnnouncer'
export { Text } from './Text'
export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
+24 -1
View File
@@ -1,8 +1,11 @@
import { proxy } from 'valtio'
import { createRef } from 'react'
import { proxy, ref } from 'valtio'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes'
import type { MutableRefObject, RefObject } from 'react'
type State = {
showHeader: boolean
@@ -10,12 +13,32 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
sidePanelRef: RefObject<HTMLElement>
sidePanelTriggers: Record<SidePanelTriggerKey, HTMLElement | null>
lastSidePanelTriggerRef: MutableRefObject<HTMLElement | null>
}
const sidePanelRef = ref(createRef<HTMLElement>())
const lastSidePanelTriggerRef = ref({
current: null,
} as MutableRefObject<HTMLElement | null>)
const sidePanelTriggers = ref<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
export const layoutStore = proxy<State>({
showHeader: false,
showFooter: false,
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
sidePanelRef,
sidePanelTriggers,
lastSidePanelTriggerRef,
})
@@ -0,0 +1,86 @@
import { proxy } from 'valtio'
export type Politeness = 'polite' | 'assertive'
export type ScreenReaderChannel = 'global' | 'idle'
type ScreenReaderAnnouncement = {
message: string
politeness: Politeness
id: number
}
type ScreenReaderAnnouncerState = {
announcements: Record<ScreenReaderChannel, ScreenReaderAnnouncement>
}
export const screenReaderAnnouncerStore = proxy<ScreenReaderAnnouncerState>({
announcements: {
global: {
message: '',
politeness: 'polite',
id: 0,
},
idle: {
message: '',
politeness: 'polite',
id: 0,
},
},
})
const channels: ScreenReaderChannel[] = ['global', 'idle']
const announcementTokens: Record<ScreenReaderChannel, number> = {
global: 0,
idle: 0,
}
const announcementTimers: Record<
ScreenReaderChannel,
ReturnType<typeof setTimeout> | null
> = {
global: null,
idle: null,
}
const lastAnnouncementTimes: Record<ScreenReaderChannel, number> = {
global: 0,
idle: 0,
}
const MIN_ANNOUNCEMENT_INTERVAL = 300 // Minimum 300ms between announcements
export const announceToScreenReader = (
message: string,
politeness: Politeness = 'polite',
channel: ScreenReaderChannel = 'global'
) => {
if (!channels.includes(channel)) return
const now = Date.now()
const timeSinceLastAnnouncement = now - lastAnnouncementTimes[channel]
announcementTokens[channel] += 1
const currentToken = announcementTokens[channel]
if (announcementTimers[channel]) {
clearTimeout(announcementTimers[channel]!)
}
const delay = Math.max(
150, // Minimum delay for clear + set sequence
MIN_ANNOUNCEMENT_INTERVAL - timeSinceLastAnnouncement
)
screenReaderAnnouncerStore.announcements[channel] = {
message: '',
politeness,
id: currentToken,
}
announcementTimers[channel] = setTimeout(() => {
if (currentToken !== announcementTokens[channel]) return
screenReaderAnnouncerStore.announcements[channel] = {
message,
politeness,
id: currentToken,
}
lastAnnouncementTimes[channel] = Date.now()
}, delay)
}
+16 -1
View File
@@ -23,9 +23,20 @@ body,
}
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
[data-rac][data-restore-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
):focus-visible {
):focus-visible,
/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus,
/* Show focus ring when restoring focus on react-aria buttons without overriding its focus state */
[data-restore-focus-visible]:is(
a,
button,
input[type='text'],
select,
textarea
):focus {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 1px;
}
@@ -45,6 +56,10 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
display: none !important;
}
html:has(.lk-video-conference) {
scrollbar-gutter: auto !important;
}
@keyframes slide-full {
from {
transform: translateY(100%);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.5.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.5.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.3.0"
version = "1.5.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",