Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61f286cc8b |
@@ -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
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
### 🚀 Major roll out to all French public servants
|
||||
|
||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
|
||||
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="https://${MEET_HOST}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.5.0"
|
||||
version = "1.3.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
+4
-18
@@ -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}`,
|
||||
|
||||
-9
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}} n’est plus épinglée.",
|
||||
"self": {
|
||||
"pin": "Votre vidéo est épinglée.",
|
||||
"unpin": "Votre vidéo n’est plus épinglée."
|
||||
}
|
||||
},
|
||||
"recordingStateToast": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
@@ -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 l’aide des raccourcis",
|
||||
"focus-toolbar": "Mettre le focus sur la barre d’outils 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 d’enregistrement",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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%);
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.5.0",
|
||||
"version": "1.3.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user