Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 61f286cc8b 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:39:46 +01:00
83 changed files with 161 additions and 2150 deletions
-25
View File
@@ -10,29 +10,6 @@ and this project adheres to
### Changed
- ♿️(frontend) fix form labels and autocomplete wiring #932
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
@@ -47,7 +24,6 @@ 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
@@ -56,7 +32,6 @@ and this project adheres to
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
- ✨(frontend) add keyboard shortcuts display
### Changed
+1 -4
View File
@@ -50,9 +50,6 @@ 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
@@ -89,7 +86,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 |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [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. |
## Contributing
@@ -3,8 +3,3 @@ 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
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
+2 -2
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment:
```bash
mkdir -p keycloak/env.d && cd keycloak
mkdir 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.
+3 -12
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](../../src/helm/meet/README.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.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 following:
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
@@ -177,15 +177,6 @@ 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 !
@@ -207,7 +198,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.5.0"
version = "1.3.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.ViewSet):
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
-3
View File
@@ -336,9 +336,6 @@ 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.5.0"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.5.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.5.0",
"version": "1.3.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.5.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
-1
View File
@@ -17,7 +17,6 @@ export interface ApiConfig {
feedback: {
url: string
}
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
+1 -27
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, useEffect, useState } from 'react'
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,7 +19,6 @@ 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 (
@@ -156,34 +155,9 @@ 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, useEffect } from 'react'
import { useMemo, useRef, useState, useEffect } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -13,7 +13,6 @@ 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', {
@@ -22,8 +21,8 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const announce = useScreenReaderAnnounce()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -77,9 +76,16 @@ export const RecordingStateToast = () => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
announce(message)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [announce, key, t])
}, [key, t])
if (!key) return null
@@ -89,6 +95,15 @@ 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,12 +104,14 @@ export const RecordingDownload = () => {
{t('success.title')}
</H>
<Text centered margin="md" wrap={'balance'}>
<span>
{t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
})}
</span>
<span
dangerouslySetInnerHTML={{
__html: t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
}),
}}
/>
<span>
{configData?.recording?.expiration_days && (
<>
@@ -446,14 +446,16 @@ export const Join = ({
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
aria-label={t('usernameLabel')}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
@@ -1,26 +1,21 @@
import { Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { Button, Dialog, H, P } 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, useRef, useState } from 'react'
import { useEffect, 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) {
@@ -40,42 +35,10 @@ export const IsIdleDisconnectModal = () => {
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
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 minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 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}
@@ -89,7 +52,6 @@ export const IsIdleDisconnectModal = () => {
{({ close }) => {
return (
<div>
<ScreenReaderAnnouncer channel="idle" />
<div
className={css({
height: '50px',
@@ -103,7 +65,6 @@ export const IsIdleDisconnectModal = () => {
color: 'blue.800',
margin: 'auto',
})}
aria-hidden="true"
>
{formattedTime}
</div>
@@ -31,16 +31,7 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { ShortcutHelpTooltip } from './ShortcutHelpTooltip'
import { isMacintosh } from '@/utils/livekit'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
import { useSnapshot } from 'valtio'
import { getShortcutById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { participantLayoutStore } from '@/stores/participantLayout'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -118,26 +109,6 @@ export const ParticipantTile: (
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
loadShortcutOverrides()
const { overrides } = useSnapshot(shortcutOverridesStore)
const openShortcutDefault = getShortcutById('open-shortcuts')?.shortcut
const openShortcutEffective =
overrides.get('open-shortcuts') ?? openShortcutDefault
const openShortcutLabel =
formatShortcutLabel(openShortcutEffective) ??
`${isMacintosh() ? '⌘' : 'Ctrl'} + /`
// Check if we should show the shortcut helper tooltip
// Only show on the first tile when in grid layout (not in carousel/left column, not in focus mode)
const { layoutType, firstGridTileTrackId } = useSnapshot(participantLayoutStore)
const currentTrackId = isTrackReference(trackReference)
? `${trackReference.participant.sid}-${trackReference.source}`
: null
const shouldShowShortcutHelper =
hasKeyboardFocus &&
layoutType === 'grid' && // Only show in grid layout
currentTrackId === firstGridTileTrackId // Only show on first tile
const interactiveProps = {
...elementProps,
@@ -258,13 +229,8 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{shouldShowShortcutHelper && (
<ShortcutHelpTooltip
triggerLabel={t('toolbarHint', {
binding: openShortcutLabel,
})}
isVisible={hasKeyboardFocus}
/>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
@@ -8,7 +8,6 @@ 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
@@ -147,14 +146,15 @@ 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,9 +166,12 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
@@ -179,6 +182,14 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
@@ -1,211 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { css } from '@/styled-system/css'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { ShortcutCategorySection } from '@/features/shortcuts/components'
import { useFocusTrap } from '@/features/shortcuts/useFocusTrap'
import {
closeShortcutHelp,
shortcutHelpStore,
toggleShortcutHelp,
} from '@/stores/shortcutHelp'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'react-i18next'
import { Shortcut } from '@/features/shortcuts/types'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
type ShortcutHelpTooltipProps = {
triggerLabel: string
isVisible?: boolean
}
const containerStyle = css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
backgroundColor: 'rgba(0,0,0,0.65)',
paddingInline: '0.5rem',
paddingBlock: '0.25rem',
fontSize: '0.875rem',
minWidth: '200px',
maxWidth: '320px',
boxShadow: '0 4px 10px rgba(0,0,0,0.25)',
zIndex: 10,
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'&[data-visible="true"]': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
})
const triggerStyle = css({
all: 'unset',
cursor: 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
color: 'white',
fontWeight: 600,
gap: '0.5rem',
outline: 'none',
'&:focus-visible': {
outline: '2px solid rgba(255,255,255,0.5)',
outlineOffset: '2px',
borderRadius: '6px',
},
})
const panelStyle = css({
overflow: 'hidden',
maxHeight: '0px',
opacity: 0,
marginTop: '0',
transition:
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
'&[data-open="true"]': {
maxHeight: '400px',
opacity: 1,
marginTop: '0.35rem',
overflowY: 'auto',
paddingRight: '0.25rem',
},
})
const emptyStateStyle = css({
fontSize: '0.85rem',
opacity: 0.8,
})
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
triggerLabel,
isVisible = false,
}) => {
const { t } = useTranslation('rooms')
const { isOpen } = useSnapshot(shortcutHelpStore)
loadShortcutOverrides()
const { overrides } = useSnapshot(shortcutOverridesStore)
const isContainerVisible = isVisible || isOpen
const triggerRef = useRef<HTMLButtonElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
const wasOpenRef = useRef(false)
useFocusTrap(panelRef, { isActive: isOpen, fallbackRef: panelRef })
const grouped = useMemo(() => {
return shortcutCatalog.reduce<
Record<
string,
{
item: (typeof shortcutCatalog)[number]
effective: Shortcut | undefined
}[]
>
>((acc, item) => {
const effective = overrides.get(item.id) ?? item.shortcut
acc[item.category] = acc[item.category] || []
acc[item.category].push({ item, effective })
return acc
}, {})
}, [overrides])
const entries = Object.entries(grouped)
const hasItems = entries.length > 0
const getCategoryLabel = useCallback(
(category: string) => t(`shortcutsPanel.categories.${category}`),
[t]
)
const getActionLabel = useCallback(
(id: string) => t(`shortcutsPanel.actions.${id}`),
[t]
)
const handleToggle = useCallback(() => {
toggleShortcutHelp()
}, [])
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeShortcutHelp()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [isOpen])
useEffect(() => {
if (isOpen) {
panelRef.current?.focus()
} else if (wasOpenRef.current && !isOpen && triggerRef.current) {
triggerRef.current.focus()
}
wasOpenRef.current = isOpen
}, [isOpen])
return (
<div data-visible={isContainerVisible} className={containerStyle}>
<button
type="button"
aria-expanded={isOpen}
aria-controls="shortcut-help-panel"
onClick={handleToggle}
ref={triggerRef}
className={triggerStyle}
>
<span>{triggerLabel}</span>
<span
aria-hidden
className={css({
fontSize: '0.75rem',
opacity: 0.8,
})}
>
{isOpen ? '▾' : '▸'}
</span>
</button>
<div
id="shortcut-help-panel"
role="dialog"
aria-modal="false"
aria-labelledby="shortcut-help-title"
data-open={isOpen}
ref={panelRef}
tabIndex={-1}
className={panelStyle}
>
<h2 id="shortcut-help-title" className="sr-only">
{t('shortcutsPanel.title')}
</h2>
{hasItems ? (
entries.map(([category, items]) => (
<ShortcutCategorySection
key={category}
category={category}
items={items}
getCategoryLabel={getCategoryLabel}
getActionLabel={getActionLabel}
/>
))
) : (
<div className={emptyStateStyle}>
{t('shortcutsPanel.noShortcuts')}
</div>
)}
</div>
</div>
)
}
@@ -6,7 +6,6 @@ import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ChatToggle = ({
onPress,
@@ -19,11 +18,6 @@ export const ChatToggle = ({
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
shortcutId: 'toggle-chat',
handler: async () => toggleChat(),
})
return (
<div
className={css({
@@ -1,10 +1,7 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import {
appendShortcutLabel,
getEffectiveShortcut,
} from '@/features/shortcuts/utils'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
@@ -19,10 +16,8 @@ import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
import { getShortcutById } from '@/features/shortcuts/catalog'
import { useSnapshot } from 'valtio'
import { shortcutOverridesStore } from '@/stores/shortcutOverrides'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -90,12 +85,10 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const shortcutId =
kind === 'audioinput' ? 'toggle-microphone' : 'toggle-camera'
const { overrides } = useSnapshot(shortcutOverridesStore)
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
shortcutId,
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
@@ -106,13 +99,6 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled: cannotUseDevice,
})
const deviceShortcut = useMemo(() => {
const override = overrides.get(shortcutId)
if (override) return override
const catalogItem = getShortcutById(shortcutId)
return catalogItem?.shortcut
}, [shortcutId, overrides])
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
@@ -17,7 +17,6 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -100,14 +99,6 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useRegisterKeyboardShortcut({
shortcut: { key: 'V', ctrlKey: true, shiftKey: true },
handler: async () => {
if (!canPublishTrack || cannotUseDevice) return
await toggleWithProcessor()
},
})
return (
<div
className={css({
@@ -9,7 +9,6 @@ import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const SPEAKING_DETECTION_DELAY = 3000
@@ -29,19 +28,6 @@ export const HandToggle = () => {
setHasShownToast(false)
}
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
shortcutId: 'raise-hand',
handler: async () => {
toggleRaisedHand()
resetToastState()
},
})
useEffect(() => {
if (isHandRaised) return
closeLowerHandToasts()
@@ -82,7 +68,10 @@ export const HandToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={handleToggle}
onPress={() => {
toggleRaisedHand()
resetToastState()
}}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -6,7 +6,6 @@ import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ParticipantsToggle = ({
onPress,
@@ -28,11 +27,6 @@ export const ParticipantsToggle = ({
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
shortcutId: 'toggle-participants',
handler: async () => toggleParticipants(),
})
return (
<div
className={css({
@@ -14,7 +14,6 @@ import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -42,11 +41,6 @@ export const ReactionsToggle = () => {
const [isVisible, setIsVisible] = useState(false)
useRegisterKeyboardShortcut({
shortcutId: 'reaction',
handler: async () => setIsVisible((prev) => !prev),
})
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
@@ -18,7 +18,6 @@ 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,
@@ -57,7 +56,7 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const announce = useScreenReaderAnnounce()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
@@ -105,9 +104,12 @@ 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
announce(message)
setEffectStatusMessage(message)
}, 80)
}
@@ -421,6 +423,9 @@ export const EffectsConfiguration = ({
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
</div>
<div
className={css({
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { setCarouselLayout, resetLayout } from '@/stores/participantLayout'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -80,15 +79,6 @@ export function CarouselLayout({
}
}, [maxVisibleTiles, carouselOrientation])
// Update store when carousel layout is active
React.useEffect(() => {
setCarouselLayout()
return () => {
resetLayout()
}
}, [])
return (
<aside
key={carouselOrientation}
@@ -10,8 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from '../controls/PaginationIndicator'
import { useGridLayout } from '../../hooks/useGridLayout'
import { PaginationControl } from '../controls/PaginationControl'
import { setGridLayout, resetLayout } from '@/stores/participantLayout'
import { isTrackReference } from '@livekit/components-core'
/** @public */
export interface GridLayoutProps
@@ -37,11 +35,6 @@ export interface GridLayoutProps
* ```
* @public
*/
const getTrackId = (trackRef: TrackReferenceOrPlaceholder): string | null => {
if (!isTrackReference(trackRef)) return null
return `${trackRef.participant.sid}-${trackRef.source}`
}
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = React.createRef<HTMLDivElement>()
@@ -57,17 +50,6 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
onRightSwipe: pagination.prevPage,
})
// Update store when tracks change - set first tile ID for grid layout
React.useEffect(() => {
const firstTrack = pagination.tracks[0]
const firstTrackId = firstTrack ? getTrackId(firstTrack) : null
setGridLayout(firstTrackId)
return () => {
resetLayout()
}
}, [pagination.tracks])
return (
<div
ref={gridEl}
@@ -12,33 +12,17 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { openShortcutHelp } from '@/stores/shortcutHelp'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useFullScreen } from '../../hooks/useFullScreen'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
export function DesktopControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const { toggleParticipants, toggleChat, openScreenRecording } = useSidePanel()
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
const { openSettingsDialog } = useSettingsDialog()
useRegisterKeyboardShortcut({
shortcutId: 'open-shortcuts',
handler: () => {
openShortcutHelp()
},
})
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
useRegisterKeyboardShortcut({
shortcutId: 'focus-toolbar',
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -48,34 +32,6 @@ export function DesktopControlBar({
firstButton?.focus()
},
})
useRegisterKeyboardShortcut({
shortcutId: 'toggle-participants',
handler: () => toggleParticipants(),
})
useRegisterKeyboardShortcut({
shortcutId: 'toggle-chat',
handler: () => toggleChat(),
})
useRegisterKeyboardShortcut({
shortcutId: 'fullscreen',
handler: () => {
if (!isFullscreenAvailable) return
toggleFullScreen()
},
})
useRegisterKeyboardShortcut({
shortcutId: 'recording',
handler: () => openScreenRecording(),
})
useRegisterKeyboardShortcut({
shortcutId: 'open-shortcuts-settings',
handler: () => openSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS),
})
return (
<div
ref={desktopControlBarEl}
@@ -5,8 +5,9 @@ import {
isWeb,
log,
} from '@livekit/components-core'
import { Participant, RoomEvent, Track } from 'livekit-client'
import React, { useCallback, useRef, useState, useEffect } from 'react'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import { useState } from 'react'
import {
ConnectionStateToast,
FocusLayoutContainer,
@@ -15,9 +16,7 @@ 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'
@@ -38,8 +37,6 @@ 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',
@@ -92,22 +89,7 @@ export interface VideoConferenceProps
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack =
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]
)
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
@@ -133,62 +115,9 @@ 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.
useEffect(() => {
React.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) &&
@@ -7,7 +7,6 @@ import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
RiAccountCircleLine,
RiKeyboardBoxLine,
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
@@ -24,7 +23,6 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import ShortcutTab from './tabs/ShortcutTab'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
@@ -109,10 +107,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<RiKeyboardBoxLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
@@ -136,7 +130,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
@@ -1,196 +0,0 @@
/**
* Edit and reset feature, not used yet
*
* This component handles edit and reset actions for keyboard shortcuts.
* To use it, uncomment the import and usage in ShortcutTab.tsx
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { buttonRecipe } from '@/primitives/buttonRecipe'
import { removeOverride, setOverride } from '@/stores/shortcutOverrides'
import { isMacintosh } from '@/utils/livekit'
const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' })
export interface ShortcutEditActionsProps {
shortcutId: string
actionLabel: string
srShortcut: string
hasOverride: boolean
onConfirmationChange?: (message: string) => void
}
export const ShortcutEditActions = ({
shortcutId,
actionLabel,
srShortcut,
hasOverride,
onConfirmationChange,
}: ShortcutEditActionsProps) => {
const { t } = useTranslation(['settings'])
const [editingId, setEditingId] = useState<string | null>(null)
const timeoutRef = useRef<number | null>(null)
const handleStartEdit = useCallback(() => {
setEditingId(shortcutId)
}, [shortcutId])
const handleCancelEdit = useCallback(() => {
setEditingId(null)
}, [])
const handleEditButtonClick = useCallback(() => {
// If already in edit mode, cancel it
if (editingId === shortcutId) {
handleCancelEdit()
return
}
// Otherwise, start edit mode
handleStartEdit()
}, [editingId, shortcutId, handleCancelEdit, handleStartEdit])
const handleReset = useCallback(() => {
removeOverride(shortcutId)
const message = t('shortcutsEditor.resetConfirmation', {
defaultValue: 'Shortcut reset',
})
onConfirmationChange?.(message)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onConfirmationChange?.('')
timeoutRef.current = null
}, 3000)
}, [shortcutId, t, onConfirmationChange])
const handleKeyCapture = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault()
const { key, ctrlKey, shiftKey, altKey, metaKey } = e
// Ignore modifier-only keys
if (
!key ||
key === 'Control' ||
key === 'Meta' ||
key === 'Shift' ||
key === 'Alt' ||
key === 'Tab' ||
key === 'Escape'
)
return
const normalized: Shortcut = {
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
}
setOverride(shortcutId, normalized)
setEditingId(null)
const message = t('shortcutsEditor.modifiedConfirmation', {
defaultValue: 'Shortcut modified',
})
onConfirmationChange?.(message)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
onConfirmationChange?.('')
timeoutRef.current = null
}, 3000)
},
[shortcutId, t, onConfirmationChange]
)
const handleEditButtonKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
// If already in edit mode, capture the key
if (editingId === shortcutId) {
handleKeyCapture(e)
return
}
// Otherwise, if it's Enter or Space, start edit mode (like a click)
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleStartEdit()
}
},
[editingId, shortcutId, handleKeyCapture, handleStartEdit]
)
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
const editButtonLabel =
editingId === shortcutId
? t('shortcutsEditor.capture')
: t('shortcutsEditor.edit')
const editButtonAriaLabel =
editingId === shortcutId
? t('shortcutsEditor.captureAria', {
defaultValue: 'Press keys to set shortcut for {{action}}',
action: actionLabel,
})
: t('shortcutsEditor.editAria', {
defaultValue: 'Edit shortcut for {{action}}',
action: actionLabel,
})
const resetButtonAriaLabel = t('shortcutsEditor.resetAria', {
defaultValue: 'Reset shortcut for {{action}}',
action: actionLabel,
})
return (
<div
role="group"
aria-label={t('shortcutsEditor.actionsGroupAria', {
defaultValue: 'Actions for {{action}}',
action: actionLabel,
})}
className={css({
display: 'flex',
gap: '0.35rem',
justifyContent: 'flex-end',
})}
>
<button
type="button"
className={buttonLink}
onKeyDown={handleEditButtonKeyDown}
onClick={handleEditButtonClick}
aria-pressed={editingId === shortcutId}
aria-label={editButtonAriaLabel}
aria-describedby={`shortcut-${shortcutId}-description`}
>
{editButtonLabel}
</button>
<button
type="button"
className={buttonLink}
onClick={handleReset}
aria-disabled={!hasOverride}
disabled={!hasOverride}
aria-label={resetButtonAriaLabel}
aria-describedby={`shortcut-${shortcutId}-description`}
style={{ opacity: !hasOverride ? 0.5 : 1 }}
>
{t('shortcutsEditor.reset')}
</button>
<span id={`shortcut-${shortcutId}-description`} className="sr-only">
{t('shortcutsEditor.currentShortcut', {
defaultValue: 'Current shortcut: {{shortcut}}',
shortcut: srShortcut,
})}
</span>
</div>
)
}
@@ -1,86 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { ShortcutRow } from '@/features/shortcuts/components'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { text } from '@/primitives/Text'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { useSnapshot } from 'valtio'
import {
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
const { t } = useTranslation(['settings', 'rooms'])
const tRooms = useCallback(
(key: string, options?: Record<string, unknown>) =>
t(key, { ns: 'rooms', ...options }),
[t]
)
useEffect(() => {
loadShortcutOverrides()
}, [])
const { overrides } = useSnapshot(shortcutOverridesStore)
const rows = useMemo(() => {
return shortcutCatalog.map((item) => {
const override = overrides.get(item.id)
const effectiveShortcut = override ?? item.shortcut
return {
item,
override,
effectiveShortcut,
}
})
}, [overrides])
return (
<TabPanel
id={id}
padding="md"
flex
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<div className={text({ variant: 'h2' })}>{t('tabs.shortcuts')}</div>
<div
role="list"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
aria-label={t('shortcutsEditor.listLabel', {
defaultValue: 'List of keyboard shortcuts',
})}
className={css({
display: 'grid',
gap: '0.25rem',
maxHeight: '420px',
overflowY: 'auto',
paddingRight: '0.35rem',
outline: 'none',
'&:focus-visible': {
outline: '2px solid rgba(255,255,255,0.5)',
outlineOffset: '2px',
borderRadius: '6px',
},
})}
>
{rows.map(({ item, override, effectiveShortcut }) => (
<ShortcutRow
key={item.id}
descriptor={item}
effectiveShortcut={effectiveShortcut}
override={override}
actionLabel={tRooms(`shortcutsPanel.actions.${item.id}`)}
customLabel={t('shortcutsEditor.custom')}
/>
))}
</div>
</TabPanel>
)
}
export default ShortcutTab
@@ -1,31 +0,0 @@
import { useMemo, useState } from 'react'
import { text } from '@/primitives/Text'
/**
* Hook to manage confirmation messages for edit/reset actions
* Use in the parent component to display confirmation messages
*/
export const useShortcutConfirmation = () => {
const [confirmationMessage, setConfirmationMessage] = useState<string>('')
const confirmationElement = useMemo(() => {
if (!confirmationMessage) return null
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={text({ variant: 'smNote' })}
>
{confirmationMessage}
</div>
)
}, [confirmationMessage])
return {
confirmationMessage,
setConfirmationMessage,
ConfirmationMessage: confirmationElement,
}
}
@@ -5,6 +5,5 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
}
@@ -1,96 +0,0 @@
import { Shortcut } from './types'
// Central list of current keyboard shortcuts. This will feed the future
// tooltip/panel so there is a single source of truth for display and, later,
// customization.
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
| 'reaction'
| 'fullscreen'
| 'recording'
| 'raise-hand'
| 'toggle-chat'
| 'toggle-participants'
| 'open-shortcuts-settings'
export const getShortcutById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
export type ShortcutDescriptor = {
id: ShortcutId
category: ShortcutCategory
shortcut?: Shortcut
kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'open-shortcuts',
category: 'navigation',
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
},
{
id: 'focus-toolbar',
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'toggle-microphone',
category: 'media',
shortcut: { key: 'd', ctrlKey: true },
},
{
id: 'toggle-camera',
category: 'media',
shortcut: { key: 'e', ctrlKey: true },
},
{
id: 'push-to-talk',
category: 'media',
kind: 'longPress',
code: 'KeyV',
},
{
id: 'reaction',
category: 'interaction',
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
},
{
id: 'fullscreen',
category: 'interaction',
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
},
{
id: 'recording',
category: 'interaction',
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
},
{
id: 'raise-hand',
category: 'interaction',
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-chat',
category: 'interaction',
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-participants',
category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
},
{
id: 'open-shortcuts-settings',
category: 'navigation',
shortcut: { key: 'K', ctrlKey: true, altKey: true },
},
]
@@ -1,48 +0,0 @@
import React from 'react'
import { css, cx } from '@/styled-system/css'
import { text } from '@/primitives/Text'
type ShortcutBadgeProps = {
visualLabel: string
isCustom?: boolean
customLabel?: string
srLabel?: string
className?: string
}
const badgeStyle = css({
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingInline: '0.4rem',
paddingBlock: '0.2rem',
borderRadius: '6px',
whiteSpace: 'nowrap',
minWidth: '5.5rem',
textAlign: 'center',
})
export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
visualLabel,
isCustom = false,
customLabel,
srLabel,
className,
}) => {
return (
<>
<div className={cx(badgeStyle, className)} aria-hidden="true">
<span aria-hidden="true">{visualLabel}</span>
{isCustom && customLabel && (
<span
className={text({ variant: 'smNote' })}
style={{ marginLeft: '0.4rem' }}
aria-hidden="true"
>
({customLabel})
</span>
)}
</div>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
}
@@ -1,96 +0,0 @@
import React from 'react'
import { css } from '@/styled-system/css'
import { ShortcutDescriptor } from '../catalog'
import { Shortcut } from '../types'
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
type ShortcutCategorySectionProps = {
category: string
items: Array<{ item: ShortcutDescriptor; effective: Shortcut | undefined }>
getCategoryLabel: (category: string) => string
getActionLabel: (id: string) => string
}
const sectionStyle = css({
display: 'flex',
flexDirection: 'column',
gap: '0.35rem',
mb: '0.5rem',
})
const headingStyle = css({
textTransform: 'capitalize',
fontSize: '0.75rem',
opacity: 0.8,
})
const listStyle = css({
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})
const listItemStyle = css({
display: 'flex',
justifyContent: 'space-between',
gap: '0.5rem',
alignItems: 'center',
fontSize: '0.85rem',
})
const badgeStyle = css({
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingInline: '0.35rem',
paddingBlock: '0.15rem',
borderRadius: '6px',
whiteSpace: 'nowrap',
})
export const ShortcutCategorySection: React.FC<
ShortcutCategorySectionProps
> = ({ category, items, getCategoryLabel, getActionLabel }) => {
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting()
return (
<section className={sectionStyle}>
<h3
id={`shortcut-section-${category}`}
data-shortcuts-heading
tabIndex={-1}
className={headingStyle}
>
{getCategoryLabel(category)}
</h3>
<ul
aria-labelledby={`shortcut-section-${category}`}
className={listStyle}
>
{items.map(({ item, effective }) => {
const visualShortcut = formatVisual(
effective,
item.code,
item.kind === 'longPress' ? getHoldTemplate('visual') : undefined
)
const srShortcut = formatForSR(effective, item.code)
const actionLabel = getActionLabel(item.id)
return (
<li key={item.id} className={listItemStyle}>
<span>
{actionLabel}
<span className="sr-only">, {srShortcut}</span>
</span>
<span aria-hidden className={badgeStyle}>
{visualShortcut}
</span>
</li>
)
})}
</ul>
</section>
)
}
@@ -1,56 +0,0 @@
import React from 'react'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text'
import { ShortcutDescriptor } from '../catalog'
import { Shortcut } from '../types'
import { ShortcutBadge } from './ShortcutBadge'
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
type ShortcutRowProps = {
descriptor: ShortcutDescriptor
effectiveShortcut?: Shortcut
override?: Shortcut
actionLabel: string
customLabel: string
}
const rowStyle = css({
display: 'grid',
gridTemplateColumns: '1.25fr auto',
alignItems: 'center',
gap: '0.75rem',
padding: '0.65rem 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
})
export const ShortcutRow: React.FC<ShortcutRowProps> = ({
descriptor,
effectiveShortcut,
override,
actionLabel,
customLabel,
}) => {
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting({
namespace: 'rooms',
})
const visualShortcut = formatVisual(
effectiveShortcut,
descriptor.code,
descriptor.kind === 'longPress' ? getHoldTemplate('visual') : undefined
)
const srShortcut = formatForSR(effectiveShortcut, descriptor.code)
const srCustomLabel = override ? ` (${customLabel})` : ''
return (
<div role="listitem" className={rowStyle}>
<div className={text({ variant: 'body' })}>{actionLabel}</div>
<ShortcutBadge
visualLabel={visualShortcut}
isCustom={!!override}
customLabel={customLabel}
srLabel={`${srShortcut}${srCustomLabel}`}
/>
</div>
)
}
@@ -1,3 +0,0 @@
export { ShortcutBadge } from './ShortcutBadge'
export { ShortcutRow } from './ShortcutRow'
export { ShortcutCategorySection } from './ShortcutCategorySection'
@@ -1,61 +0,0 @@
import { Shortcut } from './types'
import { isMacintosh } from '@/utils/livekit'
// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed).
export const formatShortcutLabel = (shortcut?: Shortcut) => {
if (!shortcut) return '—'
const key = shortcut.key?.toUpperCase()
if (!key) return '—'
const parts: string[] = []
if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl')
if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt')
if (shortcut.shiftKey) parts.push('Shift')
parts.push(key)
return parts.join('+')
}
// SR-friendly label for a shortcut (reads “Control plus D”).
export const formatShortcutLabelForSR = (
shortcut: Shortcut | undefined,
{
controlLabel,
commandLabel,
plusLabel,
noShortcutLabel,
}: {
controlLabel: string
commandLabel: string
plusLabel: string
noShortcutLabel: string
}
) => {
if (!shortcut) return noShortcutLabel
const key = shortcut.key?.toUpperCase()
if (!key) return noShortcutLabel
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
const parts: string[] = []
if (shortcut.ctrlKey) parts.push(ctrlWord)
if (shortcut.altKey) parts.push('Alt')
if (shortcut.shiftKey) parts.push('Shift')
parts.push(key)
return parts.join(` ${plusLabel} `)
}
// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V).
export const getKeyLabelFromCode = (code?: string) => {
if (!code) return ''
if (code.startsWith('Key') && code.length === 4) return code.slice(3)
if (code.startsWith('Digit') && code.length === 6) return code.slice(5)
if (code === 'Space') return '␣'
if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right
return code
}
// Long-press label (visual or SR), e.g. “Hold V”.
export const formatLongPressLabel = (
codeLabel: string,
holdTemplate: string
) => {
if (!codeLabel) return holdTemplate.replace('{{key}}', '?')
return holdTemplate.replace('{{key}}', codeLabel)
}
@@ -1 +0,0 @@
export { useShortcutFormatting } from './useShortcutFormatting'
@@ -1,63 +0,0 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Shortcut } from '../types'
import {
formatShortcutLabel,
formatShortcutLabelForSR,
formatLongPressLabel,
getKeyLabelFromCode,
} from '../formatLabels'
type UseShortcutFormattingOptions = {
namespace?: string
}
export const useShortcutFormatting = (
options: UseShortcutFormattingOptions = {}
) => {
const { namespace = 'rooms' } = options
const { t } = useTranslation(namespace)
const formatVisual = useCallback(
(shortcut?: Shortcut, code?: string, holdTemplate?: string) => {
if (code && holdTemplate) {
const label = getKeyLabelFromCode(code)
return formatLongPressLabel(label, holdTemplate)
}
return formatShortcutLabel(shortcut)
},
[]
)
const formatForSR = useCallback(
(shortcut?: Shortcut, code?: string) => {
if (code) {
const label = getKeyLabelFromCode(code)
return formatLongPressLabel(
label,
t('shortcutsPanel.sr.hold', { key: '{{key}}' })
)
}
return formatShortcutLabelForSR(shortcut, {
controlLabel: t('shortcutsPanel.sr.control'),
commandLabel: t('shortcutsPanel.sr.command'),
plusLabel: t('shortcutsPanel.sr.plus'),
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
})
},
[t]
)
const getHoldTemplate = useCallback(
(type: 'visual' | 'sr' = 'visual') => {
return t(`shortcutsPanel.${type}.hold`, { key: '{{key}}' })
},
[t]
)
return {
formatVisual,
formatForSR,
getHoldTemplate,
}
}
@@ -1,6 +1,4 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
}
@@ -1,55 +0,0 @@
import { RefObject, useEffect } from 'react'
type UseFocusTrapOptions = {
isActive: boolean
fallbackRef?: RefObject<HTMLElement>
}
const focusableSelector =
'button:not([disabled]):not([hidden]):not([aria-hidden="true"]), [href]:not([disabled]):not([hidden]):not([aria-hidden="true"]), input:not([disabled]):not([hidden]):not([aria-hidden="true"]), select:not([disabled]):not([hidden]):not([aria-hidden="true"]), textarea:not([disabled]):not([hidden]):not([aria-hidden="true"]), [tabindex]:not([tabindex="-1"]):not([disabled]):not([hidden]):not([aria-hidden="true"])'
// Adds a simple focus trap on the given container: Tab/Shift+Tab loop inside.
export const useFocusTrap = (
containerRef: RefObject<HTMLElement>,
{ isActive, fallbackRef }: UseFocusTrapOptions
) => {
useEffect(() => {
if (!isActive) return
const container = containerRef.current
if (!container) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const focusable =
container.querySelectorAll<HTMLElement>(focusableSelector)
const fallback = fallbackRef?.current ?? container
if (focusable.length === 0) {
e.preventDefault()
fallback.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement
if (e.shiftKey) {
if (active === first || active === container) {
e.preventDefault()
last.focus()
}
} else {
if (active === last) {
e.preventDefault()
first.focus()
}
}
}
container.addEventListener('keydown', handleKeyDown)
return () => {
container.removeEventListener('keydown', handleKeyDown)
}
}, [containerRef, fallbackRef, isActive])
}
@@ -3,23 +3,19 @@ import { useSnapshot } from 'valtio'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { isMacintosh } from '@/utils/livekit'
import { formatShortcutKey } from './utils'
import { loadShortcutOverrides } from '@/stores/shortcutOverrides'
export const useKeyboardShortcuts = () => {
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
useEffect(() => {
loadShortcutOverrides()
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = async (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
@@ -1,82 +1,26 @@
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import {
formatShortcutKey,
getEffectiveShortcut,
} from '@/features/shortcuts/utils'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { Shortcut } from '@/features/shortcuts/types'
import { ShortcutId, getShortcutById } from './catalog'
import {
getOverride,
loadShortcutOverrides,
shortcutOverridesStore,
} from '@/stores/shortcutOverrides'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
shortcutId?: ShortcutId
fallbackShortcut?: Shortcut
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
}
export const useRegisterKeyboardShortcut = ({
shortcut,
shortcutId,
fallbackShortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
loadShortcutOverrides()
const { overrides } = useSnapshot(shortcutOverridesStore)
const previousKeyRef = useRef<string | null>(null)
useEffect(() => {
let effectiveShortcut: Shortcut | undefined
if (shortcutId) {
// Try override first, then fallback to catalog default
effectiveShortcut = getOverride(shortcutId)
if (!effectiveShortcut) {
const catalogItem = getShortcutById(shortcutId)
effectiveShortcut = catalogItem?.shortcut
}
}
// Fallback to provided shortcuts if no shortcutId or catalog item found
effectiveShortcut = effectiveShortcut || fallbackShortcut || shortcut
if (!effectiveShortcut) {
// Clean up previous shortcut if exists
if (previousKeyRef.current) {
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
previousKeyRef.current = null
}
return
}
const formattedKey = formatShortcutKey(effectiveShortcut)
// Clean up previous shortcut if the key changed
if (previousKeyRef.current && previousKeyRef.current !== formattedKey) {
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
}
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
previousKeyRef.current = null
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
previousKeyRef.current = formattedKey
}
// Cleanup function: remove shortcut when component unmounts or dependencies change
return () => {
if (previousKeyRef.current) {
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
previousKeyRef.current = null
}
}
}, [handler, shortcutId, shortcut, fallbackShortcut, isDisabled, overrides])
}, [handler, shortcut, isDisabled])
}
+4 -36
View File
@@ -1,50 +1,18 @@
import { isMacintosh } from '@/utils/livekit'
import { Shortcut } from '@/features/shortcuts/types'
import { ShortcutId, ShortcutDescriptor } from './catalog'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
const parts = []
if (shortcut.ctrlKey) parts.push(CTRL)
if (shortcut.altKey) parts.push('alt')
if (shortcut.shiftKey) parts.push('shift')
parts.push(shortcut.key.toUpperCase())
return parts.join('+')
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
const parts: string[] = []
let formattedKeyLabel = shortcut.key.toLowerCase()
if (shortcut.ctrlKey) {
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
}
if (shortcut.altKey) {
parts.push(isMacintosh() ? '⌥' : 'Alt')
}
if (shortcut.shiftKey) {
parts.push('Shift')
}
parts.push(shortcut.key.toLowerCase())
const formattedKeyLabel = parts.join('+')
return `${label} (${formattedKeyLabel})`
}
/**
* Resolves the effective shortcut for a given shortcutId by checking overrides first,
* then falling back to the catalog default.
* @param shortcutId - The shortcut identifier
* @param overrides - Map of shortcut overrides
* @param getShortcutById - Function to lookup shortcuts from the catalog
* @returns The effective shortcut (override if present, otherwise catalog default)
*/
export const getEffectiveShortcut = (
shortcutId: ShortcutId,
overrides: Map<string, Shortcut>,
getShortcutById: (id: ShortcutId) => ShortcutDescriptor | undefined
): Shortcut | undefined => {
const override = overrides.get(shortcutId)
if (override) return override
const catalogItem = getShortcutById(shortcutId)
return catalogItem?.shortcut
}
@@ -1,19 +0,0 @@
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)
},
[]
)
}
+16 -23
View File
@@ -10,7 +10,6 @@ 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'
@@ -91,11 +90,6 @@ 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 (
<>
@@ -159,24 +153,23 @@ export const Header = () => {
)}
{!!user && (
<Menu>
<Button size="sm" variant="secondaryText">
<VisualOnlyTooltip
tooltip={loggedInTooltip}
ariaLabel={loggedInAriaLabel}
tooltipPosition="bottom"
<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' },
})}
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
{user?.full_name || user?.email}
</span>
</VisualOnlyTooltip>
{user?.full_name || user?.email}
</span>
</Button>
<MenuList
variant={'light'}
-2
View File
@@ -4,7 +4,6 @@ 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'
@@ -42,7 +41,6 @@ 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 {{room}} vom {{created_at}}.",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen",
"warning": {
+2 -44
View File
@@ -156,8 +156,7 @@
"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",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
"leaveButton": "Jetzt verlassen"
},
"controls": {
"microphone": "Mikrofon",
@@ -505,7 +504,6 @@
"participants": {
"subheading": "Im Raum",
"you": "Du",
"unknown": "Unbekannter Teilnehmer",
"host": "Host",
"contributors": "Mitwirkende",
"collapsable": {
@@ -553,14 +551,6 @@
"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",
@@ -584,7 +574,7 @@
},
"participantTileFocus": {
"containerLabel": "Optionen für {{name}}",
"toolbarHint": "{{binding}}: Tastenkürzel anzeigen.",
"toolbarHint": "F2: zur Symbolleiste unten.",
"pin": {
"enable": "Anheften",
"disable": "Lösen"
@@ -593,38 +583,6 @@
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
},
"shortcutsPanel": {
"title": "Tastenkombinationen",
"categories": {
"navigation": "Navigation",
"media": "Medien",
"interaction": "Interaktion"
},
"actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
"reaction": "Reaktionspanel",
"fullscreen": "Vollbild umschalten",
"recording": "Aufnahmepanel umschalten",
"raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
},
"sr": {
"control": "Steuerung",
"command": "Befehl",
"plus": "plus",
"hold": "Halte {{key}} gedrückt",
"noShortcut": "Kein Tastenkürzel"
},
"visual": {
"hold": "Halte {{key}} gedrückt"
}
},
"fullScreenWarning": {
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Präsentation beenden",
+1 -22
View File
@@ -114,27 +114,6 @@
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"transcription": "Transkription",
"shortcuts": "Tastenkürzel"
},
"shortcutsEditor": {
"description": "Tastenkürzel anpassen (lokal auf diesem Gerät).",
"pressHint": "Drücken Sie die gewünschten Tasten, um ein Kürzel zu setzen.",
"longPressHint": "Halten Sie eine Taste gedrückt, um ein Long-Press-Kürzel zu setzen.",
"edit": "Bearbeiten",
"capture": "Tasten drücken …",
"reset": "Zurücksetzen",
"custom": "benutzerdefiniert",
"limitations": "Die Kürzel werden lokal gespeichert und gelten nicht zwingend für alle Aktionen.",
"listLabel": "Liste der Tastenkürzel",
"shortcutAria": "Tastenkürzel für {{action}}: {{shortcut}}",
"customAria": "benutzerdefiniert",
"editAria": "Tastenkürzel für {{action}} bearbeiten",
"captureAria": "Tasten drücken, um Tastenkürzel für {{action}} festzulegen",
"resetAria": "Tastenkürzel für {{action}} zurücksetzen",
"actionsGroupAria": "Aktionen für {{action}}",
"currentShortcut": "Aktuelles Tastenkürzel: {{shortcut}}",
"modifiedConfirmation": "Tastenkürzel geändert",
"resetConfirmation": "Tastenkürzel zurückgesetzt"
"transcription": "Transkription"
}
}
+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 {{room}} from {{created_at}}.",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download",
"warning": {
+2 -44
View File
@@ -156,8 +156,7 @@
"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",
"countdownAnnouncement": "Call ends in {{duration}}."
"leaveButton": "Leave now"
},
"controls": {
"microphone": "Microphone",
@@ -505,7 +504,6 @@
"participants": {
"subheading": "In room",
"you": "You",
"unknown": "Unknown participant",
"host": "Host",
"contributors": "Contributors",
"collapsable": {
@@ -553,14 +551,6 @@
"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",
@@ -584,7 +574,7 @@
},
"participantTileFocus": {
"containerLabel": "Options for {{name}}",
"toolbarHint": "{{binding}} : show keyboard shortcuts.",
"toolbarHint": "F2: go to the bottom toolbar.",
"pin": {
"enable": "Pin",
"disable": "Unpin"
@@ -593,38 +583,6 @@
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
"shortcutsPanel": {
"title": "Keyboard shortcuts",
"categories": {
"navigation": "Navigation",
"media": "Media",
"interaction": "Interaction"
},
"actions": {
"open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar",
"toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)",
"reaction": "Emoji reaction panel",
"fullscreen": "Toggle fullscreen",
"recording": "Toggle recording panel",
"raise-hand": "Raise or lower hand",
"toggle-chat": "Toggle chat",
"toggle-participants": "Toggle participants",
"open-shortcuts-settings": "Open shortcuts settings"
},
"sr": {
"control": "Control",
"command": "Command",
"plus": "plus",
"hold": "Hold {{key}}",
"noShortcut": "No shortcut"
},
"visual": {
"hold": "Hold {{key}}"
}
},
"fullScreenWarning": {
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
+1 -22
View File
@@ -120,27 +120,6 @@
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription",
"shortcuts": "Shortcuts"
},
"shortcutsEditor": {
"description": "Customize keyboard shortcuts (local to this device).",
"pressHint": "Press the desired keys to set a new shortcut.",
"longPressHint": "Hold a key to set a long-press shortcut.",
"edit": "Edit",
"capture": "Press keys…",
"reset": "Reset",
"custom": "custom",
"limitations": "Shortcuts are saved locally and may not apply to all actions yet.",
"listLabel": "List of keyboard shortcuts",
"shortcutAria": "Shortcut for {{action}}: {{shortcut}}",
"customAria": "custom",
"editAria": "Edit shortcut for {{action}}",
"captureAria": "Press keys to set shortcut for {{action}}",
"resetAria": "Reset shortcut for {{action}}",
"actionsGroupAria": "Actions for {{action}}",
"currentShortcut": "Current shortcut: {{shortcut}}",
"modifiedConfirmation": "Shortcut modified",
"resetConfirmation": "Shortcut reset"
"transcription": "Transcription"
}
}
+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 {{room}} du {{created_at}}.",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger",
"warning": {
+2 -44
View File
@@ -156,8 +156,7 @@
"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",
"countdownAnnouncement": "L'appel se termine dans {{duration}}."
"leaveButton": "Quitter maintenant"
},
"controls": {
"microphone": "Microphone",
@@ -505,7 +504,6 @@
"participants": {
"subheading": "Dans la réunion",
"you": "Vous",
"unknown": "Participant inconnu",
"contributors": "Contributeurs",
"host": "Organisateur de la réunion",
"collapsable": {
@@ -553,14 +551,6 @@
"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",
@@ -584,7 +574,7 @@
},
"participantTileFocus": {
"containerLabel": "Options pour {{name}}",
"toolbarHint": "{{binding}} : voir les raccourcis clavier.",
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
"pin": {
"enable": "Épingler",
"disable": "Annuler l'épinglage"
@@ -593,38 +583,6 @@
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
"shortcutsPanel": {
"title": "Raccourcis clavier",
"categories": {
"navigation": "Navigation",
"media": "Média",
"interaction": "Interaction"
},
"actions": {
"open-shortcuts": "Ouvrir laide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre doutils du bas",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
"reaction": "Panneau des réactions",
"fullscreen": "Basculer en plein écran",
"recording": "Basculer le panneau denregistrement",
"raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
},
"sr": {
"control": "Contrôle",
"command": "Commande",
"plus": "plus",
"hold": "Maintenir {{key}}",
"noShortcut": "Aucun raccourci"
},
"visual": {
"hold": "Maintenir {{key}}"
}
},
"fullScreenWarning": {
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",
+1 -22
View File
@@ -120,27 +120,6 @@
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription",
"shortcuts": "Raccourcis"
},
"shortcutsEditor": {
"description": "Personnaliser les raccourcis clavier (local à cet appareil).",
"pressHint": "Appuyez sur les touches souhaitées pour définir un raccourci.",
"longPressHint": "Maintenez une touche pour définir un raccourci long.",
"edit": "Modifier",
"capture": "Appuyez sur les touches…",
"reset": "Réinitialiser",
"custom": "personnalisé",
"limitations": "Les raccourcis sont enregistrés localement et peuvent ne pas s'appliquer à toutes les actions.",
"listLabel": "Liste des raccourcis clavier",
"shortcutAria": "Raccourci pour {{action}} : {{shortcut}}",
"customAria": "personnalisé",
"editAria": "Modifier le raccourci pour {{action}}",
"captureAria": "Appuyez sur les touches pour définir le raccourci pour {{action}}",
"resetAria": "Réinitialiser le raccourci pour {{action}}",
"actionsGroupAria": "Actions pour {{action}}",
"currentShortcut": "Raccourci actuel : {{shortcut}}",
"modifiedConfirmation": "Raccourci modifié",
"resetConfirmation": "Raccourci réinitialisé"
"transcription": "Transcription"
}
}
+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 {{room}} op {{created_at}}.",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
"button": "Downloaden",
"warning": {
+2 -44
View File
@@ -156,8 +156,7 @@
"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",
"countdownAnnouncement": "Het gesprek eindigt over {{duration}}."
"leaveButton": "Nu verlaten"
},
"controls": {
"microphone": "Microfoon",
@@ -505,7 +504,6 @@
"participants": {
"subheading": "In de ruimte",
"you": "U",
"unknown": "Onbekende deelnemer",
"host": "Host",
"contributors": "Deelnemers",
"collapsable": {
@@ -553,14 +551,6 @@
"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",
@@ -584,7 +574,7 @@
},
"participantTileFocus": {
"containerLabel": "Opties voor {{name}}",
"toolbarHint": "{{binding}}: open de sneltoetsen.",
"toolbarHint": "F2: naar de werkbalk onderaan.",
"pin": {
"enable": "Pinnen",
"disable": "Losmaken"
@@ -593,38 +583,6 @@
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm"
},
"shortcutsPanel": {
"title": "Sneltoetsen",
"categories": {
"navigation": "Navigatie",
"media": "Media",
"interaction": "Interactie"
},
"actions": {
"open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk",
"toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
"reaction": "Reactiepaneel",
"fullscreen": "Volledig scherm wisselen",
"recording": "Opnamepaneel wisselen",
"raise-hand": "Hand opsteken of laten zakken",
"toggle-chat": "Chat tonen/verbergen",
"toggle-participants": "Deelnemers tonen/verbergen",
"open-shortcuts-settings": "Sneltoets-instellingen openen"
},
"sr": {
"control": "Control",
"command": "Command",
"plus": "plus",
"hold": "Houd {{key}} ingedrukt",
"noShortcut": "Geen sneltoets"
},
"visual": {
"hold": "Houd {{key}} ingedrukt"
}
},
"fullScreenWarning": {
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
"stop": "Stop met presenteren",
+1 -22
View File
@@ -114,27 +114,6 @@
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen",
"transcription": "Transcriptie",
"shortcuts": "Sneltoetsen"
},
"shortcutsEditor": {
"description": "Sneltoetsen aanpassen (lokaal op dit apparaat).",
"pressHint": "Druk op de gewenste toetsen om een sneltoets in te stellen.",
"longPressHint": "Houd een toets ingedrukt voor een long-press sneltoets.",
"edit": "Bewerken",
"capture": "Toetsen indrukken…",
"reset": "Resetten",
"custom": "aangepast",
"limitations": "Sneltoetsen worden lokaal opgeslagen en gelden mogelijk niet voor alle acties.",
"listLabel": "Lijst van sneltoetsen",
"shortcutAria": "Sneltoets voor {{action}}: {{shortcut}}",
"customAria": "aangepast",
"editAria": "Sneltoets voor {{action}} bewerken",
"captureAria": "Druk op toetsen om sneltoets voor {{action}} in te stellen",
"resetAria": "Sneltoets voor {{action}} resetten",
"actionsGroupAria": "Acties voor {{action}}",
"currentShortcut": "Huidige sneltoets: {{shortcut}}",
"modifiedConfirmation": "Sneltoets gewijzigd",
"resetConfirmation": "Sneltoets gereset"
"transcription": "Transcriptie"
}
}
+1 -3
View File
@@ -138,9 +138,7 @@ export const Field = <T extends object>({
const LabelAndDescription = (
<>
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
{description ? (
<FieldDescription slot="description">{description}</FieldDescription>
) : null}
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
const RACFieldErrors = (
@@ -1,29 +0,0 @@
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,18 +1,10 @@
import {
type ReactElement,
cloneElement,
isValidElement,
useRef,
useState,
} from 'react'
import { type ReactNode, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactElement
children: ReactNode
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
}
/**
@@ -28,50 +20,32 @@ export type VisualOnlyTooltipProps = {
export const VisualOnlyTooltip = ({
children,
tooltip,
ariaLabel,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
const [isVisible, setIsVisible] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{
top: number
left: number
} | null>(null)
const [isVisible, setIsVisible] = useState(false)
const isBottom = tooltipPosition === 'bottom'
const showTooltip = () => {
if (!wrapperRef.current) return
const getPosition = () => {
if (!wrapperRef.current) return null
const rect = wrapperRef.current.getBoundingClientRect()
setPosition({
top: isBottom ? rect.bottom + 8 : rect.top - 8,
return {
top: 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={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{wrappedChild}
{children}
</div>
{tooltipData &&
createPortal(
@@ -92,26 +66,17 @@ export const VisualOnlyTooltip = ({
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
...(isBottom
? {
bottom: '100%',
borderBottomColor: 'primaryDark.100',
}
: {
top: '100%',
borderTopColor: 'primaryDark.100',
}),
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: isBottom
? 'translate(-50%, 0)'
: 'translate(-50%, -100%)',
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
-1
View File
@@ -24,7 +24,6 @@ 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'
@@ -1,29 +0,0 @@
import { proxy } from 'valtio'
type LayoutType = 'grid' | 'carousel' | null
type State = {
layoutType: LayoutType
firstGridTileTrackId: string | null
}
export const participantLayoutStore = proxy<State>({
layoutType: null,
firstGridTileTrackId: null,
})
export const setGridLayout = (firstTrackId: string | null) => {
participantLayoutStore.layoutType = 'grid'
participantLayoutStore.firstGridTileTrackId = firstTrackId
}
export const setCarouselLayout = () => {
participantLayoutStore.layoutType = 'carousel'
participantLayoutStore.firstGridTileTrackId = null
}
export const resetLayout = () => {
participantLayoutStore.layoutType = null
participantLayoutStore.firstGridTileTrackId = null
}
@@ -1,86 +0,0 @@
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)
}
-21
View File
@@ -1,21 +0,0 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const shortcutHelpStore = proxy<State>({
isOpen: false,
})
export const openShortcutHelp = () => {
shortcutHelpStore.isOpen = true
}
export const closeShortcutHelp = () => {
shortcutHelpStore.isOpen = false
}
export const toggleShortcutHelp = () => {
shortcutHelpStore.isOpen = !shortcutHelpStore.isOpen
}
@@ -1,92 +0,0 @@
import { proxy } from 'valtio'
import { Shortcut } from '@/features/shortcuts/types'
const STORAGE_KEY = 'shortcuts:overrides'
type State = {
overrides: Map<string, Shortcut>
isLoaded: boolean
}
export const shortcutOverridesStore = proxy<State>({
overrides: new Map<string, Shortcut>(),
isLoaded: false,
})
const isValidShortcut = (value: unknown): value is Shortcut => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const shortcut = value as Record<string, unknown>
if (typeof shortcut.key !== 'string' || shortcut.key.length === 0) {
return false
}
if (shortcut.ctrlKey !== undefined && typeof shortcut.ctrlKey !== 'boolean') {
return false
}
if (
shortcut.shiftKey !== undefined &&
typeof shortcut.shiftKey !== 'boolean'
) {
return false
}
if (shortcut.altKey !== undefined && typeof shortcut.altKey !== 'boolean') {
return false
}
return true
}
export const loadShortcutOverrides = () => {
if (shortcutOverridesStore.isLoaded) return
shortcutOverridesStore.isLoaded = true
if (typeof window === 'undefined') return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Invalid shortcut overrides format in localStorage')
return
}
Object.entries(parsed).forEach(([id, value]) => {
if (isValidShortcut(value)) {
shortcutOverridesStore.overrides.set(id, value)
} else {
console.warn(`Skipping invalid shortcut override for "${id}":`, value)
}
})
} catch (e) {
console.warn('Failed to load shortcut overrides', e)
}
}
export const getOverride = (id: string): Shortcut | undefined => {
return shortcutOverridesStore.overrides.get(id)
}
const saveOverridesToStorage = () => {
if (typeof window === 'undefined') return
try {
const overridesObj: Record<string, Shortcut> = {}
shortcutOverridesStore.overrides.forEach((value, key) => {
overridesObj[key] = value
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(overridesObj))
} catch (e) {
console.warn('Failed to save shortcut overrides', e)
}
}
export const setOverride = (id: string, shortcut: Shortcut) => {
shortcutOverridesStore.overrides.set(id, shortcut)
// Force reactivity by creating a new Map reference
shortcutOverridesStore.overrides = new Map(shortcutOverridesStore.overrides)
saveOverridesToStorage()
}
export const removeOverride = (id: string) => {
shortcutOverridesStore.overrides.delete(id)
// Force reactivity by creating a new Map reference
shortcutOverridesStore.overrides = new Map(shortcutOverridesStore.overrides)
saveOverridesToStorage()
}
-4
View File
@@ -45,10 +45,6 @@ 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.5.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.5.0",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.5.0",
"version": "1.3.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.5.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.5.0",
"version": "1.3.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.5.0",
"version": "1.3.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.5.0"
version = "1.3.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",