Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine c149c8ce9c 💄(frontend) add minor layout adjustments
Propose minor layout adjustments to ensure the DINUM version with French
copywriting does not look visually awkward due to line breaks.
2026-01-05 17:43:40 +01:00
lebaudantoine 198442b137 🐛(backend) fix certificates volume mount path for Python 3.13
After upgrading Python to 3.13, not all development environments were
updated accordingly. This fixes the incorrect volume mount path
introduced by that upgrade.
2026-01-05 17:43:37 +01:00
26 changed files with 133 additions and 275 deletions
+1 -13
View File
@@ -8,25 +8,13 @@ and this project adheres to
## [Unreleased]
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve SR and focus for transcript and recording #810
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(frontend) remove the beta badge
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.2.0"
version = "1.1.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.2.0"
version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
"aiohttp==3.13.2",
]
[project.urls]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.2.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.2.0",
"version": "1.1.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.2.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -42,17 +42,6 @@ export const ControlsButton = ({
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
@@ -108,7 +97,6 @@ export const ControlsButton = ({
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
@@ -174,7 +162,6 @@ export const ControlsButton = ({
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useMemo } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -21,9 +21,6 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -70,23 +67,6 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const hasScreenRecordingAccessAndActive =
@@ -94,74 +74,61 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
)
}
@@ -5,6 +5,7 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '@/features/recording'
@@ -18,6 +19,7 @@ import {
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
@@ -26,7 +28,6 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -37,7 +38,10 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -94,17 +98,14 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
posthog.capture('screen-recording-started', {})
}
} catch (error) {
console.error('Failed to handle recording:', error)
}
}
if (!isAdminOrOwner) {
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -117,10 +117,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -164,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -4,7 +4,6 @@ import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -9,7 +9,6 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React from 'react'
import React, { useEffect } from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -39,16 +38,33 @@ export function Chat({ ...props }: ChatProps) {
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -73,7 +73,6 @@ export function MobileControlBar({
/>
<HandToggle />
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -37,7 +37,6 @@ export const LateralMenu = () => {
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
id="controlbar-more-options-trigger"
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
-61
View File
@@ -1,61 +0,0 @@
import { useEffect, useRef } from 'react'
export type RestoreFocusOptions = {
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
onOpened?: () => void
onClosed?: () => void
restoreFocusRaf?: boolean
preventScroll?: boolean
}
/**
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
* when the panel/menu closes.
*/
export function useRestoreFocus(
isOpen: boolean,
options: RestoreFocusOptions = {}
) {
const {
resolveTrigger,
onOpened,
onClosed,
restoreFocusRaf = false,
preventScroll = true,
} = options
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const wasOpen = prevIsOpenRef.current
// Just opened
if (!wasOpen && isOpen) {
const activeEl = document.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
onOpened?.()
}
// Just closed
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
}, [
isOpen,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
])
}
+22 -21
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react'
import { ReactNode } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,24 +17,25 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
-12
View File
@@ -7,18 +7,6 @@ body,
height: 100%;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
* {
outline: 2px solid transparent;
}
@@ -150,7 +150,7 @@ summary:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -187,7 +187,7 @@ celeryTranscribe:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -225,7 +225,7 @@ celerySummarize:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.2.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.2.0",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.2.0",
"version": "1.1.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.2.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.2.0",
"version": "1.1.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.2.0",
"version": "1.1.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.2.0"
version = "1.1.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+1 -7
View File
@@ -21,14 +21,8 @@ class FileService:
"""Initialize FileService with MinIO client and configuration."""
self._logger = logger
endpoint = (
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio(
endpoint,
settings.aws_s3_endpoint_url,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,