(frontend) allow user to request recording

Inspired by @ericboucher’s proposal, allow non-admin or non-owner participants
to request the start of a transcription or a recording.

All participants are notified of the request, but only the admin can actually
open the menu and start the recording.

This is a first simple and naive implementation and will be improved later.

Prefer opening the relevant recording menu for admins instead of offering a
direct quick action to start recording. With more options now tied to recording,
keeping the responsibility for starting it encapsulated within the side panel
felt cleaner.

This comes with some UX trade-offs, but it’s worth trying.

I also simplified the notification mechanism by disabling the action button for
the same duration as the notification, preventing duplicate triggers. This is
not perfect, since hovering the notification pauses its display, but it avoids
most accidental re-triggers.
This commit is contained in:
lebaudantoine
2026-01-02 17:28:09 +01:00
committed by aleb_the_flash
parent 6e1ad7fca5
commit f3e2bbf701
17 changed files with 339 additions and 23 deletions

View File

@@ -104,6 +104,16 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.RECORDING_REQUESTED }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break

View File

@@ -13,4 +13,5 @@ export const NotificationDuration = {
LOWER_HAND: ToastDuration.EXTRA_LONG,
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG,
} as const

View File

@@ -9,8 +9,10 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',

View File

@@ -0,0 +1,83 @@
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export function ToastRecordingRequest({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const type = props.toast.content.type
const {
isTranscriptOpen,
openTranscript,
isScreenRecordingOpen,
openScreenRecording,
} = useSidePanel()
const options = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionRequested:
return {
key: 'transcript.requested',
isMenuOpen: isTranscriptOpen,
openMenu: openTranscript,
}
case NotificationType.ScreenRecordingRequested:
return {
key: 'screenRecording.requested',
isMenuOpen: isScreenRecordingOpen,
openMenu: openScreenRecording,
}
default:
return
}
}, [
type,
isTranscriptOpen,
isScreenRecordingOpen,
openTranscript,
openScreenRecording,
])
if (!options) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(options.key, {
name: participant?.name,
})}
{!options.isMenuOpen && (
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={options.openMenu}
>
{t('openMenu')}
</Button>
)}
</HStack>
</StyledToastContainer>
)
}

View File

@@ -12,6 +12,7 @@ import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -52,6 +53,12 @@ const renderToast = (
case NotificationType.ScreenRecordingLimitReached:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
return (
<ToastRecordingRequest key={toast.key} toast={toast} state={state} />
)
case NotificationType.RecordingSaving:
return (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />

View File

@@ -2,14 +2,25 @@ import { A, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { LoginPrompt } from './LoginPrompt'
import { RequestRecording } from './RequestRecording'
import { useUser } from '@/features/auth'
import { VStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
const Divider = ({ label }: { label: string }) => (
<HStack gap="1rem" alignItems="center" width="100%" marginY="1rem">
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
<Text variant="xsNote">{label}</Text>
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
</HStack>
)
interface NoAccessViewProps {
i18nKeyPrefix: string
i18nKey: string
helpArticle?: string
imagePath: string
handleRequest: () => Promise<void>
isActive: boolean
}
export const NoAccessView = ({
@@ -17,6 +28,8 @@ export const NoAccessView = ({
i18nKey,
helpArticle,
imagePath,
handleRequest,
isActive,
}: NoAccessViewProps) => {
const { isLoggedIn } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
@@ -34,20 +47,18 @@ export const NoAccessView = ({
src={imagePath}
alt=""
className={css({
minHeight: '309px',
height: '309px',
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
'@media (max-height: 700px)': {
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '45%',
marginBottom: '0.3rem',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 530px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '40%',
marginBottom: '0.1rem',
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
@@ -82,6 +93,17 @@ export const NoAccessView = ({
body={t(`${i18nKey}.login.body`)}
/>
)}
{!isLoggedIn && !isActive && (
<Divider label={t(`${i18nKey}.dividerLabel`)} />
)}
{!isActive && (
<RequestRecording
heading={t(`${i18nKey}.request.heading`)}
body={t(`${i18nKey}.request.body`)}
buttonLabel={t(`${i18nKey}.request.buttonLabel`)}
handleRequest={handleRequest}
/>
)}
</Div>
)
}

View File

@@ -0,0 +1,87 @@
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { Spinner } from '@/primitives/Spinner.tsx'
import { NotificationDuration } from '@/features/notifications/NotificationDuration'
interface RequestRecordingProps {
heading: string
body: string
buttonLabel: string
handleRequest: () => Promise<void>
}
export const RequestRecording = ({
heading,
body,
buttonLabel,
handleRequest,
}: RequestRecordingProps) => {
const [isDisabled, setIsDisabled] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout>()
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const onPress = async () => {
setIsDisabled(true)
try {
await handleRequest()
} catch {
setIsDisabled(false)
return
}
timeoutRef.current = setTimeout(() => {
setIsDisabled(false)
}, NotificationDuration.RECORDING_REQUESTED)
}
return (
<div
className={css({
backgroundColor: 'neutral.50',
borderRadius: '5px',
border: '1px solid',
borderColor: 'neutral.200',
paddingY: '1rem',
paddingX: '1rem',
display: 'flex',
flexDirection: 'column',
marginBottom: '1.5rem',
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
</HStack>
<Text variant="smNote" wrap="pretty">
{body}
</Text>
<div
className={css({
marginTop: '1rem',
})}
>
<Button
variant="tertiary"
fullWidth
onPress={onPress}
isDisabled={isDisabled}
>
{isDisabled && <Spinner size={24} />}
{buttonLabel}
</Button>
</div>
</div>
)
}

View File

@@ -57,6 +57,13 @@ export const ScreenRecordingSidePanel = () => {
const room = useRoomContext()
const { openTranscript } = useSidePanel()
const handleRequestScreenRecording = async () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
posthog.capture('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
if (!roomId) {
console.warn('No room ID found')
@@ -105,6 +112,8 @@ export const ScreenRecordingSidePanel = () => {
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_recording}
imagePath="/assets/intro-slider/4.png"
handleRequest={handleRequestScreenRecording}
isActive={statuses.isActive}
/>
)
}

View File

@@ -69,6 +69,13 @@ export const TranscriptSidePanel = () => {
const room = useRoomContext()
const { openScreenRecording } = useSidePanel()
const handleRequestTranscription = async () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
posthog.capture('transcript-requested', {})
}
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
@@ -124,6 +131,8 @@ export const TranscriptSidePanel = () => {
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}
@@ -135,6 +144,8 @@ export const TranscriptSidePanel = () => {
i18nKey="premium"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}

View File

@@ -30,12 +30,14 @@
"transcript": {
"started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Transkription starten."
},
"screenRecording": {
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Aufnahme starten."
},
"recordingSave": {
"transcript": {
@@ -46,5 +48,6 @@
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald sie fertig ist.",
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald sie fertig ist."
}
}
},
"openMenu": "Menü öffnen"
}

View File

@@ -332,18 +332,30 @@
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Transkription (Beta) starten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
}
},
"premium": {
"heading": "Premium-Funktion",
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn Ihre E-Mail-Adresse nicht autorisiert ist, kontaktieren Sie bitte den Support, um Zugriff zu erhalten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
}
}
},
@@ -370,9 +382,15 @@
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Videoaufnahme (Beta) starten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
},
"request": {
"heading": "Aufnahme anfordern",
"body": "Der Moderator wird benachrichtigt und kann die Aufnahme für Sie starten.",
"buttonLabel": "Anfrage senden"
}
},
"durationMessage": "(begrenzt auf {{max_duration}})"

View File

@@ -30,12 +30,14 @@
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"requested": "{{name}} wants to start the meeting transcription."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
"requested": "{{name}} wants to start the meeting recording."
},
"recordingSave": {
"transcript": {
@@ -46,5 +48,6 @@
"message": "Your recording is being saved! Well send a notification to <strong>{{email}}</strong> as soon as its ready.",
"default": "Your recording is being saved! Well send a notification to your email as soon as its ready."
}
}
},
"openMenu": "Open menu"
}

View File

@@ -332,18 +332,30 @@
"heading": "Restricted Access",
"body": "For security reasons, only the meeting creator or an admin can start a transcription (beta).",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Only the meeting creator or an admin can start a transcription. Log in to verify your permissions."
},
"request": {
"heading": "Request Transcription",
"body": "The host will be notified and can enable transcription for you.",
"buttonLabel": "Request"
}
},
"premium": {
"heading": "Premium feature",
"body": "This feature is reserved for public agents. If your email address is not authorized, please contact support to get access.",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "You are not logged in!",
"body": "You must be logged in to use this feature. Please log in, then try again."
},
"request": {
"heading": "Request Transcription",
"body": "The host will be notified and can enable transcription for you.",
"buttonLabel": "Request"
}
}
},
@@ -370,9 +382,15 @@
"heading": "Restricted Access",
"body": "For security reasons, only the meeting creator or an admin can start a recording (beta).",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Only the meeting creator or an admin can start screen recording. Log in to verify your permissions."
},
"request": {
"heading": "Request Recording",
"body": "The host will be notified and can enable recording for you.",
"buttonLabel": "Request"
}
},
"durationMessage": "(limited to {{max_duration}}) "

View File

@@ -30,12 +30,14 @@
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"requested": "{{name}} a demandé à démarrer la transcription."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
"requested": "{{name}} a demandé à démarrer l'enregistrement."
},
"recordingSave": {
"transcript": {
@@ -46,5 +48,6 @@
"message": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail à <strong>{{email}}</strong> dès quil sera prêt.",
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès quil sera prêt."
}
}
},
"openMenu": "Ouvrir le menu"
}

View File

@@ -332,18 +332,30 @@
"heading": "Accès restreint",
"body": "Pour des raisons de sécurité, seul le créateur ou un administrateur de la réunion peut lancer une transcription (beta).",
"linkMore": "En savoir plus",
"dividerLabel": "OU",
"login": {
"heading": "Connexion requise",
"body": "Seul le créateur de la réunion ou un administrateur peut démarrer la transcription. Connectez-vous pour vérifier vos autorisations."
},
"request": {
"heading": "Demander à l'organisateur",
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
"buttonLabel": "Demander"
}
},
"premium": {
"heading": "Fonctionnalité premium",
"body": "Cette fonctionnalité est réservée aux agents publics. Si votre adresse email nest pas autorisée, contactez le support pour obtenir l'accès.",
"linkMore": "En savoir plus",
"dividerLabel": "OU",
"login": {
"heading": "Vous n'êtes pas connecté !",
"body": "Vous devez être connecté pour utiliser cette fonctionnalité. Connectez-vous, puis réessayez."
},
"request": {
"heading": "Demander à l'organisateur",
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
"buttonLabel": "Demander"
}
}
},
@@ -370,9 +382,15 @@
"heading": "Accès restreint",
"body": "Pour des raisons de sécurité, seul le créateur ou un administrateur de la réunion peut lancer un enregistrement (beta).",
"linkMore": "En savoir plus",
"dividerLabel": "OU",
"login": {
"heading": "Connexion requise",
"body": "Seul le créateur de la réunion ou un administrateur peut démarrer l'enregistrement. Connectez-vous pour vérifier vos autorisations."
},
"request": {
"heading": "Demander à l'organisateur",
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
"buttonLabel": "Demander"
}
},
"durationMessage": "(limité à {{max_duration}}) "

View File

@@ -30,12 +30,14 @@
"transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"requested": "{{name}} wil graag de transcriptie starten."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
"requested": "{{name}} wil graag de opname starten."
},
"recordingSave": {
"transcript": {
@@ -46,5 +48,6 @@
"message": "We zijn uw opname aan het voltooien! U ontvangt een e-mail op <strong>{{email}}</strong> zodra deze klaar is.",
"default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is."
}
}
},
"openMenu": "Menu openen"
}

View File

@@ -332,18 +332,30 @@
"heading": "Toegang beperkt",
"body": "Om veiligheidsredenen kan alleen de maker of een beheerder van de vergadering een transcriptie starten (beta).",
"linkMore": "Meer informatie",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
"body": "Alleen de maker van de vergadering of een beheerder kan de transcriptie starten. Log in om uw machtigingen te controleren."
},
"request": {
"heading": "Transcriptie aanvragen",
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
"buttonLabel": "Aanvragen"
}
},
"premium": {
"heading": "Premiumfunctie",
"body": "Deze functie is voorbehouden aan openbare medewerkers. Als uw e-mailadres niet is toegestaan, neem dan contact op met de support om toegang te krijgen.",
"linkMore": "Meer informatie",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
"body": "Alleen de maker van de vergadering of een beheerder kan de transcriptie starten. Log in om uw machtigingen te controleren."
},
"request": {
"heading": "Transcriptie aanvragen",
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
"buttonLabel": "Aanvragen"
}
}
},
@@ -370,9 +382,15 @@
"heading": "Toegang beperkt",
"body": "Om veiligheidsredenen kan alleen de maker of een beheerder van de vergadering een opname starten (beta).",
"linkMore": "Meer informatie",
"dividerLabel": "OF",
"login": {
"heading": "Inloggen vereist",
"body": "Alleen de maker van de vergadering of een beheerder kan de opname starten. Log in om uw machtigingen te controleren."
},
"request": {
"heading": "Opname aanvragen",
"body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.",
"buttonLabel": "Aanvragen"
}
},
"durationMessage": "(beperkt tot {{max_duration}})"