Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cb735a09 | |||
| 461ab70918 | |||
| bc6bf73e71 | |||
| 566b085ede | |||
| 84d51bc86c | |||
| 66eab072ee | |||
| f390ad46d5 | |||
| 6bdf1d173b | |||
| 02cc74e06f | |||
| 784d97efd6 | |||
| 1f44edcdf3 | |||
| 92e87fcd32 | |||
| 5138f66262 | |||
| 9a21404d23 | |||
| 85784419a3 | |||
| 3431df05af | |||
| 4e9dd87a7a | |||
| e4c27aa840 | |||
| 280cd01df5 | |||
| 1a552282a5 | |||
| 37fe23c0f7 | |||
| 255da4bf60 | |||
| 6dccb507d2 | |||
| d202a025e7 | |||
| ac9ba0df62 | |||
| 91562d049c | |||
| 60321296e5 | |||
| ae06873ff5 | |||
| 70ffb758c7 | |||
| 33a94da636 | |||
| aa6757601d | |||
| c26b1b711c | |||
| 3ee33fc2ec | |||
| d43b2857c1 |
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -20,7 +22,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core import models, utils, enums
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -544,6 +546,7 @@ class ResourceAccessViewSet(
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -591,7 +594,7 @@ class RecordingViewSet(
|
||||
)
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
recording = models.Recording.objects.select_related('room').get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
@@ -617,3 +620,83 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording's
|
||||
media file.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
user = request.user
|
||||
recording_id = url_params['recording']
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
abilities = recording.get_abilities(user)
|
||||
|
||||
if not abilities["retrieve"]:
|
||||
logger.debug("User '%s' lacks permission for attachment", user)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||
RECORDING_FOLDER = 'recordings'
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/recordings/(?P<recording>{UUID_REGEX:s})(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||
@@ -576,6 +576,18 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Wip"""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.SAVED,
|
||||
}
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Wip."""
|
||||
extension = "ogg" if self.mode == RecordingModeChoices.TRANSCRIPT else "mp4"
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{extension}"
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
|
||||
import requests
|
||||
|
||||
@@ -21,10 +27,7 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
return self._notify_user_by_email(recording)
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -33,6 +36,65 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""Wip."""
|
||||
|
||||
# subject
|
||||
# emails
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
language = get_language()
|
||||
|
||||
context = (
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": domain,
|
||||
"room_name": recording.room.name,
|
||||
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
|
||||
"recording_time": recording.created_at.strftime("%H:%M"),
|
||||
"link": f"{domain}/recordings/{recording.id}",
|
||||
}
|
||||
)
|
||||
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/screen_recording.html", context)
|
||||
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification to %s was not sent: %s", emails, exception)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -57,10 +119,8 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ from django.conf import settings
|
||||
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
"""Generates a consistent HSL color based on a given identity string.
|
||||
@@ -110,3 +114,34 @@ def generate_livekit_config(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our recording' metadata
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
|
||||
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
@@ -318,6 +318,9 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
@@ -2,7 +2,7 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -35,7 +35,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FORM} target="_blank" size="sm">
|
||||
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -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: {
|
||||
@@ -207,6 +204,7 @@ export const IntroSlider = () => {
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
|
||||
@@ -88,6 +88,18 @@ export const MainNotificationToast = () => {
|
||||
if (notification.data?.emoji)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,4 +6,8 @@ export enum NotificationType {
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<StyledToast>
|
||||
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||
<div {...contentProps}>{props.toast.content?.message}</div>
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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'
|
||||
|
||||
export function ToastAnyRecording({ state, ...props }: 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 key = useMemo(() => {
|
||||
switch (type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
return 'transcript.started'
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [type])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(key, {
|
||||
name: participant.name,
|
||||
})}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
|
||||
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:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export type RecordingApi = {
|
||||
id: string
|
||||
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
|
||||
created_at: string
|
||||
mode: RecordingMode
|
||||
}
|
||||
|
||||
export const fetchRecording = ({
|
||||
recordingId,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Recording as RecordingRoute } from './routes/Recording'
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Center, VStack } from '@/styled-system/jsx'
|
||||
import { Bold, Button, Field, H, Input, LinkButton, Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen.tsx'
|
||||
import { useParams } from 'wouter'
|
||||
import { fetchRecording } from '../api/fetchRecording'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { UserAware, useUser } from '@/features/auth'
|
||||
import { CenteredContent } from '@/layout/CenteredContent.tsx'
|
||||
import { useState } from 'react'
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording.ts'
|
||||
|
||||
function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
// Accept Date object or create one from string
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date'
|
||||
}
|
||||
|
||||
const year = dateObj.getFullYear()
|
||||
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
|
||||
const day = dateObj.getDate()
|
||||
const hours = dateObj.getHours()
|
||||
const minutes = dateObj.getMinutes()
|
||||
const seconds = dateObj.getSeconds()
|
||||
|
||||
// Pad numbers with leading zeros
|
||||
const pad = (num) => String(num).padStart(2, '0')
|
||||
|
||||
// Replace format tokens with actual values
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', pad(month))
|
||||
.replace('DD', pad(day))
|
||||
.replace('HH', pad(hours))
|
||||
.replace('mm', pad(minutes))
|
||||
.replace('ss', pad(seconds))
|
||||
}
|
||||
|
||||
export const Recording = () => {
|
||||
const { recordingId } = useParams()
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['recording', recordingId],
|
||||
queryFn: () => fetchRecording({ recordingId }),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent>
|
||||
<Spinner />
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !isLoggedIn) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={'lien invalide'} withBackButton>
|
||||
<Text centered>
|
||||
Vous ne pouvez pas accéder à ce lien d'enregistrement
|
||||
</Text>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
maxHeight: '309px',
|
||||
})}
|
||||
/>
|
||||
<H lvl={1} centered>
|
||||
Votre enregistrement est prêt !
|
||||
</H>
|
||||
<Text centered margin="md">
|
||||
Enregistrement de la réunion <b>{data.room.name}</b> du{' '}
|
||||
{formatDate(data.created_at, 'YYYY-MM-DD HH:mm')}. <br /> Cet
|
||||
enregistrement est disponible <u>pendant 7 jours</u>.
|
||||
</Text>
|
||||
<LinkButton
|
||||
href={`https://meet.127.0.0.1.nip.io/media/recordings/${data.id}.${data.mode == RecordingMode.ScreenRecording ? 'mp4' : 'ogg'}`}
|
||||
download={`${data.room.name}-${formatDate(data.created_at)}`}
|
||||
>
|
||||
Télécharger
|
||||
</LinkButton>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,102 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
|
||||
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingToast',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
|
||||
if (!room?.isRecording) return
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
useEffect(() => {
|
||||
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.isRecording])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
break
|
||||
case NotificationType.TranscriptionStopped:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordingStatusChanged = (status: boolean) => {
|
||||
if (!status) {
|
||||
recordingStore.status = RecordingStatus.STOPPED
|
||||
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
|
||||
} else if (
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
) {
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
} else {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room, recordingSnap])
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STOPPING:
|
||||
return 'transcript.stopping'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STOPPING:
|
||||
return 'screenRecording.stopping'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -19,25 +106,24 @@ export const RecordingStateToast = () => {
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.25rem 0.35rem',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderColor: 'primaryDark.400',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
<Spinner size={20} variant="dark" />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
color: 'white',
|
||||
backgroundColor: 'danger.700',
|
||||
padding: '3px',
|
||||
borderRadius: '3px',
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
/>
|
||||
<Text variant={'sm'}>{t('label')}</Text>
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { A, Button, Div, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
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, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
export const ScreenRecording = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: status,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStopped)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
})
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStarted)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isTranscriptStarted,
|
||||
[isLoading, isRecordingTransitioning, isTranscriptStarted]
|
||||
)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
|
||||
{isScreenRecordingStarted ? (
|
||||
<>
|
||||
<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={() => handleScreenRecording()}
|
||||
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_RECORDING} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</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 './Tools'
|
||||
|
||||
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,26 @@ 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 +161,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,133 @@
|
||||
import { A, Div, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
|
||||
import { ReactNode } from 'react'
|
||||
import { Transcript } from './Transcript'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ScreenRecording } from '@/features/rooms/livekit/components/ScreenRecording'
|
||||
import { useIsRecordingEnabled } from '../hooks/useIsRecordingEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
}: ToolsButtonProps) => {
|
||||
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, openScreenRecording, activeSubPanelId } =
|
||||
useSidePanel()
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
|
||||
// FIXME - use settings returned by backend
|
||||
const isRecordingEnabled = true
|
||||
|
||||
switch (activeSubPanelId) {
|
||||
case SubPanelId.TRANSCRIPT:
|
||||
return <Transcript />
|
||||
case SubPanelId.SCREEN_RECORDING:
|
||||
return <ScreenRecording />
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
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_MORE_TOOLS} target="_blank">
|
||||
{t('moreLink')}
|
||||
</A>
|
||||
.
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiFileTextFill size={24} color="white" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
/>
|
||||
)}
|
||||
{isRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
/>
|
||||
)}
|
||||
</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,37 @@ 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'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import {
|
||||
BETA_USERS_FORM_URL,
|
||||
CRISP_HELP_ARTICLE_TRANSCRIPT,
|
||||
} from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
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 isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,6 +52,17 @@ export const Transcript = () => {
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
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 +72,12 @@ export const Transcript = () => {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.TranscriptionStopped)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
await notifyParticipant(NotificationType.TranscriptionStarted)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
@@ -58,7 +85,10 @@ export const Transcript = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTranscriptAccess) return
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isScreenRecordingStarted,
|
||||
[isLoading, isRecordingTransitioning, isScreenRecordingStarted]
|
||||
)
|
||||
|
||||
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_TRANSCRIPT} 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>
|
||||
{isTranscriptStarted ? (
|
||||
<>
|
||||
<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_TRANSCRIPT} 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>
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
+9
-14
@@ -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>
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
|
||||
export const useHasScreenRecordingAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('screen-recording')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isScreenRecordingEnabled = useIsRecordingEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isScreenRecordingEnabled
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
|
||||
export const useHasTranscriptAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isTranscriptEnabled = useIsTranscriptEnabled()
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsTranscriptEnabled = () => {
|
||||
export const useIsRecordingEnabled = (mode: RecordingMode) => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
|
||||
data?.recording?.available_modes?.includes(mode)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsScreenRecordingStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
}
|
||||
|
||||
export const useIsTranscriptStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsRecordingTransitioning = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const transitionalStates = [
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
]
|
||||
|
||||
return transitionalStates.includes(recordingSnap.status)
|
||||
}
|
||||
@@ -5,53 +5,83 @@ export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TRANSCRIPT = 'transcript',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
|
||||
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 isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
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
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
activeSubPanelId,
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
toggleTranscript,
|
||||
toggleTools,
|
||||
toggleAdmin,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
isSubPanelOpen,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isTranscriptOpen,
|
||||
isScreenRecordingOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
@@ -21,7 +22,7 @@ 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'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
|
||||
export function MobileControlBar({
|
||||
@@ -133,7 +134,7 @@ export function MobileControlBar({
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<TranscriptToggle
|
||||
<ToolsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
@@ -150,7 +151,7 @@ export function MobileControlBar({
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { AdminToggle } from '../../components/AdminToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
@@ -20,7 +20,7 @@ const NavigationControls = ({
|
||||
<>
|
||||
<ChatToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ParticipantsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<TranscriptToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ToolsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<AdminToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ export const CreateMeetingButton = () => {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
<Spinner size={34} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "",
|
||||
"open": "",
|
||||
"accept": ""
|
||||
},
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"stopped": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"transcript": {
|
||||
"tools": {
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
@@ -169,30 +169,48 @@
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
"admin": "",
|
||||
"tools": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": ""
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "",
|
||||
"moreLink": "",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
"button": "",
|
||||
"linkMore": ""
|
||||
},
|
||||
"stop": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
},
|
||||
"beta": {
|
||||
"heading": "",
|
||||
"body": "",
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -274,8 +292,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": ""
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "",
|
||||
"starting": "",
|
||||
"stopping": ""
|
||||
},
|
||||
"any": {
|
||||
"started": ""
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "Several people want to join this call.",
|
||||
"open": "Open",
|
||||
"accept": "Accept"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,68 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "AI Assistant",
|
||||
"admin": "Admin settings"
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Recording",
|
||||
"admin": "Admin settings",
|
||||
"tools": "More tools"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"chat": "messages",
|
||||
"transcript": "AI assistant",
|
||||
"admin": "Admin settings"
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "recording",
|
||||
"admin": "admin settings",
|
||||
"tools": "more tools"
|
||||
},
|
||||
"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": "Keep a written record of your meeting."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Recording",
|
||||
"body": "Record your meeting to watch it again whenever you like."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"heading": "Transcription in progress...",
|
||||
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
|
||||
"button": "Stop transcription"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Record this call",
|
||||
"body": "Record this call to watch it later and receive the video recording by email.",
|
||||
"button": "Start recording",
|
||||
"linkMore": "Learn more"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Recording in progress…",
|
||||
"body": "You will receive the result by email once the recording is complete.",
|
||||
"button": "Stop recording"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -273,8 +310,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Recording"
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "Transcribing",
|
||||
"starting": "Transcription starting",
|
||||
"stopping": "Transcription stopping"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Recording in progress",
|
||||
"starting": "Starting recording",
|
||||
"stopping": "Stopping recording"
|
||||
},
|
||||
"any": {
|
||||
"started": "Recording in progress"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "Plusieurs personnes souhaitent participer à cet appel.",
|
||||
"open": "Afficher",
|
||||
"accept": "Accepter"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,30 +167,67 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel",
|
||||
"transcript": "Assistant IA",
|
||||
"admin": "Commandes de l'organisateur"
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Enregistrement",
|
||||
"admin": "Commandes de l'organisateur",
|
||||
"tools": "Plus d'outils"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets",
|
||||
"chat": "les messages",
|
||||
"transcript": "l'assistant IA",
|
||||
"admin": "Commandes de l'organisateur"
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "enregistrement",
|
||||
"admin": "commandes de l'organisateur",
|
||||
"tools": "plus d'outils"
|
||||
},
|
||||
"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": "Conservez une trace écrite de votre réunion."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Enregistrement",
|
||||
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Transcription en cours …",
|
||||
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Enregistrer cet appel",
|
||||
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
|
||||
"button": "Démarrer l'enregistrement",
|
||||
"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.",
|
||||
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
}
|
||||
},
|
||||
@@ -273,8 +310,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Enregistrement"
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "Transcription en cours",
|
||||
"starting": "Démarrage de la transcription",
|
||||
"stopping": "Arrêt de la transcription"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Enregistrement en cours",
|
||||
"starting": "Démarrage de l'enregistrement",
|
||||
"stopping": "Arrêt de l'enregistrement"
|
||||
},
|
||||
"any": {
|
||||
"started": "Enregistrement en cours"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
"several": "Meerdere mensen willen deelnemen aan dit gesprek.",
|
||||
"open": "Openen",
|
||||
"accept": "Accepteren"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,68 @@
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
"chat": "Berichten in de chat",
|
||||
"transcript": "AI-assistent",
|
||||
"admin": "Beheerdersbediening"
|
||||
"transcript": "Transcriptie",
|
||||
"screenRecording": "Schermopname",
|
||||
"admin": "Beheerdersbediening",
|
||||
"tools": "Meer tools"
|
||||
},
|
||||
"content": {
|
||||
"participants": "deelnemers",
|
||||
"effects": "effecten",
|
||||
"chat": "berichten",
|
||||
"transcript": "AI-assistent",
|
||||
"admin": "Beheerdersbediening"
|
||||
"screenRecording": "transcriptie",
|
||||
"transcript": "schermopname",
|
||||
"admin": "beheerdersbediening",
|
||||
"tools": "meer tools"
|
||||
},
|
||||
"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": "Bewaar een schriftelijk verslag van je vergadering."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Opname",
|
||||
"body": "Neem je vergadering op om die later opnieuw te bekijken."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Transcribeer dit gesprek",
|
||||
"body": "Transcribeer dit gesprek automatisch en ontvang het verslag in Docs.",
|
||||
"button": "Transcriptie starten",
|
||||
"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"
|
||||
"heading": "Transcriptie bezig...",
|
||||
"body": "De transcriptie van uw vergadering is bezig. U ontvangt het resultaat per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Transcriptie stoppen"
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Word betatester",
|
||||
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Aanmelden"
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Dit gesprek opnemen",
|
||||
"body": "Neem dit gesprek op om het later terug te kijken. Je ontvangt de video-opname per e-mail.",
|
||||
"button": "Opname starten",
|
||||
"linkMore": "Meer informatie"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Opname bezig …",
|
||||
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
|
||||
"button": "Opname stoppen"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -273,8 +310,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Opnemen"
|
||||
"recordingToast": {
|
||||
"transcript": {
|
||||
"started": "Transcriptie bezig",
|
||||
"starting": "Transcriptie begint",
|
||||
"stopping": "Transcriptie stopt"
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "Opname bezig",
|
||||
"starting": "Opname starten",
|
||||
"stopping": "Opname stoppen"
|
||||
},
|
||||
"any": {
|
||||
"started": "Opname bezig"
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"pin": {
|
||||
|
||||
@@ -9,6 +9,7 @@ type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||
TooltipWrapperProps & {
|
||||
// Use tooltip as description below the button.
|
||||
description?: boolean
|
||||
target?: string
|
||||
}
|
||||
|
||||
export const LinkButton = ({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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
|
||||
@@ -11,8 +17,8 @@ export const Spinner = () => {
|
||||
{({ percentage }) => (
|
||||
<>
|
||||
<svg
|
||||
width={56}
|
||||
height={56}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
@@ -25,7 +31,7 @@ export const Spinner = () => {
|
||||
strokeDashoffset={0}
|
||||
strokeLinecap="round"
|
||||
className={css({
|
||||
stroke: 'primary.100',
|
||||
stroke: variant == 'light' ? 'primary.100' : 'primaryDark.100',
|
||||
})}
|
||||
style={{}}
|
||||
/>
|
||||
@@ -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`,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const buttonRecipe = cva({
|
||||
primary: {
|
||||
backgroundColor: 'primary.800',
|
||||
color: 'white',
|
||||
fontWeight: 'medium !important',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.action',
|
||||
},
|
||||
@@ -56,6 +57,7 @@ export const buttonRecipe = cva({
|
||||
secondary: {
|
||||
backgroundColor: 'white',
|
||||
color: 'primary.800',
|
||||
fontWeight: 'medium !important',
|
||||
borderColor: 'primary.800',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'greyscale.100',
|
||||
@@ -113,6 +115,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
tertiary: {
|
||||
backgroundColor: 'primary.100',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'primary.800',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.300',
|
||||
@@ -120,9 +123,14 @@ export const buttonRecipe = cva({
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primary.300',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'primary.400',
|
||||
},
|
||||
},
|
||||
tertiaryText: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'primary.900',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.300',
|
||||
@@ -133,6 +141,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
primaryDark: {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'white',
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primaryDark.900',
|
||||
@@ -149,6 +158,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
secondaryDark: {
|
||||
backgroundColor: 'primaryDark.50',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'white',
|
||||
'&[data-pressed]': {
|
||||
backgroundColor: 'primaryDark.200',
|
||||
@@ -164,6 +174,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
primaryTextDark: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'white',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
@@ -179,6 +190,7 @@ export const buttonRecipe = cva({
|
||||
},
|
||||
quaternaryText: {
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'medium !important',
|
||||
color: 'greyscale.600',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'greyscale.100',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AccessibilityRoute } from '@/features/legalsTerms/Accessibility'
|
||||
import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
|
||||
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
|
||||
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
|
||||
import { DownloadRoute, RecordingRoute } from '@/features/recording'
|
||||
|
||||
export const routes: Record<
|
||||
| 'home'
|
||||
@@ -34,6 +35,14 @@ export const routes: Record<
|
||||
to: (roomId: string) => `/${roomId.trim()}`,
|
||||
Component: RoomRoute,
|
||||
},
|
||||
recording: {
|
||||
name: 'recording',
|
||||
path: new RegExp(
|
||||
`^[/]recording[/](?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$`
|
||||
),
|
||||
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
|
||||
Component: RecordingRoute,
|
||||
},
|
||||
feedback: {
|
||||
name: 'feedback',
|
||||
path: '/feedback',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export enum RecordingStatus {
|
||||
TRANSCRIPT_STARTING,
|
||||
TRANSCRIPT_STARTED,
|
||||
TRANSCRIPT_STOPPING,
|
||||
STOPPED,
|
||||
SCREEN_RECORDING_STARTING,
|
||||
SCREEN_RECORDING_STARTED,
|
||||
SCREEN_RECORDING_STOPPING,
|
||||
ANY_STARTED,
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: RecordingStatus
|
||||
}
|
||||
|
||||
export const recordingStore = proxy<State>({
|
||||
status: RecordingStatus.STOPPED,
|
||||
})
|
||||
@@ -1,2 +1,14 @@
|
||||
export const GRIST_FORM =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4' as const
|
||||
export const GRIST_FEEDBACKS_FORM =
|
||||
'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26' as const
|
||||
|
||||
export const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const
|
||||
|
||||
export const CRISP_HELP_ARTICLE_MORE_TOOLS =
|
||||
'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23' as const
|
||||
|
||||
export const CRISP_HELP_ARTICLE_TRANSCRIPT =
|
||||
'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x' as const
|
||||
|
||||
export const CRISP_HELP_ARTICLE_RECORDING =
|
||||
'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0' as const
|
||||
|
||||
@@ -16,6 +16,9 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
|
||||
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
|
||||
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
@@ -195,3 +198,17 @@ celery:
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
@@ -32,6 +32,8 @@ backend:
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
|
||||
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
|
||||
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token
|
||||
@@ -223,3 +225,17 @@ celery:
|
||||
- "worker"
|
||||
- "--pool=solo"
|
||||
- "--loglevel=info"
|
||||
|
||||
ingressMedia:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{{- if .Values.ingressMedia.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressMedia.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressMedia.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressMedia.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressMedia.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressMedia.host }}
|
||||
- host: {{ .Values.ingressMedia.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: ImplementationSpecific
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressMedia.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ $.Values.ingressMedia.path | quote }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: ImplementationSpecific
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}-media
|
||||
port:
|
||||
number: {{ .Values.serviceMedia.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}-media
|
||||
servicePort: {{ .Values.serviceMedia.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- $component := "media" -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $fullName }}-media
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
annotations:
|
||||
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: {{ $.Values.serviceMedia.host }}
|
||||
@@ -70,6 +70,47 @@ ingressAdmin:
|
||||
additional: []
|
||||
|
||||
|
||||
## @param ingressMedia.enabled whether to enable the Ingress or not
|
||||
## @param ingressMedia.className IngressClass to use for the Ingress
|
||||
## @param ingressMedia.host Host for the Ingress
|
||||
## @param ingressMedia.path Path to use for the Ingress
|
||||
ingressMedia:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /media/(.*)
|
||||
## @param ingressMedia.hosts Additional host to configure for the Ingress
|
||||
hosts: [ ]
|
||||
# - chart-example.local
|
||||
## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressMedia.tls.secretName Secret name for TLS config
|
||||
## @skip ingressMedia.tls.additional
|
||||
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: null
|
||||
additional: []
|
||||
|
||||
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
|
||||
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers
|
||||
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
|
||||
## @param serviceMedia.host
|
||||
## @param serviceMedia.port
|
||||
## @param serviceMedia.annotations
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
|
||||
## @section backend
|
||||
|
||||
backend:
|
||||
@@ -546,3 +587,5 @@ celery:
|
||||
## @param celery.pdb.enabled Enable pdb on celery
|
||||
pdb:
|
||||
enabled: false
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<mjml>
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
<mj-image
|
||||
align="center"
|
||||
src="{{logo_img}}"
|
||||
width="320px"
|
||||
alt="{%trans 'Logo email' %}"
|
||||
/>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
|
||||
<mj-column>
|
||||
<mj-text align="center">
|
||||
<h1>{% trans "Your recording is ready!"%}</h1>
|
||||
</mj-text>
|
||||
<!-- Main Message -->
|
||||
<mj-text>
|
||||
{% blocktrans %}
|
||||
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to view.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
<p>{% trans "To keep this recording permanently:" %}</p>
|
||||
<ol>
|
||||
<li>{% trans "Click the \"Open\" button below" %}</li>
|
||||
<li>{% trans "Use the \"Download\" button in the interface" %}</li>
|
||||
<li>{% trans "Save the file to your preferred location" %}</li>
|
||||
</ol>
|
||||
</mj-text>
|
||||
<mj-button
|
||||
href="{{link}}"
|
||||
background-color="#000091"
|
||||
color="white"
|
||||
padding-bottom="30px"
|
||||
>
|
||||
{% trans "Open"%}
|
||||
</mj-button>
|
||||
<mj-text>
|
||||
{% blocktrans %}
|
||||
If you have any questions or need assistance, please contact our support team at {{support_email}}.
|
||||
{% endblocktrans %}
|
||||
</mj-text>
|
||||
<mj-divider
|
||||
border-width="1px"
|
||||
border-style="solid"
|
||||
border-color="#DDDDDD"
|
||||
width="30%"
|
||||
align="center"
|
||||
/>
|
||||
<!-- Signature -->
|
||||
<mj-text>
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Thank you for using {{brandname}}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
Reference in New Issue
Block a user