Compare commits

...

30 Commits

Author SHA1 Message Date
lebaudantoine 2d448cffd7 wip poc 2026-01-28 16:44:51 +01:00
Cyril 180da8d433 📝(docs) update changelog
Document focus management pattern entry
2026-01-28 12:17:06 +01:00
Cyril 8a30071f41 📝(docs) update changelog
Document focus management pattern entry
2026-01-28 12:17:06 +01:00
Cyril 2002aa7b04 ️(frontend) focus device menu on open
Add focus hook and trigger refs for device menus
2026-01-28 12:17:06 +01:00
Cyril 69dfda4b4b ️(frontend) add escape key hook for overlays
Use shared hook to close side panel and reactions
2026-01-28 12:17:06 +01:00
Cyril a863dedc6a 📝(docs) add side panel focus pattern
Document trigger ref and panel ref focus workflow
2026-01-28 12:17:06 +01:00
Cyril 17e5c31ee2 ️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
2026-01-28 12:17:06 +01:00
Cyril b16e4e9feb ♻️(frontend) use refs instead of queryselector for panels
Replace document.querySelector with panelRef for robustness
2026-01-23 14:17:11 +01:00
Cyril b3f106baf1 (frontend) add auto refocus for effects menu
Restore focus to options button on close, focus first element on open
2026-01-23 14:16:35 +01:00
Cyril 79ad62c700 🔧(git) ignore personal rules.md file
Allow developers to keep personal rules.md files without committing them
2026-01-23 14:16:34 +01:00
Cyril 90e432ac68 ️(frontend) add visible focus ring on restore focus
Show focus ring when restoring focus, keyboard navigation only
2026-01-23 14:16:34 +01:00
lebaudantoine 99a18b6e90 🩹(backend) use case-insensitive email matching in the external api
Fix a minor issue in the external API where users were matched using
case-sensitive email comparison, while authentication treats emails as
case-insensitive. This caused inconsistencies that are now resolved.

Spotted by T. Lemeur from Centrale.
2026-01-20 20:50:13 +01:00
Cyril 250e599465 📝(frontend) align close dialog label in rooms locale
keep close label consistent with global wording
2026-01-20 12:39:03 +01:00
Cyril 144a4e1b85 ️(frontend) improve background effect announcements
ensure sr announces clear and virtual background state
2026-01-20 12:34:32 +01:00
Cyril 78ab3cdbdf ️(frontend) improve aria-label with accessible emoji description
replace raw emoji with descriptive label to enhance screen reader support
2026-01-19 23:35:18 +01:00
Cyril a815d6c00d 📝(docs) add changelog file to document project changes
helps track notable changes and improvements over time
2026-01-19 23:35:18 +01:00
Cyril dfbc3a9d17 💄(frontend) add globally available sr-only utility class
provides reusable hidden style for screen reader-only content
2026-01-19 23:35:18 +01:00
Cyril 086db3d089 📝(frontend) update a11y store labels and link for clarity
improves naming and navigation for better user understanding of options
2026-01-19 23:35:18 +01:00
Cyril 014ef3d804 (frontend) create a11y store to manage user option toggles
sets up state handling for enabling or disabling a11y preferences
2026-01-19 23:35:18 +01:00
Cyril de3e1a56a8 (frontend) add placeholder for accessibility menu in settings panel
prepares UI for future accessibility options without implementing logic yet
2026-01-19 23:35:18 +01:00
Cyril 459749b992 (frontend) getEmojiLabel util for accessible emoji labeling across app
centralizes emoji label logic to ensure consistency and reuse in UI components
2026-01-19 23:35:18 +01:00
Cyril e1450329f2 ️(frontend) add screen reader announcements for reactions interactions
ensures users get feedback when adding reactions via assistive tech
2026-01-19 23:35:18 +01:00
Cyril c7e3194331 ️(frontend) announce copy state in invite dialog
improves screen reader feedback after copying the link
2026-01-19 22:55:47 +01:00
Cyril 902b005f32 ️(frontend) improve contrast for selected options
add dark inner border to enhance visibility and accessibility
2026-01-19 22:28:46 +01:00
Cyril 51d22783b2 ️(frontend) make carousel image decorative
avoid screen reader announcing redundant visual content
2026-01-19 18:29:25 +01:00
blipp 76f80a0f2f Fix k8s link in Docker Compose installation guide 2026-01-19 18:29:25 +01:00
Cyril 82eb930200 📝(docs) update changelog
document the latest change in the project history
2026-01-19 18:29:25 +01:00
Cyril eeeb950e08 ️(frontend) improve participants toggle a11y label
avoid screen reader duplication by using visual-only tooltip
2026-01-19 18:29:19 +01:00
Cyril cb77688572 ️(frontend) add accessible back button in side panel
label the back button and separate it from the heading for a11y
2026-01-19 15:14:25 +01:00
lebaudantoine f9524b2f0a 🔒️(backend) prevent automatic upgrade setuptools
The latest `setuptools` version pulls in a `jaraco.context` version that
triggers a Trivy scan failure. `jaraco.context` has a path traversal
vulnerability.

This fix is inspired by suitenumerique/people, specifically Marie’s PR #1010.
2026-01-19 14:16:00 +01:00
56 changed files with 1063 additions and 203 deletions
+3
View File
@@ -77,6 +77,9 @@ db.sqlite3
*.iml
.devcontainer
# Personal rules/config files
rules.md
# Egress output
docker/livekit/out
+17 -2
View File
@@ -7,10 +7,25 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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
- ♿️(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
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
@@ -27,7 +42,7 @@ and this project adheres to
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
+1 -1
View File
@@ -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
+6 -2
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.validators import validate_email
import jwt
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email=email)
user = models.User.objects.get(email__iexact=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
"scope": "user.family@example.com",
},
format="json",
)
+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 })
},
})
```
@@ -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>
@@ -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"
>
@@ -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"
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiAdminLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
@@ -5,6 +6,7 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const AdminToggle = ({
variant = 'primaryTextDark',
@@ -14,10 +16,17 @@ export const AdminToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
const { isAdminOpen, toggleAdmin } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('admin', el)
},
[setTrigger]
)
const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return
if (!hasAdminAccess) return null
return (
<div
@@ -32,6 +41,7 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -10,8 +10,23 @@ import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
const useFocusOnOpen = ({ ref, key }) => {
useEffect(() => {
requestAnimationFrame(() => {
const panel = ref.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(key)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
}, [])
}
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const panelRef = useRef()
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
@@ -24,6 +39,8 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
useFocusOnOpen({ ref: panelRef, key: '[data-attr="copy-info-sidepanel"]' })
return (
<Div
display="flex"
@@ -32,6 +49,7 @@ export const Info = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
ref={panelRef}
>
<VStack alignItems="start">
<Text
@@ -71,7 +89,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',
}}
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -140,11 +143,53 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -13,6 +13,9 @@ 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 = {
title: string
@@ -23,6 +26,8 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
}
const StyledSidePanel = ({
@@ -34,8 +39,11 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
ref={panelRef}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -63,31 +71,34 @@ const StyledSidePanel = ({
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
<HStack alignItems="center">
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem' })}
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
onPress={onBack}
>
<RiArrowLeftLine size={20} />
<RiArrowLeftLine size={20} aria-hidden="true" />
</Button>
)}
{title}
</Heading>
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
<Div
position="absolute"
top="5"
@@ -129,8 +140,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const SidePanelContent = () => {
const {
activePanelId,
isParticipantsOpen,
@@ -144,6 +154,23 @@ export const SidePanel = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return (
<StyledSidePanel
@@ -158,12 +185,16 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
<Panel isOpen={isParticipantsOpen}>
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
revisit if memory becomes a concern */}
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen}>
<Panel isOpen={isEffectsOpen} keepAlive={true}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen} keepAlive={true}>
@@ -172,7 +203,7 @@ export const SidePanel = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
<Panel isOpen={isAdminOpen} keepAlive={true}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
@@ -181,3 +212,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 { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -29,6 +30,8 @@ export const AudioDevicesControl = ({
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -55,6 +58,12 @@ export const AudioDevicesControl = ({
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -76,7 +85,12 @@ export const AudioDevicesControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
@@ -92,6 +106,7 @@ export const AudioDevicesControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -100,6 +115,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 { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
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 { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
return (
<Button
size="sm"
@@ -28,6 +31,7 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={(el) => setTrigger('effects', el)}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -50,6 +54,9 @@ export const VideoDeviceControl = ({
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { setTrigger } = useSidePanelTriggers()
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -99,6 +106,12 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -120,12 +133,18 @@ export const VideoDeviceControl = ({
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={(el) => setTrigger('cameraMenu', el)}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -136,6 +155,7 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -144,6 +164,7 @@ export const VideoDeviceControl = ({
})}
>
<div
data-attr="video-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -1,9 +1,11 @@
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const InfoToggle = ({
onPress,
@@ -12,8 +14,18 @@ export const InfoToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
// const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const wip = useRef<HTMLElement | null>()
// const setInfoTriggerRef = useCallback(
// (el: HTMLElement | null) => {
// setTrigger('info', el)
// },
// [setTrigger]
// )
return (
<div
className={css({
@@ -27,8 +39,9 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
ref={wip}
onPress={(e) => {
toggleInfo()
toggleInfo(wip)
onPress?.(e)
}}
data-attr={`controls-info-${tooltipLabel}`}
@@ -1,10 +1,19 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const { setTrigger } = useSidePanelTriggers()
const setOptionsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('options', el)
},
[setTrigger]
)
return (
<Menu variant="dark">
@@ -14,6 +23,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">
@@ -1,10 +1,13 @@
import { useCallback } from 'react'
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 { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
export const ParticipantsToggle = ({
onPress,
@@ -19,10 +22,19 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
const setParticipantsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('participants', el)
},
[setTrigger]
)
return (
<div
@@ -31,21 +43,25 @@ 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}
ref={setParticipantsTriggerRef}
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 { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -36,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)
@@ -101,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
@@ -113,6 +132,7 @@ export const ReactionsToggle = () => {
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
@@ -145,7 +165,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
@@ -1,9 +1,11 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -13,7 +15,14 @@ export const ToolsToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isToolsOpen, toggleTools } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('tools', el)
},
[setTrigger]
)
return (
<div
@@ -28,6 +37,7 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -5,14 +5,48 @@ 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
}
// 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({
@@ -56,11 +56,11 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -89,27 +89,27 @@ export const EffectsConfiguration = ({
useEffect(
() => () => {
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceBlurStatusMessage = (message: string) => {
blurAnnouncementId.current += 1
const currentId = blurAnnouncementId.current
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setBlurStatusMessage('')
setEffectStatusMessage('')
blurAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== blurAnnouncementId.current) return
setBlurStatusMessage(message)
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
@@ -118,25 +118,42 @@ export const EffectsConfiguration = ({
onSubmit?.(undefined)
}
const updateBlurStatusMessage = (
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (type !== ProcessorType.BLUR) return
let message = ''
if (wasSelectedBeforeToggle) {
message = t('blur.status.none')
} else if (options.blurRadius === BlurRadius.LIGHT) {
message = t('blur.status.light')
} else if (options.blurRadius === BlurRadius.NORMAL) {
message = t('blur.status.strong')
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (message) {
announceBlurStatusMessage(message)
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
@@ -200,7 +217,7 @@ export const EffectsConfiguration = ({
onSubmit?.(processor)
}
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -407,7 +424,7 @@ export const EffectsConfiguration = ({
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{blurStatusMessage}
{effectStatusMessage}
</div>
</div>
<div
@@ -0,0 +1,29 @@
import { useRef, ReactNode } from 'react'
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
const panelRef = useRef<HTMLElement>(null)
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
triggersRef.current[key] = el
}
const getTrigger = (key: SidePanelTriggerKey) => {
return triggersRef.current[key] ?? null
}
return (
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
{children}
</SidePanelContext.Provider>
)
}
@@ -0,0 +1,20 @@
import { createContext } from 'react'
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement>
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
}
export const SidePanelContext = createContext<SidePanelContextValue | null>(
null
)
@@ -1,5 +1,6 @@
import { useSnapshot } from 'valtio'
import { ref, useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect } from 'react'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -56,8 +57,13 @@ export const useSidePanel = () => {
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
const toggleInfo = (wip) => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (!isInfoOpen) {
layoutStore.genericRef = ref(wip)
}
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
@@ -71,6 +77,18 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS
}
useEffect(() => {
if (!layoutSnap.activePanelId) {
console.log('$$prout closing')
const trigger = layoutSnap?.genericRef?.current
console.log(trigger)
if (trigger) {
trigger.focus({ preventScroll: true })
trigger.setAttribute('data-restore-focus-visible', '')
}
}
}, [layoutSnap.activePanelId, layoutSnap?.genericRef])
return {
activePanelId,
activeSubPanelId,
@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
export const useSidePanelRef = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error('useSidePanelRef must be used within SidePanelProvider')
}
return context.panelRef
}
@@ -0,0 +1,15 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
export const useSidePanelTriggers = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error(
'useSidePanelTriggers must be used within SidePanelProvider'
)
}
return {
setTrigger: context.setTrigger,
getTrigger: context.getTrigger,
}
}
@@ -26,6 +26,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { SidePanelProvider } from '../contexts/SidePanelContext'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
@@ -188,75 +189,77 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<SidePanelProvider>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</SidePanelProvider>
</LayoutContextProvider>
)}
<RoomAudioRenderer />
@@ -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
}
@@ -11,6 +11,7 @@ import {
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -22,6 +23,7 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -112,6 +114,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -125,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
</Dialog>
@@ -0,0 +1,40 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
}
+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,
+3 -6
View File
@@ -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 -2
View File
@@ -86,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:"
@@ -192,7 +193,9 @@
"leave": "Verlassen",
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen"
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -206,7 +209,18 @@
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"you": "Sie"
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
}
},
"options": {
@@ -277,6 +291,7 @@
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
+3 -6
View File
@@ -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 -2
View File
@@ -86,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:"
@@ -192,7 +193,9 @@
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone"
"closed": "See everyone",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
},
"tools": {
"open": "Hide more tools",
@@ -206,7 +209,18 @@
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"you": "you"
"announce": "{{name}} : {{emoji}}",
"you": "you",
"emojis": {
"thumbs-up": "thumbs up",
"thumbs-down": "thumbs down",
"clapping-hands": "clapping hands",
"red-heart": "red heart",
"face-with-tears-of-joy": "face with tears of joy",
"face-with-open-mouth": "surprised face",
"party-popper": "party popper",
"folded-hands": "folded hands"
}
}
},
"options": {
@@ -277,6 +291,7 @@
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
"effects": "Backgrounds and Effects",
@@ -108,12 +108,18 @@
"label": "Language"
},
"settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription"
}
}
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
}
}
}
+17 -2
View File
@@ -86,6 +86,7 @@
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
@@ -192,7 +193,9 @@
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants"
"closed": "Afficher les participants",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
},
"tools": {
"open": "Masquer les outils de réunion",
@@ -206,7 +209,18 @@
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"you": "vous"
"announce": "{{name}} : {{emoji}}",
"you": "vous",
"emojis": {
"thumbs-up": "pouce levé",
"thumbs-down": "pouce baissé",
"clapping-hands": "applaudissements",
"red-heart": "cœur",
"face-with-tears-of-joy": "visage qui rit",
"face-with-open-mouth": "visage étonné",
"party-popper": "confettis",
"folded-hands": "mains jointes"
}
}
},
"options": {
@@ -277,6 +291,7 @@
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
"effects": "Arrière-plans et effets",
@@ -108,12 +108,18 @@
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres",
"accessibility": {
"announceReactions": {
"label": "Vocaliser les réactions"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription"
}
}
+3 -6
View File
@@ -44,18 +44,15 @@
},
"slide1": {
"title": "Stap over op eenvoud. Probeer ons nu!",
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
},
"slide2": {
"title": "Houdt groepsgesprekken zonder limieten",
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
},
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
}
}
}
+17 -2
View File
@@ -86,6 +86,7 @@
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
@@ -192,7 +193,9 @@
"leave": "Vertrekken",
"participants": {
"open": "Verberg iedereen",
"closed": "Toon iedereen"
"closed": "Toon iedereen",
"count_one": "{{count}} deelnemer",
"count_other": "{{count}} deelnemers"
},
"tools": {
"open": "Meer tools verbergen",
@@ -206,7 +209,18 @@
"reactions": {
"button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}",
"you": "U"
"announce": "{{name}} : {{emoji}}",
"you": "U",
"emojis": {
"thumbs-up": "duim omhoog",
"thumbs-down": "duim omlaag",
"clapping-hands": "applaudisserende handen",
"red-heart": "rood hart",
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
"face-with-open-mouth": "verrast gezicht",
"party-popper": "feestknaller",
"folded-hands": "gevouwen handen"
}
}
},
"options": {
@@ -277,6 +291,7 @@
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
+11 -1
View File
@@ -70,6 +70,9 @@ export const Popover = ({
children,
variant = 'light',
withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
...dialogProps
}: {
children: [
@@ -80,10 +83,17 @@ export const Popover = ({
]
variant?: 'dark' | 'light'
withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
<DialogTrigger
isOpen={isOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
{trigger}
<StyledPopover>
{withArrow && (
+2 -1
View File
@@ -112,7 +112,8 @@ export const buttonRecipe = cva({
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
boxShadow:
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
},
'&[data-disabled]': {
backgroundColor: 'greyscale.100',
+76
View File
@@ -0,0 +1,76 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
type AccessibilityState = {
announceReactions: boolean
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
}
function getAccessibilityState(): AccessibilityState {
try {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
announceReactions:
typeof parsed.announceReactions === 'boolean'
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
}
}
// Legacy migration: if the setting was previously stored in notifications
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (legacy) {
try {
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
if (typeof parsedLegacy?.announceReactions === 'boolean') {
const migratedState: AccessibilityState = {
...DEFAULT_STATE,
...parsedLegacy,
announceReactions: parsedLegacy.announceReactions,
}
try {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(migratedState)
)
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
} catch {
// ignore persistence issues during migration
}
return migratedState
}
} catch {
// ignore legacy parsing issues
}
}
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const accessibilityStore = proxy<AccessibilityState>(
getAccessibilityState()
)
subscribe(accessibilityStore, () => {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(accessibilityStore)
)
})
+2
View File
@@ -10,6 +10,7 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
genericRef: HTMLElement | null
}
export const layoutStore = proxy<State>({
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
genericRef: null,
})
+14
View File
@@ -0,0 +1,14 @@
import { css } from '@/styled-system/css'
// Reusable visually hidden style for screen-reader-only content
export const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
})
+12 -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;
}
+1
View File
@@ -4,4 +4,5 @@
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
ACCESSIBILITY: 'app_accessibility_settings',
} as const