(frontend) introduce a shortcut settings tab

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.
This commit is contained in:
Ovgodd
2026-02-18 17:15:29 +01:00
committed by aleb_the_flash
parent 87b9ca2314
commit 8ca52737cd
17 changed files with 405 additions and 7 deletions

View File

@@ -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

View File

@@ -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}`)}
</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" />
@@ -130,6 +136,7 @@ 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} />

View File

@@ -36,5 +36,3 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
</TabPanel>
)
}
export default AccessibilityTab

View File

@@ -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<TabPanelProps, 'id'>) => {
const { t } = useTranslation(['settings', 'rooms'])
return (
<TabPanel
id={id}
padding="md"
flex
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<H lvl={2}>{t('shortcuts.listLabel')}</H>
<table className={tableStyle}>
<thead className="sr-only">
<tr>
<th scope="col">{t('shortcuts.columnAction')}</th>
<th scope="col">{t('shortcuts.columnShortcut')}</th>
</tr>
</thead>
<tbody>
{shortcutCatalog.map((item) => (
<ShortcutRow key={item?.id} descriptor={item} />
))}
</tbody>
</table>
</TabPanel>
)
}

View File

@@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
}

View File

@@ -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<ShortcutBadgeProps> = ({
visualLabel,
srLabel,
className,
}) => {
return (
<>
<div className={cx(badgeStyle, className)} aria-hidden="true">
<span>{visualLabel}</span>
</div>
{srLabel && <span className="sr-only">{srLabel}</span>}
</>
)
}

View File

@@ -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<ShortcutRowProps> = ({ 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 (
<tr>
<td className={text({ variant: 'body' })}>
{t(`actions.${descriptor.id}`)}
</td>
<td className={shortcutCellStyle}>
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
</td>
</tr>
)
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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 laide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre doutils du bas",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
"reaction": "Panneau des réactions",
"fullscreen": "Basculer en plein écran",
"recording": "Basculer le panneau denregistrement",
"raise-hand": "Lever ou baisser la main",
"toggle-chat": "Afficher/Masquer le chat",
"toggle-participants": "Afficher/Masquer les participants",
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
},
"sr": {
"control": "Contrôle",
"command": "Commande",
"plus": "plus",
"hold": "Maintenir {{key}}",
"noShortcut": "Aucun raccourci"
},
"visual": {
"hold": "Maintenir {{key}}"
}
},
"fullScreenWarning": {
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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"
}
}