Compare commits

..

14 Commits

Author SHA1 Message Date
lebaudantoine 9333d72538 (frontend) introduce a recording toaster
Notify visually users that the room is being recorded.
Draft, it will be enhance the future.
2024-12-04 18:33:30 +01:00
lebaudantoine 5ecb05ba57 ♻️(frontend) refactor pulse_mic into pulse_background
Make the keyframe more generic with an explicit naming.
2024-12-04 18:24:21 +01:00
lebaudantoine f7d07f77a7 🩹(frontend) fix submit button in feedbacks page
Since recent changes in the Color palette, the button was totally
invisible… fixed it.
2024-12-04 18:21:57 +01:00
lebaudantoine 7a22831cb9 🔥(frontend) remove transcription menu item
This menu item was replaced by a side panel.
2024-12-04 18:20:35 +01:00
lebaudantoine 1a409da941 (frontend) introduce a sidepanel for AI assistant
Introduce the content for the AI assistant panel, which describes the
feature, and offers a button to start and stop a recording.
2024-12-04 18:18:14 +01:00
lebaudantoine db8626adcc ♻️(frontend) introduce useRoomId hook
Manipulating the room's id from the react-query cache should be
encapsulated in a dedicated hook.
2024-12-04 18:17:32 +01:00
lebaudantoine eaf2c4d021 ♻️(frontend) package checks in useHasTranscriptAccess hook
This hook will be used by the toggle and the sidepanel to make
sure the users have sufficient permissions to access the transcript
features.
2024-12-04 18:17:32 +01:00
lebaudantoine bad5d1de3f ♻️(frontend) introduce a reusable isTranscriptEnabled
Encapsulate the logic, checking if the feature is enabled in the
backend, in a proper and reusable hook.
2024-12-04 18:17:32 +01:00
lebaudantoine 5e31ca727a 🚩(frontend) enable transcript toggle with a feature flag
Rely on Posthog for a first iteration on the feature flag feature.
This is a pragmatic choice, relying on an external dependency might
not suitable on the longer term, however, compare to the maturity
of our product, this is the best trade off.
2024-12-04 18:17:32 +01:00
lebaudantoine 5f42fcd159 🚚(frontend) remove wrong 'tsx' extension
The hook only uses typescript code.
2024-12-04 18:17:32 +01:00
lebaudantoine 985b621e14 📈(frontend) check if analytic is enabled
Few frontend features rely on Posthog. Posthog is not activated in
dev environment. Offer a hook that encapsulates this logic, and
return a boolean flag.
2024-12-04 18:17:32 +01:00
lebaudantoine d2d66fb8b6 (frontend) initialize transcript sidebar panel
Setup base structure and styling for transcript menu sidebar
2024-12-04 18:17:32 +01:00
lebaudantoine 4b4f16e93f 🔖(patch) bump release to 0.1.10
I made a mistake, and forgot to set summary and celery
deployment to 0 replicas.
2024-12-03 02:31:10 +01:00
lebaudantoine 3e3f964ac5 🚑️(helm) set celery and summary replicas to 0
These two services are not yet needed in production. Disable them.
2024-12-03 02:25:52 +01:00
28 changed files with 357 additions and 112 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.9"
version = "0.1.10"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.9",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.9",
"version": "0.1.10",
"dependencies": {
"@livekit/components-react": "2.6.9",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.9",
"version": "0.1.10",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+4 -4
View File
@@ -116,10 +116,10 @@ const config: Config = {
'80%': { transform: 'rotate(20deg)' },
'100%': { transform: 'rotate(0)' },
},
pulse_mic: {
'0%': { color: 'primary', opacity: '1' },
'50%': { color: 'primary', opacity: '0.8' },
'100%': { color: 'primary', opacity: '1' },
pulse_background: {
'0%': { opacity: '1' },
'50%': { opacity: '0.65' },
'100%': { opacity: '1' },
},
},
tokens: defineTokens({
@@ -0,0 +1,6 @@
import { useConfig } from '@/api/useConfig.ts'
export const useIsAnalyticsEnabled = () => {
const { data } = useConfig()
return !!data?.analytics?.id
}
@@ -121,7 +121,13 @@ const OpenFeedback = ({
>
{t('submit')}
</Button>
<Button invisible size="sm" fullWidth onPress={onNext}>
<Button
invisible
variant="secondary"
size="sm"
fullWidth
onPress={onNext}
>
{t('skip')}
</Button>
</VStack>
@@ -0,0 +1,43 @@
import { css } from '@/styled-system/css'
import { RiRecordCircleLine } from '@remixicon/react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useRoomContext } from '@livekit/components-react'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
const room = useRoomContext()
if (!room?.isRecording) return
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.25rem 0.35rem',
backgroundColor: 'primaryDark.200',
borderColor: 'primaryDark.400',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RiRecordCircleLine
size={20}
className={css({
color: 'white',
backgroundColor: 'danger.700',
padding: '3px',
borderRadius: '3px',
})}
/>
<Text variant={'sm'}>{t('label')}</Text>
</div>
)
}
@@ -10,6 +10,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
type StyledSidePanelProps = {
title: string
@@ -106,6 +107,7 @@ export const SidePanel = () => {
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
isTranscriptOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
@@ -127,6 +129,9 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
<Panel isOpen={isTranscriptOpen}>
<Transcript />
</Panel>
</StyledSidePanel>
)
}
@@ -0,0 +1,100 @@
import { Button, Div, H, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
export const Transcript = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const hasTranscriptAccess = useHasTranscriptAccess()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
if (!hasTranscriptAccess) return
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img src={thirdSlide} alt={'wip'} />
{room.isRecording ? (
<>
<H lvl={2}>{t('stop.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('stop.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('stop.button')}
</Button>
</>
) : (
<>
<H lvl={2}>{t('start.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('start.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('start.button')}
</Button>
</>
)}
</Div>
)
}
@@ -10,7 +10,6 @@ import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { TranscriptMenuItem } from './TranscriptMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = ({
@@ -35,7 +34,6 @@ export const OptionsMenuItems = ({
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<TranscriptMenuItem />
</Section>
<Separator />
<Section>
@@ -1,71 +0,0 @@
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { MenuItem } from 'react-aria-components'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useConfig } from '@/api/useConfig'
export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const apiRoomData = useRoomData()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const { data } = useConfig()
const room = useRoomContext()
const handleTranscript = async () => {
const roomId = apiRoomData?.livekit?.room
if (!roomId) {
console.warn('No room ID found')
return
}
try {
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
}
} catch (error) {
console.error('Failed to handle transcript:', error)
}
}
if (
!data?.recording?.is_enabled ||
!data?.recording?.available_modes?.includes(RecordingMode.Transcript) ||
!apiRoomData?.is_administrable
) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true }).item}
onAction={async () => await handleTranscript()}
>
{room.isRecording ? (
<>
<RiRecordCircleLine size={20} />
{t('transcript.stop')}
</>
) : (
<>
<RiStopCircleLine size={20} />
{t('transcript.start')}
</>
)}
</MenuItem>
)
}
@@ -84,9 +84,12 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
<RiMicOffFill color={'gray'} />
) : (
<RiMicFill
style={{
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
}}
className={css({
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
animation: isSpeaking
? 'pulse_background 800ms infinite'
: undefined,
})}
/>
)}
</Button>
@@ -0,0 +1,37 @@
import { ToggleButton } from '@/primitives'
import { RiBardLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
export const TranscriptToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
const hasTranscriptAccess = useHasTranscriptAccess()
if (!hasTranscriptAccess) return
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
onPress={() => toggleTranscript()}
>
<RiBardLine />
</ToggleButton>
</div>
)
}
@@ -0,0 +1,17 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsTranscriptEnabled()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isTranscriptEnabled
)
}
@@ -0,0 +1,6 @@
import { useRoomData } from './useRoomData'
export const useIsAdminOrOwner = () => {
const apiRoomData = useRoomData()
return apiRoomData?.is_administrable
}
@@ -0,0 +1,11 @@
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsTranscriptEnabled = () => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
)
}
@@ -0,0 +1,6 @@
import { useRoomData } from './useRoomData'
export const useRoomId = () => {
const apiRoomData = useRoomData()
return apiRoomData?.livekit?.room
}
@@ -5,6 +5,7 @@ export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TRANSCRIPT = 'transcript',
}
export const useSidePanel = () => {
@@ -14,6 +15,7 @@ export const useSidePanel = () => {
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
const isSidePanelOpen = !!activePanelId
const toggleParticipants = () => {
@@ -28,14 +30,20 @@ export const useSidePanel = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
}
const toggleTranscript = () => {
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
}
return {
activePanelId,
toggleParticipants,
toggleChat,
toggleEffects,
toggleTranscript,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
isTranscriptOpen,
}
}
@@ -13,8 +13,10 @@ import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { SupportToggle } from '../components/controls/SupportToggle'
import { TranscriptToggle } from '../components/controls/TranscriptToggle'
import { css } from '@/styled-system/css'
import { SupportToggle } from '@/features/rooms/livekit/components/controls/SupportToggle.tsx'
/** @public */
export type ControlBarControls = {
@@ -156,6 +158,7 @@ export function ControlBar({
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
</div>
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '../components/RecordingStateToast'
const LayoutWrapper = styled(
'div',
@@ -212,6 +213,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
</div>
)
}
+24 -7
View File
@@ -71,6 +71,10 @@
"open": "",
"closed": ""
},
"transcript": {
"open": "",
"closed": ""
},
"support": ""
},
"options": {
@@ -79,11 +83,7 @@
"feedbacks": "",
"settings": "",
"username": "",
"effects": "",
"transcript": {
"start": "",
"stop": ""
}
"effects": ""
}
},
"effects": {
@@ -102,18 +102,32 @@
"heading": {
"participants": "",
"effects": "",
"chat": ""
"chat": "",
"transcript": ""
},
"content": {
"participants": "",
"effects": "",
"chat": ""
"chat": "",
"transcript": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"transcript": {
"start": {
"heading": "",
"body": "",
"button": ""
},
"stop": {
"heading": "",
"body": "",
"button": ""
}
},
"rating": {
"submit": "",
"question": "",
@@ -150,5 +164,8 @@
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
},
"recording": {
"label": ""
}
}
+24 -7
View File
@@ -70,6 +70,10 @@
"open": "Hide everyone",
"closed": "See everyone"
},
"transcript": {
"open": "Hide AI assistant",
"closed": "Show AI assistant"
},
"support": "Support"
},
"options": {
@@ -78,11 +82,7 @@
"feedbacks": "Give us feedbacks",
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects",
"transcript": {
"start": "Start meeting transcription",
"stop": "Stop ongoing transcription"
}
"effects": "Apply effects"
}
},
"effects": {
@@ -101,18 +101,32 @@
"heading": {
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat"
"chat": "Messages in the chat",
"transcript": "AI Assistant"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages"
"chat": "messages",
"transcript": "AI assistant"
},
"closeButton": "Hide {{content}}"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"transcript": {
"start": {
"heading": "Start the Assistant!",
"body": "The assistant automatically starts recording your meeting audio (limited to 1 hour). At the end, you'll receive a clear and concise summary of the discussion directly via email.",
"button": "Start"
},
"stop": {
"heading": "Recording in Progress...",
"body": "Your meeting is currently being recorded. You will receive a summary via email once the meeting ends.",
"button": "Stop Recording"
}
},
"rating": {
"submit": "Submit",
"question": "What do you think about the quality of your call?",
@@ -149,5 +163,8 @@
"raisedHands": "Raised hands",
"lowerParticipantHand": "Lower {{name}}'s hand",
"lowerParticipantsHand": "Lower all hands"
},
"recording": {
"label": "Recording"
}
}
+24 -7
View File
@@ -70,6 +70,10 @@
"open": "Masquer les participants",
"closed": "Afficher les participants"
},
"transcript": {
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
},
"support": "Support"
},
"options": {
@@ -78,11 +82,7 @@
"feedbacks": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets",
"transcript": {
"start": "Démarrer la transcription",
"stop": "Arrêter la transcription en cours"
}
"effects": "Appliquer des effets"
}
},
"effects": {
@@ -101,18 +101,32 @@
"heading": {
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel"
"chat": "Messages dans l'appel",
"transcript": "Assistant IA"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages"
"chat": "les messages",
"transcript": "l'assistant IA"
},
"closeButton": "Masquer {{content}}"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"transcript": {
"start": {
"heading": "Démarrer l'assistant !",
"body": "L'assistant démarre automatiquement l'enregistrement sonore de votre réunion (limité à 1h). À la fin, vous recevrez un résumé clair et concis des échanges directement par e-mail.",
"button": "Démarrer"
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "L'enregistrement de votre réunion est en cours. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Arrêter l'enregistrement"
}
},
"rating": {
"submit": "Envoyer",
"question": "Que pensez-vous de la qualité de votre appel ?",
@@ -149,5 +163,8 @@
"raisedHands": "Mains levées",
"lowerParticipantHand": "Baisser la main de {{name}}",
"lowerParticipantsHand": "Baisser la main de tous les participants"
},
"recording": {
"label": "Enregistrement"
}
}
+8
View File
@@ -55,6 +55,14 @@ export const text = cva({
textAlign: 'inherit',
},
},
wrap: {
balance: {
textWrap: 'balance',
},
pretty: {
textWrap: 'pretty',
},
},
bold: {
true: {
fontWeight: 'bold',
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.9"
tag: "v0.1.10"
backend:
migrateJobAnnotations:
@@ -129,7 +129,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.9"
tag: "v0.1.10"
ingress:
enabled: true
@@ -165,3 +165,9 @@ posthog:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/upstream-vhost: eu-assets.i.posthog.com
nginx.ingress.kubernetes.io/backend-protocol: https
summary:
replicas: 0
celery:
replicas: 0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.9",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.9",
"version": "0.1.10",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.9",
"version": "0.1.10",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {