From 8ca52737cdd285e49fe5d0f831e851f9294202d7 Mon Sep 17 00:00:00 2001 From: Ovgodd Date: Wed, 18 Feb 2026 17:15:29 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20introduce=20a=20shortcut?= =?UTF-8?q?=20settings=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work adapted from PR #859 and partially extracted to ship as a smaller, focused PR. This allows users to view the full list of available shortcuts. An editor to customize these shortcuts may be introduced later. --- CHANGELOG.md | 1 + .../components/SettingsDialogExtended.tsx | 9 ++- .../components/tabs/AccessibilityTab.tsx | 2 - .../settings/components/tabs/ShortcutTab.tsx | 51 ++++++++++++++++ src/frontend/src/features/settings/type.ts | 1 + .../shortcuts/components/ShortcutBadge.tsx | 34 +++++++++++ .../shortcuts/components/ShortcutRow.tsx | 42 +++++++++++++ .../src/features/shortcuts/formatLabels.ts | 61 +++++++++++++++++++ .../shortcuts/hooks/useShortcutFormatting.ts | 51 ++++++++++++++++ src/frontend/src/locales/de/rooms.json | 32 ++++++++++ src/frontend/src/locales/de/settings.json | 8 ++- src/frontend/src/locales/en/rooms.json | 32 ++++++++++ src/frontend/src/locales/en/settings.json | 8 ++- src/frontend/src/locales/fr/rooms.json | 32 ++++++++++ src/frontend/src/locales/fr/settings.json | 8 ++- src/frontend/src/locales/nl/rooms.json | 32 ++++++++++ src/frontend/src/locales/nl/settings.json | 8 ++- 17 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx create mode 100644 src/frontend/src/features/shortcuts/components/ShortcutBadge.tsx create mode 100644 src/frontend/src/features/shortcuts/components/ShortcutRow.tsx create mode 100644 src/frontend/src/features/shortcuts/formatLabels.ts create mode 100644 src/frontend/src/features/shortcuts/hooks/useShortcutFormatting.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 677c0e71..77cda0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - ♻️(frontend) replace custom reactions toolbar with react aria popover #985 - 🔒️(frontend) uninstall curl from the frontend production image #987 - 💄(frontend) add focus ring to reaction emoji buttons +- ✨(frontend) introduce a shortcut settings tab #975 ## [1.8.0] - 2026-02-20 diff --git a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx index 7018f90d..919d065e 100644 --- a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx +++ b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx @@ -12,6 +12,7 @@ import { RiSpeakerLine, RiVideoOnLine, RiEyeLine, + RiKeyboardBoxLine, } from '@remixicon/react' import { AccountTab } from './tabs/AccountTab' import { NotificationsTab } from './tabs/NotificationsTab' @@ -19,11 +20,12 @@ import { GeneralTab } from './tabs/GeneralTab' import { AudioTab } from './tabs/AudioTab' import { VideoTab } from './tabs/VideoTab' import { TranscriptionTab } from './tabs/TranscriptionTab' +import { ShortcutTab } from './tabs/ShortcutTab' import { useRef } from 'react' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { SettingsDialogExtendedKey } from '@/features/settings/type' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' -import AccessibilityTab from './tabs/AccessibilityTab' +import { AccessibilityTab } from './tabs/AccessibilityTab' const tabsStyle = css({ maxHeight: '40.625rem', // fixme size copied from meet settings modal @@ -107,6 +109,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { {isWideScreen && t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)} + + + {isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)} + {isAdminOrOwner && ( @@ -130,6 +136,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { + {/* Transcription tab won't be accessible if the tab is not active in the tab list */} diff --git a/src/frontend/src/features/settings/components/tabs/AccessibilityTab.tsx b/src/frontend/src/features/settings/components/tabs/AccessibilityTab.tsx index def060b5..952d129d 100644 --- a/src/frontend/src/features/settings/components/tabs/AccessibilityTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/AccessibilityTab.tsx @@ -36,5 +36,3 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => { ) } - -export default AccessibilityTab diff --git a/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx b/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx new file mode 100644 index 00000000..0fe54975 --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/ShortcutTab.tsx @@ -0,0 +1,51 @@ +import { shortcutCatalog } from '@/features/shortcuts/catalog' +import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow' +import { css } from '@/styled-system/css' +import { useTranslation } from 'react-i18next' +import { TabPanel, type TabPanelProps } from '@/primitives/Tabs' +import { H } from '@/primitives' + +const tableStyle = css({ + width: '100%', + borderCollapse: 'collapse', + overflowY: 'auto', + '& th, & td': { + padding: '0.65rem 0', + textAlign: 'left', + }, + '& tbody tr': { + borderBottom: '1px solid rgba(255,255,255,0.08)', + }, +}) + +export const ShortcutTab = ({ id }: Pick) => { + const { t } = useTranslation(['settings', 'rooms']) + + return ( + + {t('shortcuts.listLabel')} + + + + + + + + + {shortcutCatalog.map((item) => ( + + ))} + +
{t('shortcuts.columnAction')}{t('shortcuts.columnShortcut')}
+
+ ) +} diff --git a/src/frontend/src/features/settings/type.ts b/src/frontend/src/features/settings/type.ts index 93428717..95fcc1e0 100644 --- a/src/frontend/src/features/settings/type.ts +++ b/src/frontend/src/features/settings/type.ts @@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey { GENERAL = 'general', NOTIFICATIONS = 'notifications', TRANSCRIPTION = 'transcription', + SHORTCUTS = 'shortcuts', ACCESSIBILITY = 'accessibility', } diff --git a/src/frontend/src/features/shortcuts/components/ShortcutBadge.tsx b/src/frontend/src/features/shortcuts/components/ShortcutBadge.tsx new file mode 100644 index 00000000..ce2d6002 --- /dev/null +++ b/src/frontend/src/features/shortcuts/components/ShortcutBadge.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { css, cx } from '@/styled-system/css' + +type ShortcutBadgeProps = { + visualLabel: 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 = ({ + visualLabel, + srLabel, + className, +}) => { + return ( + <> + + {srLabel && {srLabel}} + + ) +} diff --git a/src/frontend/src/features/shortcuts/components/ShortcutRow.tsx b/src/frontend/src/features/shortcuts/components/ShortcutRow.tsx new file mode 100644 index 00000000..c046e968 --- /dev/null +++ b/src/frontend/src/features/shortcuts/components/ShortcutRow.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { css } from '@/styled-system/css' +import { text } from '@/primitives/Text' +import { ShortcutDescriptor } from '../catalog' +import { ShortcutBadge } from './ShortcutBadge' +import { useShortcutFormatting } from '../hooks/useShortcutFormatting' +import { useTranslation } from 'react-i18next' + +type ShortcutRowProps = { + descriptor: ShortcutDescriptor +} + +const shortcutCellStyle = css({ + textAlign: 'right', +}) + +export const ShortcutRow: React.FC = ({ descriptor }) => { + const { t } = useTranslation('rooms', { keyPrefix: 'shortcutsPanel' }) + const { formatVisual, formatForSR } = useShortcutFormatting() + + const visualShortcut = formatVisual( + descriptor.shortcut, + descriptor.code, + descriptor.kind + ) + const srShortcut = formatForSR( + descriptor.shortcut, + descriptor.code, + descriptor.kind + ) + + return ( + + + {t(`actions.${descriptor.id}`)} + + + + + + ) +} diff --git a/src/frontend/src/features/shortcuts/formatLabels.ts b/src/frontend/src/features/shortcuts/formatLabels.ts new file mode 100644 index 00000000..598fb38b --- /dev/null +++ b/src/frontend/src/features/shortcuts/formatLabels.ts @@ -0,0 +1,61 @@ +import { Shortcut } from './types' +import { isMacintosh } from '@/utils/livekit' + +// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed). +export const formatShortcutLabel = (shortcut?: Shortcut) => { + if (!shortcut) return '—' + const key = shortcut.key?.toUpperCase() + if (!key) return '—' + const parts: string[] = [] + if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl') + if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt') + if (shortcut.shiftKey) parts.push('Shift') + parts.push(key) + return parts.join('+') +} + +// SR-friendly label for a shortcut (reads “Control plus D”). +export const formatShortcutLabelForSR = ( + shortcut: Shortcut | undefined, + { + controlLabel, + commandLabel, + plusLabel, + noShortcutLabel, + }: { + controlLabel: string + commandLabel: string + plusLabel: string + noShortcutLabel: string + } +) => { + if (!shortcut) return noShortcutLabel + const key = shortcut.key?.toUpperCase() + if (!key) return noShortcutLabel + const ctrlWord = isMacintosh() ? commandLabel : controlLabel + const parts: string[] = [] + if (shortcut.ctrlKey) parts.push(ctrlWord) + if (shortcut.altKey) parts.push('Alt') + if (shortcut.shiftKey) parts.push('Shift') + parts.push(key) + return parts.join(` ${plusLabel} `) +} + +// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V). +export const getKeyLabelFromCode = (code?: string) => { + if (!code) return '' + if (code.startsWith('Key') && code.length === 4) return code.slice(3) + if (code.startsWith('Digit') && code.length === 6) return code.slice(5) + if (code === 'Space') return '␣' + if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right + return code +} + +// Long-press label (visual or SR), e.g. “Hold V”. +export const formatLongPressLabel = ( + codeLabel: string, + holdTemplate: string +) => { + if (!codeLabel) return holdTemplate.replace('{{key}}', '?') + return holdTemplate.replace('{{key}}', codeLabel) +} diff --git a/src/frontend/src/features/shortcuts/hooks/useShortcutFormatting.ts b/src/frontend/src/features/shortcuts/hooks/useShortcutFormatting.ts new file mode 100644 index 00000000..44e75d9b --- /dev/null +++ b/src/frontend/src/features/shortcuts/hooks/useShortcutFormatting.ts @@ -0,0 +1,51 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Shortcut } from '../types' +import { + formatShortcutLabel, + formatShortcutLabelForSR, + formatLongPressLabel, + getKeyLabelFromCode, +} from '../formatLabels' + +export const useShortcutFormatting = () => { + const { t } = useTranslation('rooms') + + const formatVisual = useCallback( + (shortcut?: Shortcut, code?: string, kind?: string) => { + if (code && kind === 'longPress') { + const label = getKeyLabelFromCode(code) + return formatLongPressLabel( + label, + t('shortcutsPanel.visual.hold', { key: '{{key}}' }) + ) + } + return formatShortcutLabel(shortcut) + }, + [t] + ) + + const formatForSR = useCallback( + (shortcut?: Shortcut, code?: string, kind?: string) => { + if (code && kind === 'longPress') { + 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] + ) + + return { + formatVisual, + formatForSR, + } +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index d994855a..2f170f7c 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -595,6 +595,38 @@ "muteParticipant": "{{name}} stummschalten", "fullScreen": "Vollbild" }, + "shortcutsPanel": { + "title": "Tastenkombinationen", + "categories": { + "navigation": "Navigation", + "media": "Medien", + "interaction": "Interaktion" + }, + "actions": { + "open-shortcuts": "Tastenkürzel-Hilfe öffnen", + "focus-toolbar": "Fokus auf die untere Symbolleiste", + "toggle-microphone": "Mikrofon umschalten", + "toggle-camera": "Kamera umschalten", + "push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)", + "reaction": "Reaktionspanel", + "fullscreen": "Vollbild umschalten", + "recording": "Aufnahmepanel umschalten", + "raise-hand": "Hand heben oder senken", + "toggle-chat": "Chat anzeigen/ausblenden", + "toggle-participants": "Teilnehmer anzeigen/ausblenden", + "open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen" + }, + "sr": { + "control": "Steuerung", + "command": "Befehl", + "plus": "plus", + "hold": "Halte {{key}} gedrückt", + "noShortcut": "Kein Tastenkürzel" + }, + "visual": { + "hold": "Halte {{key}} gedrückt" + } + }, "fullScreenWarning": { "message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.", "stop": "Präsentation beenden", diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 1664b612..8602f427 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -100,6 +100,11 @@ } } }, + "shortcuts": { + "listLabel": "Tastenkürzel", + "columnAction": "Aktion", + "columnShortcut": "Tastenkürzel" + }, "dialog": { "heading": "Einstellungen" }, @@ -120,6 +125,7 @@ "general": "Allgemein", "notifications": "Benachrichtigungen", "accessibility": "Barrierefreiheit", - "transcription": "Transkription" + "transcription": "Transkription", + "shortcuts": "Tastenkürzel" } } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 71800d70..0baa228e 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -595,6 +595,38 @@ "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", diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 0c1865dc..6d0420bf 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -100,6 +100,11 @@ } } }, + "shortcuts": { + "listLabel": "Keyboard shortcuts", + "columnAction": "Action", + "columnShortcut": "Shortcut" + }, "dialog": { "heading": "Settings" }, @@ -120,6 +125,7 @@ "general": "General", "notifications": "Notifications", "accessibility": "Accessibility", - "transcription": "Transcription" + "transcription": "Transcription", + "shortcuts": "Shortcuts" } } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 8144d4d3..b8d56006 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -595,6 +595,38 @@ "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", diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index c5d96efa..dfefb828 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -100,6 +100,11 @@ } } }, + "shortcuts": { + "listLabel": "Liste des raccourcis clavier", + "columnAction": "Action", + "columnShortcut": "Raccourci" + }, "dialog": { "heading": "Paramètres" }, @@ -120,6 +125,7 @@ "general": "Général", "notifications": "Notifications", "accessibility": "Accessibilité", - "transcription": "Transcription" + "transcription": "Transcription", + "shortcuts": "Raccourcis" } } diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index a76dc10c..938af7bb 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -595,6 +595,38 @@ "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", diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index 31edf1c5..160c03fc 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -100,6 +100,11 @@ } } }, + "shortcuts": { + "listLabel": "Sneltoetsen", + "columnAction": "Actie", + "columnShortcut": "Sneltoets" + }, "dialog": { "heading": "Instellingen" }, @@ -114,6 +119,7 @@ "video": "Video", "general": "Algemeen", "notifications": "Meldingen", - "transcription": "Transcriptie" + "transcription": "Transcriptie", + "shortcuts": "Sneltoetsen" } }