Compare commits

..

8 Commits

Author SHA1 Message Date
lebaudantoine f8436d9ae2 🔖(minor) bump release to 1.4.0 2026-01-25 20:02:37 +01:00
lebaudantoine 39fb273201 💩(ci) disable temporarily Trivy scan step for backend image
A new vulnerability (CVE-2026-0994) was reported and is not yet fixed.
It affects protobuf libraries used by the livekit-api Python package.

A fix is in progress upstream, but the related PR has not yet been merged or
released. Since a release is required tonight, the Trivy scan step is
temporarily disabled to allow the build to proceed. This should be re-enabled
once a patched version is available.

https://github.com/protocolbuffers/protobuf/pull/25239
2026-01-25 18:01:13 +01:00
lebaudantoine d101459115 (frontend) add configurable external redirect for unauthenticated users
Offer a way to redirect unauthenticated users to an external home page when they
visit the app, allowing a more marketing-focused entry point with a clearer
value proposition.

In many self-hosted deployments, the default unauthenticated home page is not
accessible or already redirects elsewhere. To ensure resilience, the client
briefly checks that the target page is reachable and falls back to the default
page if not.
2026-01-25 16:49:56 +01:00
aleb_the_flash 88696a23fd 🩹(doc) update link to the environment variables
Link was invalid. Update it to point to the chart's README file.
Please note this file might be removed.
2026-01-25 00:17:50 +01:00
Cyril 13d26a76b3 (frontend) scope scrollbar gutter override to video rooms
limit scrollbar gutter override to video conference context
2026-01-25 00:07:51 +01:00
lebaudantoine b675517a60 🚧(frontend) debug transcript segment organization
for the big monday demo, push a draft commit.
2026-01-23 19:43:29 +01:00
lebaudantoine a5254ffd59 🔊(frontend) log participant and segments
Log transcription segments to troubleshoot duplication issue.
2026-01-23 18:53:10 +01:00
lebaudantoine ff82bca9ec 🐛(frontend) ensure transcript segments are sorted by their timestamp
Switching from Deepgram to our custom Kyutai implementation introduced changes
in how segment data is returned by the LiveKit agent, so the segment start time
is now treated as optional.
2026-01-23 18:22:40 +01:00
45 changed files with 220 additions and 726 deletions
+6 -6
View File
@@ -37,12 +37,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
-3
View File
@@ -77,9 +77,6 @@ db.sqlite3
*.iml
.devcontainer
# Personal rules/config files
rules.md
# Egress output
docker/livekit/out
+9 -1
View File
@@ -7,9 +7,15 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.4.0] - 2026-01-25
### Added
- ✨(frontend) add configurable redirect for unauthenticated users #904
### Changed
- ♿(frontend) improve menu focus management #869
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
@@ -23,6 +29,8 @@ and this project adheres to
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(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
+1 -1
View File
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
## Step 2: Configuration
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
In this example, we assume the following services:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.3.0"
version = "1.4.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+3
View File
@@ -336,6 +336,9 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"external_home_url": values.Value(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.3.0"
version = "1.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
-34
View File
@@ -28,37 +28,3 @@ export default {
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
## Side Panel Focus Pattern
We use a consistent focus management pattern for side panels:
- **Open**: focus the first actionable element inside the panel.
- **Close**: restore focus to the button that opened the panel.
Implementation summary:
1. A provider stores a `panelRef` and a registry of trigger refs (`setTrigger/getTrigger`).
2. Each trigger button registers itself with `setTrigger("key", el)`.
3. Panel content uses `useRestoreFocus` with:
- `resolveTrigger` → returns `getTrigger("key")`.
- `onOpened` → finds the first actionable element inside `panelRef`.
Example:
```tsx
// Trigger button
;<ToggleButton ref={(el) => setTrigger('tools', el)} />
// Panel content
useRestoreFocus(isOpen, {
resolveTrigger: (activeEl) => getTrigger('tools') ?? activeEl,
onOpened: () => {
const first = panelRef.current?.querySelector(
'[data-attr="tools-list"] button'
)
// Leading semicolon avoids ASI issues when a line starts with '('
;(first as HTMLElement | null)?.focus({ preventScroll: true })
},
})
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.3.0",
"version": "1.4.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -17,6 +17,7 @@ export interface ApiConfig {
feedback: {
url: string
}
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
+27 -1
View File
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,6 +19,7 @@ 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 (
@@ -155,9 +156,34 @@ 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>
@@ -10,16 +10,9 @@ import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
const { isAdminOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const { roomId } = useParams()
@@ -45,29 +38,6 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
// Restore focus to the element that opened the Admin panel
useRestoreFocus(isAdminOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('admin') ?? activeEl
},
// Focus the first focusable element when the panel opens (first Field switch)
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first switch in the moderation section
const firstSwitch =
panel.querySelector<HTMLElement>('[role="switch"]')
if (firstSwitch) {
firstSwitch.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<Div
display="flex"
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiAdminLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
@@ -6,7 +5,6 @@ import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { useSidePanel } from '../hooks/useSidePanel'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
export const AdminToggle = ({
variant = 'primaryTextDark',
@@ -16,17 +14,10 @@ export const AdminToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
const { isAdminOpen, toggleAdmin } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
const setAdminTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('admin', el)
},
[setTrigger]
)
const hasAdminAccess = useIsAdminOrOwner()
if (!hasAdminAccess) return null
if (!hasAdminAccess) return
return (
<div
@@ -41,7 +32,6 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isAdminOpen}
ref={setAdminTriggerRef}
onPress={(e) => {
toggleAdmin()
onPress?.(e)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -10,23 +10,8 @@ import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
const useFocusOnOpen = ({ ref, key }) => {
useEffect(() => {
requestAnimationFrame(() => {
const panel = ref.current
if (panel) {
const firstButton = panel.querySelector<HTMLElement>(key)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
}, [])
}
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const panelRef = useRef()
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
@@ -39,8 +24,6 @@ export const Info = () => {
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
useFocusOnOpen({ ref: panelRef, key: '[data-attr="copy-info-sidepanel"]' })
return (
<Div
display="flex"
@@ -49,7 +32,6 @@ export const Info = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
ref={panelRef}
>
<VStack alignItems="start">
<Text
@@ -89,7 +71,7 @@ export const Info = () => {
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard}
data-attr="copy-info-sidepanel"
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
}}
@@ -13,8 +13,6 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useEscapeKey } from '@/hooks/useEscapeKey'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
@@ -26,7 +24,6 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
panelRef: React.RefObject<HTMLElement>
backButtonLabel: string
}
@@ -39,11 +36,9 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
panelRef,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
ref={panelRef}
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -140,7 +135,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
const SidePanelContent = () => {
export const SidePanel = () => {
const {
activePanelId,
isParticipantsOpen,
@@ -154,23 +149,6 @@ const SidePanelContent = () => {
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const panelRef = useSidePanelRef()
useEscapeKey(
() => {
// Close subpanel + panel together for a consistent Escape behavior
if (isSubPanelOpen) {
layoutStore.activeSubPanelId = null
layoutStore.activePanelId = null
return
}
layoutStore.activePanelId = null
},
{
isActive: isSidePanelOpen,
capture: true,
}
)
return (
<StyledSidePanel
@@ -187,14 +165,11 @@ const SidePanelContent = () => {
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
revisit if memory becomes a concern */}
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen} keepAlive={true}>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen} keepAlive={true}>
@@ -203,7 +178,7 @@ const SidePanelContent = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen} keepAlive={true}>
<Panel isOpen={isAdminOpen}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen}>
@@ -212,7 +187,3 @@ const SidePanelContent = () => {
</StyledSidePanel>
)
}
export const SidePanel = () => {
return <SidePanelContent />
}
@@ -5,8 +5,6 @@ import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -100,8 +98,6 @@ export const Tools = () => {
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
@@ -110,30 +106,10 @@ export const Tools = () => {
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return getTrigger('options') ?? activeEl
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return getTrigger('tools') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToolButton in the tools list (transcript or screen recording button)
const toolsList = panel.querySelector<HTMLElement>(
'[data-attr="tools-list"]'
)
if (toolsList) {
const firstToolButton = toolsList.querySelector<HTMLElement>(
'button:first-of-type'
)
if (firstToolButton) {
firstToolButton.focus({ preventScroll: true })
}
}
}
})
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
@@ -165,7 +141,6 @@ export const Tools = () => {
flexDirection="column"
alignItems="start"
gap={0.5}
data-attr="tools-list"
>
<Text
variant="note"
@@ -1,4 +1,3 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
@@ -10,13 +9,13 @@ import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import Source = Track.Source
import { isSafari } from '@/utils/livekit'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
@@ -30,8 +29,6 @@ export const AudioDevicesControl = ({
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
@@ -58,12 +55,6 @@ export const AudioDevicesControl = ({
const canPublishTrack = useCanPublishTrack(TrackSource.MICROPHONE)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="audio-input-select"] button, [data-attr="audio-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -85,12 +76,7 @@ export const AudioDevicesControl = ({
}}
/>
{!hideMenu && (
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
@@ -106,7 +92,6 @@ export const AudioDevicesControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -115,7 +100,6 @@ export const AudioDevicesControl = ({
})}
>
<div
data-attr="audio-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -10,7 +10,6 @@ import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoice
import { useCanPublishTrack } from '../../../hooks/useCanPublishTrack'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
@@ -18,12 +17,10 @@ import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { TrackSource } from '@livekit/protocol'
import { useFocusOnOpen } from '@/hooks/useFocusOnOpen'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
return (
<Button
size="sm"
@@ -31,7 +28,6 @@ const EffectsButton = ({ onPress }: { onPress: () => void }) => {
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
ref={(el) => setTrigger('effects', el)}
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
@@ -54,9 +50,6 @@ export const VideoDeviceControl = ({
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const popoverContentRef = React.useRef<HTMLDivElement>(null)
const { setTrigger } = useSidePanelTriggers()
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
@@ -106,12 +99,6 @@ export const VideoDeviceControl = ({
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
useFocusOnOpen(isMenuOpen, popoverContentRef, {
selector:
'[data-attr="video-input-select"] button, [data-attr="video-input-select"] [role="combobox"]',
delayMs: 250,
})
return (
<div
className={css({
@@ -133,18 +120,12 @@ export const VideoDeviceControl = ({
}}
/>
{!hideMenu && (
<Popover
variant="dark"
withArrow={false}
isOpen={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
ref={(el) => setTrigger('cameraMenu', el)}
variant={
!canPublishTrack || !trackProps.enabled || cannotUseDevice
? 'error2'
@@ -155,7 +136,6 @@ export const VideoDeviceControl = ({
</Button>
{({ close }) => (
<div
ref={popoverContentRef}
className={css({
maxWidth: '36rem',
padding: '0.15rem',
@@ -164,7 +144,6 @@ export const VideoDeviceControl = ({
})}
>
<div
data-attr="video-input-select"
style={{
flex: '1 1 0',
minWidth: 0,
@@ -1,11 +1,9 @@
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { RiInformationLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const InfoToggle = ({
onPress,
@@ -14,18 +12,8 @@ export const InfoToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
const { isInfoOpen, toggleInfo } = useSidePanel()
// const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
const wip = useRef<HTMLElement | null>()
// const setInfoTriggerRef = useCallback(
// (el: HTMLElement | null) => {
// setTrigger('info', el)
// },
// [setTrigger]
// )
return (
<div
className={css({
@@ -39,9 +27,8 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isInfoOpen}
ref={wip}
onPress={(e) => {
toggleInfo(wip)
toggleInfo()
onPress?.(e)
}}
data-attr={`controls-info-${tooltipLabel}`}
@@ -1,19 +1,10 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiMoreFill } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { OptionsMenuItems } from './OptionsMenuItems'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const { setTrigger } = useSidePanelTriggers()
const setOptionsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('options', el)
},
[setTrigger]
)
return (
<Menu variant="dark">
@@ -23,7 +14,6 @@ export const OptionsButton = () => {
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
ref={setOptionsTriggerRef}
>
<RiMoreFill />
</Button>
@@ -70,7 +70,6 @@ export function ParticipantsCollapsableList<T>({
<ToggleHeader
isSelected={isOpen}
aria-label={label}
data-focus-target="list-header"
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
@@ -12,17 +12,10 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const { isParticipantsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
@@ -55,31 +48,6 @@ export const ParticipantsList = () => {
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
// Restore focus to the element that opened the Participants panel
useRestoreFocus(isParticipantsOpen, {
resolveTrigger: (activeEl) => {
return getTrigger('participants') ?? activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
// Use setTimeout + RAF to ensure DOM is fully rendered and transition completed
setTimeout(() => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first ToggleHeader (collapsable list header) in the participants panel
const firstListHeader = panel.querySelector<HTMLElement>(
'button[data-focus-target="list-header"]'
)
firstListHeader?.focus({ preventScroll: true })
}
})
}, 100) // Wait for panel slide-in animation to complete
},
restoreFocusRaf: true,
preventScroll: true,
})
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
return (
<Div overflowY="scroll">
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
@@ -7,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 { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
export const ParticipantsToggle = ({
onPress,
@@ -26,15 +24,8 @@ export const ParticipantsToggle = ({
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
const setParticipantsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('participants', el)
},
[setTrigger]
)
return (
<div
@@ -51,7 +42,6 @@ export const ParticipantsToggle = ({
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
ref={setParticipantsTriggerRef}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
@@ -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 { useEscapeKey } from '@/hooks/useEscapeKey'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
@@ -38,7 +37,6 @@ export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false)
@@ -104,22 +102,6 @@ export const ReactionsToggle = () => {
}
}, [isVisible, isRendered])
useEscapeKey(
() => {
// Mirror the trigger button behavior (Enter toggles open/close)
triggerRef.current?.click()
requestAnimationFrame(() => {
triggerRef.current?.focus({ preventScroll: true })
})
},
{
isActive: isVisible,
capture: true,
preventDefault: true,
stopPropagation: true,
}
)
return (
<>
<div
@@ -132,7 +114,6 @@ export const ReactionsToggle = () => {
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
ref={triggerRef}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
@@ -1,11 +1,9 @@
import { useCallback } from 'react'
import { ToggleButton } from '@/primitives'
import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const ToolsToggle = ({
variant = 'primaryTextDark',
@@ -15,14 +13,7 @@ export const ToolsToggle = ({
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isToolsOpen, toggleTools } = useSidePanel()
const { setTrigger } = useSidePanelTriggers()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
const setToolsTriggerRef = useCallback(
(el: HTMLElement | null) => {
setTrigger('tools', el)
},
[setTrigger]
)
return (
<div
@@ -37,7 +28,6 @@ export const ToolsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isToolsOpen}
ref={setToolsTriggerRef}
onPress={(e) => {
toggleTools()
onPress?.(e)
@@ -5,48 +5,14 @@ import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
export const Effects = () => {
const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const { isEffectsOpen } = useSidePanel()
const panelRef = useSidePanelRef()
const { getTrigger } = useSidePanelTriggers()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
useRestoreFocus(isEffectsOpen, {
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return getTrigger('options') ?? activeEl
}
// For direct button clicks, use the active element as is
return activeEl
},
// Focus the first focusable element when the panel opens
onOpened: () => {
requestAnimationFrame(() => {
const panel = panelRef.current
if (panel) {
// Find the first toggle button (blur light button)
const firstButton = panel.querySelector<HTMLElement>(
'[data-attr="toggle-blur-light"]'
)
if (firstButton) {
firstButton.focus({ preventScroll: true })
}
}
})
},
restoreFocusRaf: true,
preventScroll: true,
})
return (
<div
className={css({
@@ -1,29 +0,0 @@
import { useRef, ReactNode } from 'react'
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
const panelRef = useRef<HTMLElement>(null)
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
triggersRef.current[key] = el
}
const getTrigger = (key: SidePanelTriggerKey) => {
return triggersRef.current[key] ?? null
}
return (
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
{children}
</SidePanelContext.Provider>
)
}
@@ -1,20 +0,0 @@
import { createContext } from 'react'
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement>
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
}
export const SidePanelContext = createContext<SidePanelContextValue | null>(
null
)
@@ -1,6 +1,5 @@
import { ref, useSnapshot } from 'valtio'
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect } from 'react'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -57,13 +56,8 @@ export const useSidePanel = () => {
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = (wip) => {
const toggleInfo = () => {
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (!isInfoOpen) {
layoutStore.genericRef = ref(wip)
}
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
@@ -77,18 +71,6 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS
}
useEffect(() => {
if (!layoutSnap.activePanelId) {
console.log('$$prout closing')
const trigger = layoutSnap?.genericRef?.current
console.log(trigger)
if (trigger) {
trigger.focus({ preventScroll: true })
trigger.setAttribute('data-restore-focus-visible', '')
}
}
}, [layoutSnap.activePanelId, layoutSnap?.genericRef])
return {
activePanelId,
activeSubPanelId,
@@ -1,10 +0,0 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
export const useSidePanelRef = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error('useSidePanelRef must be used within SidePanelProvider')
}
return context.panelRef
}
@@ -1,15 +0,0 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
export const useSidePanelTriggers = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error(
'useSidePanelTriggers must be used within SidePanelProvider'
)
}
return {
setTrigger: context.setTrigger,
getTrigger: context.getTrigger,
}
}
@@ -26,7 +26,6 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { SidePanelProvider } from '../contexts/SidePanelContext'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
@@ -189,77 +188,75 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<SidePanelProvider>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</SidePanelProvider>
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,101 +13,63 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime: number
startTime?: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime: number
startTime?: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptions = (
const updateTranscriptionSegments = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
const segment = segments[0]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
})
}
return {
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
updateTranscriptionSegments,
transcriptionSegments,
}
}
@@ -192,24 +154,54 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionRows, updateTranscriptions, updateParticipant } =
const { transcriptionSegments, updateTranscriptionSegments } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
}
}, [room, updateTranscriptions])
}, [room, updateTranscriptionSegments])
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
}
}, [room, updateParticipant])
return rows
}, [transcriptionSegments])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
-36
View File
@@ -1,36 +0,0 @@
import { useEffect, useRef } from 'react'
type UseEscapeKeyOptions = {
isActive: boolean
capture?: boolean
preventDefault?: boolean
stopPropagation?: boolean
}
export const useEscapeKey = (
handler: () => void,
{
isActive,
capture = false,
preventDefault = false,
stopPropagation = false,
}: UseEscapeKeyOptions
) => {
const handleRef = useRef(handler)
handleRef.current = handler
useEffect(() => {
if (!isActive) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (preventDefault) event.preventDefault()
if (stopPropagation) event.stopPropagation()
handleRef.current()
}
document.addEventListener('keydown', onKeyDown, capture)
return () => {
document.removeEventListener('keydown', onKeyDown, capture)
}
}, [capture, isActive, preventDefault, stopPropagation])
}
-24
View File
@@ -1,24 +0,0 @@
import { useEffect } from 'react'
type UseFocusOnOpenOptions = {
selector: string
delayMs?: number
preventScroll?: boolean
}
export const useFocusOnOpen = (
isOpen: boolean,
containerRef: React.RefObject<HTMLElement>,
{ selector, delayMs = 0, preventScroll = true }: UseFocusOnOpenOptions
) => {
useEffect(() => {
if (!isOpen) return
const timer = setTimeout(() => {
requestAnimationFrame(() => {
const first = containerRef.current?.querySelector<HTMLElement>(selector)
first?.focus({ preventScroll })
})
}, delayMs)
return () => clearTimeout(timer)
}, [containerRef, delayMs, isOpen, preventScroll, selector])
}
+1 -48
View File
@@ -26,26 +26,6 @@ export function useRestoreFocus(
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
useEffect(() => {
// Track last interaction type (like native :focus-visible behavior)
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevIsOpenRef.current
@@ -61,28 +41,7 @@ export function useRestoreFocus(
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => {
trigger.focus({ preventScroll })
// Only show focus ring if last interaction was keyboard (like native :focus-visible)
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
// Remove focus ring only when the trigger loses focus
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
// Store cleanup for unmount case
cleanupRef.current?.()
cleanupRef.current = () => {
trigger.removeEventListener('blur', handleBlur)
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
}
}
const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
@@ -91,12 +50,6 @@ export function useRestoreFocus(
}
prevIsOpenRef.current = isOpen
// Cleanup: remove focus ring if component unmounts before focus changes
return () => {
cleanupRef.current?.()
cleanupRef.current = null
}
}, [
isOpen,
onClosed,
+1 -11
View File
@@ -70,9 +70,6 @@ export const Popover = ({
children,
variant = 'light',
withArrow = true,
isOpen,
defaultOpen,
onOpenChange,
...dialogProps
}: {
children: [
@@ -83,17 +80,10 @@ export const Popover = ({
]
variant?: 'dark' | 'light'
withArrow?: boolean
isOpen?: boolean
defaultOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
} & Omit<DialogProps, 'children'>) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger
isOpen={isOpen}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<DialogTrigger>
{trigger}
<StyledPopover>
{withArrow && (
-2
View File
@@ -10,7 +10,6 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
genericRef: HTMLElement | null
}
export const layoutStore = proxy<State>({
@@ -19,5 +18,4 @@ export const layoutStore = proxy<State>({
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
genericRef: null,
})
+5 -12
View File
@@ -23,20 +23,9 @@ body,
}
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
[data-rac][data-restore-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
):focus-visible,
/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus,
/* Show focus ring when restoring focus on react-aria buttons without overriding its focus state */
[data-restore-focus-visible]:is(
a,
button,
input[type='text'],
select,
textarea
):focus {
):focus-visible {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 1px;
}
@@ -56,6 +45,10 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
display: none !important;
}
html:has(.lk-video-conference) {
scrollbar-gutter: auto !important;
}
@keyframes slide-full {
from {
transform: translateY(100%);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.4.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.3.0",
"version": "1.4.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.4.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",