Compare commits

..

4 Commits

Author SHA1 Message Date
lebaudantoine 427a383dfe 🔒️(ci) set persist-credentials: false on actions/checkout
By default, `actions/checkout` saves the job's auth token
(`GITHUB_TOKEN` or the provided PAT) in the local git config so
later steps can run authenticated git commands. That token then
stays on disk for the rest of the job, where it can leak:

* If an artifact upload includes the checkout directory, the token
  is packaged with it and anyone with artifact access can extract
  it. On public repos that is anyone, and the token can be used
  while the job is still running ("ArtiPACKED", flagged by
  `zizmor` as `artipacked`).
* Any later step, third-party action, or build dependency can read
  the token from the git config, which widens the impact of a
  supply-chain compromise.

None of our workflows need authenticated git after checkout, so
disable credential persistence. If a step needs to push in
2026-10-05 12:30:02 +02:00
davd-gzl d2f21d3617 🔧(frontend) rename the make target to test-frontend
The frontend test target now sits beside test-back and
test-summary and follows their naming.
2026-10-05 12:23:44 +02:00
davd-gzl f0d5d652d1 🔧(frontend) give vitest its own config and a make target
A separate vitest.config.ts keeps the tests off the build
plugins and the mediapipe version check in vite.config.ts.
make test now runs the frontend tests after the backend ones,
and test-front runs on Node 24, the current LTS.
2026-10-05 12:23:44 +02:00
davd-gzl f4217b5b9b ✅(frontend) add vitest so the frontend can carry unit tests
Add vitest as a dev dependency, a test script that runs panda
codegen first, and a test-front job, so the frontend can carry
unit tests. One test covers normalizeRoomId, a plain function,
so no DOM library comes with it.
2026-10-05 12:23:40 +02:00
14 changed files with 233 additions and 151 deletions
+1 -3
View File
@@ -17,7 +17,6 @@ and this project adheres to
- ✨(frontend) let signed-out visitors start a meeting - ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration - ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✅(frontend) add vitest so the frontend can carry unit tests - ✅(frontend) add vitest so the frontend can carry unit tests
- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
### Changed ### Changed
@@ -95,7 +94,7 @@ and this project adheres to
### Fixed ### Fixed
- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat** - 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) render periodSeconds and failureThreshold on probes - 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA" - 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival - 🐛(frontend) play the waiting room notification sound on every arrival
@@ -167,7 +166,6 @@ and this project adheres to
### Added ### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms - ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed ### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports - 📱(frontend) collapse mobile control bar items on narrow viewports
@@ -7,6 +7,7 @@ import {
useSwipe, useSwipe,
} from '@livekit/components-react' } from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps' import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout' import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl' import { PaginationControl } from './PaginationControl'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger' import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
@@ -61,7 +62,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
> >
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop> <TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && ( {tracks.length > layout.maxTiles && (
<PaginationControl {...pagination} focusShortcut /> <>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
/>
<PaginationControl pagesContainer={gridEl} {...pagination} />
</>
)} )}
</div> </div>
) )
@@ -1,147 +1,99 @@
import * as React from 'react'
import { createInteractingObservable } from '@livekit/components-core'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css, cva, type RecipeVariantProps } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useCallback, useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const paginationToolbar = cva({ export interface PaginationControlProps {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.125rem',
backgroundColor: 'primaryDark.50',
borderRadius: '2rem',
border: '1px solid',
borderColor: 'primaryDark.200',
padding: '0.375rem',
},
variants: {
placement: {
overlay: {
position: 'absolute',
bottom: '1rem',
left: '50%',
zIndex: 2,
transform: 'translateX(-50%)',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
},
inline: {
alignSelf: 'center',
flexShrink: 0,
marginTop: '1rem',
},
},
},
defaultVariants: {
placement: 'overlay',
},
})
export type PaginationControlProps = RecipeVariantProps<
typeof paginationToolbar
> & {
totalPageCount: number totalPageCount: number
nextPage: () => void nextPage: () => void
prevPage: () => void prevPage: () => void
currentPage: number currentPage: number
// The shortcut listens on the main window only, so a single instance may own it. pagesContainer?: React.RefObject<HTMLElement>
focusShortcut?: boolean
} }
const arrowButtonClass = css({
_disabled: {
cursor: 'default',
backgroundColor: 'transparent !important',
'& svg': {
opacity: 0.35,
},
_focusVisible: {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
})
export function PaginationControl({ export function PaginationControl({
totalPageCount, totalPageCount,
nextPage, nextPage,
prevPage, prevPage,
currentPage, currentPage,
placement, pagesContainer: connectedElement,
focusShortcut = false,
}: PaginationControlProps) { }: PaginationControlProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' }) const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
const prevButtonRef = useRef<HTMLButtonElement>(null) const [interactive, setInteractive] = useState(false)
const nextButtonRef = useRef<HTMLButtonElement>(null)
const isSinglePage = totalPageCount <= 1
const isFirstPage = currentPage <= 1
const isLastPage = currentPage >= totalPageCount
const focusPagination = useCallback(() => { useEffect(() => {
const target = isLastPage ? prevButtonRef.current : nextButtonRef.current let subscription:
target?.focus() | ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
}, [isLastPage]) | undefined
if (connectedElement) {
subscription = createInteractingObservable(
connectedElement.current,
2000
).subscribe(setInteractive)
}
return () => {
if (subscription) {
subscription.unsubscribe()
}
}
}, [connectedElement])
useRegisterKeyboardShortcut({ if (totalPageCount <= 1) return null
id: focusShortcut ? 'focus-pagination' : undefined,
handler: focusPagination,
isDisabled: isSinglePage,
unregisterOnUnmount: true,
})
if (isSinglePage) return null
const pageCount = t('count', { currentPage, totalPageCount })
return ( return (
<div <nav
role="group" aria-label={t('label')}
aria-label={`${t('label')}, ${pageCount}`} className={css({
className={paginationToolbar({ placement })} position: 'absolute',
bottom: '1rem',
left: '50%',
transform: 'translateX(-50%)',
alignItems: 'stretch',
backgroundColor: 'var(--lk-control-bg)',
borderRadius: 'var(--lk-border-radius)',
transition: 'opacity ease-in-out .15s',
display: 'none',
border: '1px solid',
borderColor: 'primaryDark.100',
overflow: 'hidden',
})}
style={{
display: interactive ? 'flex' : 'none',
}}
data-lk-user-interaction={interactive}
> >
<Button <Button
ref={prevButtonRef} isDisabled={currentPage == 1}
aria-disabled={isFirstPage} onPress={prevPage}
onPress={isFirstPage ? undefined : prevPage} size="xs"
size="sm" variant="quaternaryText"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('previous')} aria-label={t('previous')}
tooltip={t('previous')}
> >
<RiArrowLeftSLine size={20} /> <RiArrowLeftSLine />
</Button> </Button>
<span <span
role="status" role="status"
className={css({ className={css({
color: 'white', padding: '0.25rem 0.5rem',
fontSize: '0.8125rem',
fontWeight: 500,
minWidth: '3.5rem',
textAlign: 'center',
userSelect: 'none',
padding: '0 0.35rem',
whiteSpace: 'nowrap',
})} })}
> >
{pageCount} {t('count', {
currentPage,
totalPageCount,
})}
</span> </span>
<Button <Button
ref={nextButtonRef} isDisabled={currentPage == totalPageCount}
aria-disabled={isLastPage} onPress={nextPage}
onPress={isLastPage ? undefined : nextPage} size="xs"
size="sm" variant="quaternaryText"
square
variant="primaryTextDark"
className={arrowButtonClass}
aria-label={t('next')} aria-label={t('next')}
tooltip={t('next')}
> >
<RiArrowRightSLine size={20} /> <RiArrowRightSLine />
</Button> </Button>
</div> </nav>
) )
} }
@@ -0,0 +1,30 @@
import * as React from 'react'
export interface PaginationIndicatorProps {
totalPageCount: number
currentPage: number
}
export const PaginationIndicator: (
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
PaginationIndicatorProps
>(function PaginationIndicator(
{ totalPageCount, currentPage }: PaginationIndicatorProps,
ref
) {
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
if (index + 1 === currentPage) {
return <span data-lk-active key={index} />
} else {
return <span key={index} />
}
})
return (
<div ref={ref} className="lk-pagination-indicator" aria-hidden="true">
{bubbles}
</div>
)
})
@@ -0,0 +1,96 @@
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
interface PipPaginationProps {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
export const PipPagination = ({
totalPageCount,
currentPage,
nextPage,
prevPage,
}: PipPaginationProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
if (totalPageCount <= 1) return null
return (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
const Nav = styled('nav', {
base: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25rem',
marginTop: '1rem',
flexShrink: 0,
},
})
const ArrowButton = styled('button', {
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.75rem',
height: '1.75rem',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
color: 'white',
backgroundColor: 'primaryDark.100',
transition: 'opacity 0.15s, background-color 0.15s',
'&:hover:not(:disabled)': {
backgroundColor: 'primaryDark.75',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'white',
outlineOffset: '2px',
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
},
})
const Counter = styled('span', {
base: {
fontSize: '0.75rem',
color: 'white',
opacity: 0.8,
whiteSpace: 'nowrap',
padding: '0 0.25rem',
minWidth: '3rem',
textAlign: 'center',
},
})
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout' import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
import { PaginationControl } from '@/features/layout/components/PaginationControl' import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout' import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame' import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid' import { MAX_PIP_TILES } from '../../utils/pipGrid'
@@ -120,7 +120,12 @@ const PaginatedStage = ({
}) => ( }) => (
<StageWrapper> <StageWrapper>
<StageFrame>{children}</StageFrame> <StageFrame>{children}</StageFrame>
<PaginationControl {...pagination} placement="inline" /> <PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper> </StageWrapper>
) )
@@ -31,20 +31,35 @@ export const useSidePanel = () => {
const isSidePanelOpen = !!activePanelId const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId const isSubPanelOpen = !!activeSubPanelId
// Reads the live store, not the render snapshot: shortcut handlers outlive const toggleAdmin = () => {
// toggles that unmount (e.g. inside a closed overflow menu). layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
const togglePanel = (panelId: PanelId) => { if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
layoutStore.activePanelId =
layoutStore.activePanelId === panelId ? null : panelId
if (layoutStore.activeSubPanelId) layoutStore.activeSubPanelId = null
} }
const toggleAdmin = () => togglePanel(PanelId.ADMIN) const toggleParticipants = () => {
const toggleParticipants = () => togglePanel(PanelId.PARTICIPANTS) layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
const toggleChat = () => togglePanel(PanelId.CHAT) if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
const toggleEffects = () => togglePanel(PanelId.EFFECTS) }
const toggleTools = () => togglePanel(PanelId.TOOLS)
const toggleInfo = () => togglePanel(PanelId.INFO) const toggleChat = () => {
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => { const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
@@ -7,7 +7,6 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId = export type ShortcutId =
| 'open-shortcuts' | 'open-shortcuts'
| 'focus-toolbar' | 'focus-toolbar'
| 'focus-pagination'
| 'toggle-microphone' | 'toggle-microphone'
| 'toggle-camera' | 'toggle-camera'
| 'push-to-talk' | 'push-to-talk'
@@ -45,11 +44,6 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'navigation', category: 'navigation',
shortcut: { key: 'F2' }, shortcut: { key: 'F2' },
}, },
{
id: 'focus-pagination',
category: 'navigation',
shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
},
{ {
id: 'toggle-microphone', id: 'toggle-microphone',
category: 'media', category: 'media',
@@ -7,16 +7,12 @@ export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId id?: ShortcutId
handler: () => Promise<void | boolean | undefined> | void handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean isDisabled?: boolean
// Opt-in: controls rendered inside menus unmount when the menu closes, and
// their shortcuts must keep working.
unregisterOnUnmount?: boolean
} }
export const useRegisterKeyboardShortcut = ({ export const useRegisterKeyboardShortcut = ({
id, id,
handler, handler,
isDisabled = false, isDisabled = false,
unregisterOnUnmount = false,
}: useRegisterKeyboardShortcutProps) => { }: useRegisterKeyboardShortcutProps) => {
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@@ -25,14 +21,8 @@ export const useRegisterKeyboardShortcut = ({
const formattedKey = formatShortcutKey(descriptor.shortcut) const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) { if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey) keyboardShortcutsStore.shortcuts.delete(formattedKey)
return } else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
} }
keyboardShortcutsStore.shortcuts.set(formattedKey, handler) }, [handler, id, isDisabled])
if (!unregisterOnUnmount) return
return () => {
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
}
}
}, [handler, id, isDisabled, unregisterOnUnmount])
} }
-1
View File
@@ -784,7 +784,6 @@
"actions": { "actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen", "open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste", "focus-toolbar": "Fokus auf die untere Symbolleiste",
"focus-pagination": "Fokus auf die Teilnehmerseiten",
"toggle-microphone": "Mikrofon umschalten", "toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten", "toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk", "push-to-talk": "Push-to-talk",
-1
View File
@@ -784,7 +784,6 @@
"actions": { "actions": {
"open-shortcuts": "Open shortcuts help", "open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar", "focus-toolbar": "Focus bottom toolbar",
"focus-pagination": "Focus participant pagination",
"toggle-microphone": "Toggle microphone", "toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera", "toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)", "push-to-talk": "Push-to-talk (hold to unmute)",
-1
View File
@@ -783,7 +783,6 @@
"actions": { "actions": {
"open-shortcuts": "Abrir la ayuda de atajos", "open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior", "focus-toolbar": "Poner el foco en la barra de herramientas inferior",
"focus-pagination": "Poner el foco en la paginación de participantes",
"toggle-microphone": "Activar o desactivar el micrófono", "toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara", "toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)", "push-to-talk": "Pulsar para hablar (mantener para reactivar)",
-1
View File
@@ -784,7 +784,6 @@
"actions": { "actions": {
"open-shortcuts": "Ouvrir l’aide des raccourcis", "open-shortcuts": "Ouvrir l’aide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas", "focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
"focus-pagination": "Mettre le focus sur la pagination des participants",
"toggle-microphone": "Activer ou désactiver le micro", "toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra", "toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)", "push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
-1
View File
@@ -784,7 +784,6 @@
"actions": { "actions": {
"open-shortcuts": "Sneltoetsenhulp openen", "open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk", "focus-toolbar": "Focus op de onderste werkbalk",
"focus-pagination": "Focus op de paginering van deelnemers",
"toggle-microphone": "Microfoon aan/uit", "toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit", "toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)", "push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",