Compare commits

...

14 Commits

Author SHA1 Message Date
lebaudantoine 8db00ae281 wip avoid displaying transcript if feature not enable 2025-04-03 22:46:08 +02:00
lebaudantoine 272469acd8 wip add beta testeur 2025-04-03 22:43:28 +02:00
lebaudantoine 89c5dac927 wip support target in Link 2025-04-03 22:39:02 +02:00
lebaudantoine 03f9b5e12d wip extract links in a dedicated files 2025-04-03 22:33:29 +02:00
lebaudantoine 4ea0231744 wip enhance state management transcript 2025-04-03 19:18:58 +02:00
lebaudantoine 95f2d5de52 wip disabled tertiary 2025-04-03 18:06:19 +02:00
lebaudantoine ab3307b906 wip font button 2025-04-03 18:02:16 +02:00
lebaudantoine c269bd9496 wip refactor transcript toast 2025-04-03 18:00:40 +02:00
lebaudantoine ae3cf2fbf7 wip refactor sidepannel 2025-04-03 18:00:24 +02:00
lebaudantoine cd3a9567c7 wip animation link 2025-04-03 17:20:13 +02:00
lebaudantoine 8e18efbd7a wip rework transcribing toast 2025-04-03 17:10:41 +02:00
lebaudantoine 0a4d09186d wip parametrize spinner 2025-04-03 17:01:40 +02:00
lebaudantoine cd57cb2b80 wip introduce subpanel 2025-04-03 16:15:18 +02:00
lebaudantoine 9e9e0a0b47 wip draft 2025-04-02 17:15:15 +02:00
30 changed files with 645 additions and 173 deletions
@@ -8,10 +8,7 @@ import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
// todo - extract in a proper env variable
const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
import { BETA_USERS_FORM_URL } from '@/utils/constants'
const Heading = styled('h2', {
base: {
@@ -88,6 +88,24 @@ export const MainNotificationToast = () => {
if (notification.data?.emoji)
handleEmoji(notification.data.emoji, participant)
break
case NotificationType.TranscriptionStarted:
toastQueue.add(
{
participant,
type: NotificationType.TranscriptionStarted,
},
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.TranscriptionStopped:
toastQueue.add(
{
participant,
type: NotificationType.TranscriptionStopped,
},
{ timeout: NotificationDuration.ALERT }
)
break
default:
return
}
@@ -6,4 +6,6 @@ export enum NotificationType {
LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived',
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
}
@@ -9,6 +9,7 @@ import { ToastRaised } from './ToastRaised'
import { ToastMuted } from './ToastMuted'
import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastTranscription } from '@/features/notifications/components/ToastTranscription.tsx'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -36,6 +37,12 @@ const renderToast = (
case NotificationType.LowerHand:
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionStarted:
return <ToastTranscription key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionStopped:
return <ToastTranscription key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -0,0 +1,35 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
export function ToastTranscription({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(
props.toast.content.type == NotificationType.TranscriptionStarted
? 'recordingStarted'
: 'recordingStopped',
{
name: participant.name,
}
)}
</HStack>
</StyledToastContainer>
)
}
@@ -1,43 +0,0 @@
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>
)
}
@@ -3,15 +3,15 @@ import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
import { Button, Div } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from '@/features/rooms/livekit/components/Tools.tsx'
type StyledSidePanelProps = {
title: string
@@ -19,6 +19,8 @@ type StyledSidePanelProps = {
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
}
const StyledSidePanel = ({
@@ -27,6 +29,8 @@ const StyledSidePanel = ({
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
}: StyledSidePanelProps) => (
<div
className={css({
@@ -61,9 +65,22 @@ const StyledSidePanel = ({
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : undefined,
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && (
<Button
variant="secondaryText"
size={'sm'}
square
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
>
<RiArrowLeftLine size={20} />
</Button>
)}
{title}
</Heading>
<Div
@@ -114,19 +131,25 @@ export const SidePanel = () => {
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
isTranscriptOpen,
isToolsOpen,
isAdminOpen,
isSubPanelOpen,
activeSubPanelId,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
return (
<StyledSidePanel
title={t(`heading.${activePanelId}`)}
onClose={() => (layoutStore.activePanelId = null)}
title={t(`heading.${activeSubPanelId || activePanelId}`)}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activePanelId}`),
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
@@ -137,8 +160,8 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
<Panel isOpen={isTranscriptOpen}>
<Transcript />
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
<Admin />
@@ -0,0 +1,102 @@
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { RiFileTextFill } from '@remixicon/react'
import { Transcript } from '@/features/rooms/livekit/components/Transcript'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useTranslation } from 'react-i18next'
import { useIsTranscriptEnabled } from '@/features/rooms/livekit/hooks/useIsTranscriptEnabled.ts'
const CRISP_HELP_ARTICLE =
'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'
const ActionButton = ({ icon, title, description, onPress }) => {
return (
<RACButton
className={css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'start',
paddingY: '0.5rem',
paddingX: '0.75rem 1.5rem',
borderRadius: '5px',
gap: '1.25rem',
width: 'full',
textAlign: 'start',
'&[data-hovered]': {
backgroundColor: 'primary.50',
cursor: 'pointer',
},
})}
onPress={onPress}
>
<div
className={css({
height: '50px',
minWidth: '50px',
borderRadius: '25px',
backgroundColor: 'primary.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
{icon}
</div>
<div>
<Text margin={false} as="h3">
{title}
</Text>
<Text as="p" variant="smNote" wrap="pretty">
{description}
</Text>
</div>
</RACButton>
)
}
export const Tools = () => {
const { openTranscript, isTranscriptOpen } = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsTranscriptEnabled()
if (isTranscriptOpen && isTranscriptEnabled) {
return <Transcript />
}
return (
<Div
display="flex"
overflowY="scroll"
padding="0 0.75rem"
flexGrow={1}
flexDirection="column"
alignItems="start"
>
<Text
variant="note"
wrap="balance"
className={css({
textStyle: 'sm',
paddingX: '0.75rem',
})}
margin="md"
>
{t('body')}{' '}
<A href={CRISP_HELP_ARTICLE} target="_blank">
{t('moreLink')}
</A>
.
</Text>
{isTranscriptEnabled && (
<ActionButton
icon={<RiFileTextFill size={24} color="white" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
/>
)}
</Div>
)
}
@@ -1,10 +1,7 @@
import { Button, Div, H, Text } from '@/primitives'
import { A, Button, Div, LinkButton, 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 {
@@ -12,22 +9,34 @@ import {
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { NotificationPayload } from '@/features/notifications/NotificationPayload.ts'
import { NotificationType } from '@/features/notifications/NotificationType.ts'
import { useSnapshot } from 'valtio/index'
import {
TranscriptionStatus,
transcriptionStore,
} from '@/stores/transcription.ts'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess.ts'
import { BETA_USERS_FORM_URL } from '@/utils/constants.ts'
const CRISP_HELP_ARTICLE =
'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x'
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 transcriptionSnap = useSnapshot(transcriptionStore)
const room = useRoomContext()
useEffect(() => {
@@ -40,6 +49,17 @@ export const Transcript = () => {
}
}, [room])
const notifyParticipant = async (status) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: status,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
})
}
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
@@ -49,8 +69,12 @@ export const Transcript = () => {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
await notifyParticipant(NotificationType.TranscriptionStopped)
transcriptionStore.status = TranscriptionStatus.STOPPING
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
await notifyParticipant(NotificationType.TranscriptionStarted)
transcriptionStore.status = TranscriptionStatus.STARTING
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -58,7 +82,13 @@ export const Transcript = () => {
}
}
if (!hasTranscriptAccess) return
const isDisabled = useMemo(
() =>
isLoading ||
transcriptionSnap.status === TranscriptionStatus.STARTING ||
transcriptionSnap.status === TranscriptionStatus.STOPPING,
[isLoading, transcriptionSnap]
)
return (
<Div
@@ -69,38 +99,98 @@ export const Transcript = () => {
flexDirection="column"
alignItems="center"
>
<img src={thirdSlide} alt={'wip'} />
{room.isRecording ? (
<img
src={thirdSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{!hasTranscriptAccess ? (
<>
<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()}
data-attr="stop-transcript"
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('stop.button')}
</Button>
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<LinkButton
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{t('beta.button')}
</LinkButton>
</>
) : (
<>
<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()}
data-attr="start-transcript"
>
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('start.button')}
</Button>
{room.isRecording ? (
<>
<Text>{t('stop.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
<Text>{t('start.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{t('start.button')}
</Button>
</>
)}
</>
)}
</Div>
@@ -0,0 +1,105 @@
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo, useState } from 'react'
import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils.ts'
import { NotificationType } from '@/features/notifications/NotificationType.ts'
import { useSnapshot } from 'valtio/index'
import {
TranscriptionStatus,
transcriptionStore,
} from '@/stores/transcription.ts'
export const TranscriptStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
const room = useRoomContext()
const transcriptionSnap = useSnapshot(transcriptionStore)
useEffect(() => {
if (room.isRecording) {
transcriptionStore.status = TranscriptionStatus.STARTED
}
}, [])
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return
switch (notification.type) {
case NotificationType.TranscriptionStarted:
transcriptionStore.status = TranscriptionStatus.STARTING
break
case NotificationType.TranscriptionStopped:
transcriptionStore.status = TranscriptionStatus.STOPPING
break
default:
return
}
}
const handleRecordingStatusChanged = (status) => {
transcriptionStore.status = status
? TranscriptionStatus.STARTED
: TranscriptionStatus.STOPPED
}
room.on(RoomEvent.DataReceived, handleDataReceived)
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const message = useMemo(() => {
switch (transcriptionSnap.status) {
case TranscriptionStatus.STOPPING:
return t('transcriptStopping')
case TranscriptionStatus.STARTING:
return t('transcriptStarting')
default:
return t('transcriptStarted')
}
}, [transcriptionSnap])
if (transcriptionSnap.status == TranscriptionStatus.STOPPED) return
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'primaryDark.100',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<Spinner size={20} variant="black" />
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{message}
</Text>
</div>
)
}
@@ -1,24 +1,19 @@
import { ToggleButton } from '@/primitives'
import { RiBardLine } from '@remixicon/react'
import { RiShapesLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = ({
export const ToolsToggle = ({
variant = 'primaryTextDark',
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
const hasTranscriptAccess = useHasTranscriptAccess()
if (!hasTranscriptAccess) return
const { isToolsOpen, toggleTools } = useSidePanel()
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
return (
<div
@@ -32,15 +27,15 @@ export const TranscriptToggle = ({
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
isSelected={isToolsOpen}
onPress={(e) => {
toggleTranscript()
toggleTools()
onPress?.(e)
}}
{...props}
data-attr="toggle-transcript"
data-attr="toggle-tools"
>
<RiBardLine />
<RiShapesLine />
</ToggleButton>
</div>
)
@@ -5,53 +5,73 @@ export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TRANSCRIPT = 'transcript',
TOOLS = 'tools',
ADMIN = 'admin',
}
export enum SubPanelId {
TRANSCRIPT = 'transcript',
}
export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
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 toggleTranscript = () => {
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
const toggleTools = () => {
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
}
return {
activePanelId,
activeSubPanelId,
toggleParticipants,
toggleChat,
toggleEffects,
toggleTranscript,
toggleTools,
toggleAdmin,
openTranscript,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
isTranscriptOpen,
isToolsOpen,
isAdminOpen,
isTranscriptOpen,
}
}
@@ -22,8 +22,9 @@ import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { ToolsToggle } from '../../components/controls/ToolsToggle.tsx'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { GRIST_FORM } from '@/utils/constants'
export function MobileControlBar({
onDeviceError,
@@ -134,7 +135,7 @@ export function MobileControlBar({
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<TranscriptToggle
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
@@ -155,7 +156,7 @@ export function MobileControlBar({
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
href={GRIST_FORM}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
@@ -2,7 +2,7 @@ import { css } from '@/styled-system/css'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
import { ToolsToggle } from '../../components/controls/ToolsToggle.tsx'
import { AdminToggle } from '../../components/AdminToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react'
@@ -18,7 +18,7 @@ const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
<>
<ChatToggle onPress={onPress} />
<ParticipantsToggle onPress={onPress} />
<TranscriptToggle onPress={onPress} />
<ToolsToggle onPress={onPress} />
<SupportToggle onPress={onPress} />
<AdminToggle onPress={onPress} />
</>
@@ -28,7 +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'
import { TranscriptStateToast } from '../components/TranscriptStateToast'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
@@ -231,7 +231,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
<TranscriptStateToast />
</div>
)
}
@@ -21,5 +21,7 @@
"several": "",
"open": "",
"accept": ""
}
},
"recordingStarted": "",
"recordingStopped": ""
}
+22 -7
View File
@@ -104,7 +104,7 @@
"open": "",
"closed": ""
},
"transcript": {
"tools": {
"open": "",
"closed": ""
},
@@ -168,26 +168,39 @@
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"admin": ""
"tools": "",
"admin": "",
"transcript": ""
},
"content": {
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"admin": ""
"tools": "",
"admin": "",
"transcript": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"moreTools": {
"body": "",
"moreLink": "",
"tools": {
"transcript": {
"title": "",
"body": ""
}
}
},
"transcript": {
"start": {
"heading": "",
"body": "",
"button": ""
"button": "",
"linkMore": ""
},
"stop": {
"heading": "",
@@ -275,7 +288,9 @@
}
},
"recording": {
"label": ""
"transcriptStarted": "",
"transcriptStarting": "",
"transcriptStopping": ""
},
"participantTileFocus": {
"pin": {
@@ -21,5 +21,7 @@
"several": "Several people want to join this call.",
"open": "Open",
"accept": "Accept"
}
},
"recordingStarted": "{{name}} started the meeting transcription.",
"recordingStopped": "{{name}} stopped the meeting transcription."
}
+31 -11
View File
@@ -103,9 +103,9 @@
"open": "Hide everyone",
"closed": "See everyone"
},
"transcript": {
"open": "Hide AI assistant",
"closed": "Show AI assistant"
"tools": {
"open": "Hide more tools",
"closed": "Show more tools"
},
"admin": {
"open": "Hide admin",
@@ -167,31 +167,49 @@
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "AI Assistant",
"admin": "Admin settings"
"tools": "More tools",
"admin": "Admin settings",
"transcript": "Transcription"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages",
"transcript": "AI assistant",
"admin": "Admin settings"
"tools": "more tools",
"admin": "Admin settings",
"transcript": "Transcription"
},
"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."
},
"moreTools": {
"body": "Access more tools in Visio to enhance your meetings,",
"moreLink": "learn more",
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcribe your meeting for later"
}
}
},
"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"
"heading": "Transcribe this call",
"body": "Automatically transcribe this call and receive the summary in Docs,",
"button": "Start transcription",
"linkMore": "Learn more"
},
"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"
},
"beta": {
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
"button": "Sign up"
}
},
"admin": {
@@ -274,7 +292,9 @@
}
},
"recording": {
"label": "Recording"
"transcriptStarted": "Transcribing",
"transcriptStarting": "Transcription starting",
"transcriptStopping": "Transcription stopping"
},
"participantTileFocus": {
"pin": {
@@ -21,5 +21,7 @@
"several": "Plusieurs personnes souhaitent participer à cet appel.",
"open": "Afficher",
"accept": "Accepter"
}
},
"recordingStarted": "{{name}} a démarré la transcription de la réunion.",
"recordingStopped": "{{name}} a arrêté la transcription de la réunion."
}
+32 -12
View File
@@ -103,9 +103,9 @@
"open": "Masquer les participants",
"closed": "Afficher les participants"
},
"transcript": {
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
"tools": {
"open": "Masquer plus d'outils",
"closed": "Afficher plus d'outils"
},
"admin": {
"open": "Masquer l'admin",
@@ -167,31 +167,49 @@
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Assistant IA",
"admin": "Commandes de l'organisateur"
"tools": "Plus d'outils",
"admin": "Commandes de l'organisateur",
"transcript": "Transcription"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages",
"transcript": "l'assistant IA",
"admin": "Commandes de l'organisateur"
"tools": "plus d'outils",
"admin": "Commandes de l'organisateur",
"transcript": "Transcription"
},
"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."
},
"moreTools": {
"body": "Accèder à d'avantage d'outils dans Visio pour améliorer vos réunions,",
"moreLink": "en savoir plus",
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcrivez votre réunion pour plus tard"
}
}
},
"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"
"heading": "Transcrire cet appel",
"body": "Transcrivez cet appel automatiquement et recevez le compte rendu dans Docs.",
"button": "Démarrer la transcription",
"linkMore": "En savoir plus"
},
"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"
"button": "Arrêter la transcription"
},
"beta": {
"heading": "Devenez beta testeur",
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Inscrivez-vous"
}
},
"admin": {
@@ -274,7 +292,9 @@
}
},
"recording": {
"label": "Enregistrement"
"transcriptStarted": "Transcription en cours",
"transcriptStarting": "Démarrage de la transcription",
"transcriptStopping": "Arrêt de la transcription"
},
"participantTileFocus": {
"pin": {
@@ -21,5 +21,7 @@
"several": "Meerdere mensen willen deelnemen aan dit gesprek.",
"open": "Openen",
"accept": "Accepteren"
}
},
"recordingStarted": "{{name}} is de transcriptie van de vergadering gestart.",
"recordingStopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
}
+31 -11
View File
@@ -103,9 +103,9 @@
"open": "Verberg iedereen",
"closed": "Toon iedereen"
},
"transcript": {
"open": "Verberg AI-assistent",
"closed": "Toon AI-assistant"
"tools": {
"open": "Meer tools verbergen",
"closed": "Meer tools weergeven"
},
"admin": {
"open": "Verberg beheerder",
@@ -167,31 +167,49 @@
"participants": "Deelnemers",
"effects": "Effecten",
"chat": "Berichten in de chat",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"tools": "Meer tools",
"admin": "Beheerdersbediening",
"transcript": "Transcriptie"
},
"content": {
"participants": "deelnemers",
"effects": "effecten",
"chat": "berichten",
"transcript": "AI-assistent",
"admin": "Beheerdersbediening"
"tools": "meer tools",
"admin": "Beheerdersbediening",
"transcript": "Transcriptie"
},
"closeButton": "Verberg {{content}}"
},
"chat": {
"disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek."
},
"moreTools": {
"body": "Toegang tot meer tools in Visio om je vergaderingen te verbeteren,",
"moreLink": "lees meer",
"tools": {
"transcript": {
"title": "Transcriptie",
"body": "Transcribeer je vergadering voor later"
}
}
},
"transcript": {
"start": {
"heading": "Start de assistent!",
"body": "De assistent begint automatisch de audio van uw vergadering op te nemen (beperkt tot 1 uur). Na afloop krijgt u direct een heldere en beknopte samenvatting van de discussies in uw e-mail.",
"button": "Start"
"heading": "Dit gesprek transcriberen",
"body": "Transcribeer dit gesprek automatisch en ontvang de samenvatting in Docs.",
"button": "Start transcriberen",
"linkMore": "Meer informatie"
},
"stop": {
"heading": "Opname loopt ...",
"body": "Uw vergadering wordt momenteel opgenomen. U ontvangt een samenvatting via e-mail, zo gauw de vergarding gesloten wordt.",
"button": "Stop met opname"
},
"beta": {
"heading": "Word beta tester",
"body": "Neem je vergadering op voor later. Je ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
"button": "Schrijf je in"
}
},
"admin": {
@@ -274,7 +292,9 @@
}
},
"recording": {
"label": "Opnemen"
"transcriptStarted": "Transcriptie bezig",
"transcriptStarting": "Transcriptie begint",
"transcriptStopping": "Transcriptie stopt"
},
"participantTileFocus": {
"pin": {
+2 -1
View File
@@ -7,8 +7,9 @@ const link = cva({
textUnderlineOffset: '2',
cursor: 'pointer',
borderRadius: 2,
transition: 'all 0.2s',
fontWeight: 'normal',
'&[data-hovered]': {
fontWeight: 500,
textDecorationThickness: '2px',
},
'&[data-pressed]': {
@@ -9,6 +9,7 @@ type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
target?: string
}
export const LinkButton = ({
+12 -6
View File
@@ -1,18 +1,25 @@
import { ProgressBar } from 'react-aria-components'
import { css } from '@/styled-system/css'
export const Spinner = () => {
export const Spinner = ({
size = 56,
variant = 'light',
}: {
size: number
variant: 'light' | 'dark'
}) => {
const center = 14
const strokeWidth = 3
const r = 14 - strokeWidth
const c = 2 * r * Math.PI
return (
<ProgressBar aria-label="Loading…" value={30}>
{({ percentage }) => (
<>
<svg
width={56}
height={56}
width={size}
height={size}
viewBox="0 0 28 28"
fill="none"
strokeWidth={strokeWidth}
@@ -25,9 +32,8 @@ export const Spinner = () => {
strokeDashoffset={0}
strokeLinecap="round"
className={css({
stroke: 'primary.100',
stroke: variant == 'light' ? 'primary.100' : 'primaryDark.100',
})}
style={{}}
/>
<circle
cx={center}
@@ -37,7 +43,7 @@ export const Spinner = () => {
strokeDashoffset={percentage && c - (percentage / 100) * c}
strokeLinecap="round"
className={css({
stroke: 'primary.800',
stroke: variant == 'light' ? 'primary.800' : 'white',
})}
style={{
animation: `rotate 1s ease-in-out infinite`,
+6 -1
View File
@@ -41,6 +41,7 @@ export const buttonRecipe = cva({
variant: {
primary: {
backgroundColor: 'primary.800',
fontWeight: '500 !important',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'primary.action',
@@ -112,7 +113,7 @@ export const buttonRecipe = cva({
},
},
tertiary: {
backgroundColor: 'primary.100',
backgroundColor: 'primary.50',
color: 'primary.800',
'&[data-hovered]': {
backgroundColor: 'primary.300',
@@ -120,6 +121,10 @@ export const buttonRecipe = cva({
'&[data-pressed]': {
backgroundColor: 'primary.300',
},
'&[data-disabled]': {
backgroundColor: 'transparent',
color: 'primary.400',
},
},
tertiaryText: {
backgroundColor: 'transparent',
+6 -1
View File
@@ -1,14 +1,19 @@
import { proxy } from 'valtio'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
type State = {
showHeader: boolean
showFooter: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
}
export const layoutStore = proxy<State>({
showHeader: false,
showFooter: false,
activePanelId: null,
activeSubPanelId: null,
})
+16
View File
@@ -0,0 +1,16 @@
import { proxy } from 'valtio'
export enum TranscriptionStatus {
STARTING,
STARTED,
STOPPING,
STOPPED,
}
type State = {
status: TranscriptionStatus
}
export const transcriptionStore = proxy<State>({
status: TranscriptionStatus.STOPPED,
})
+3
View File
@@ -1,2 +1,5 @@
export const GRIST_FORM =
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4' as const
export const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const