Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine 2d4fa9ffb7 💄(frontend) adjust spacing in the recording side panels
Based on @Arnaud’s feedback, adjust the spacing between the title, details
section, and control buttons to make the layout feel more homogeneous.
2026-01-08 10:37:23 +01:00
lebaudantoine 609fff8660 🚸(frontend) remove the default comma delimiter in humanized durations
The comma caused values like 1h30 to be rendered as “1 heure, 30 minutes,”
which feels awkward in most European languages.
2026-01-08 10:37:23 +01:00
61 changed files with 416 additions and 1125 deletions
-23
View File
@@ -8,35 +8,13 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
@@ -44,7 +22,6 @@ and this project adheres to
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
+4 -5
View File
@@ -31,7 +31,6 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
@@ -123,8 +122,9 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
@@ -193,8 +193,7 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.3.0"
version = "1.2.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+2 -6
View File
@@ -5,7 +5,7 @@ from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email__iexact=email)
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,10 +123,6 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
"scope": user.email,
},
format="json",
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.3.0"
version = "1.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.3.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.3.0",
"version": "1.2.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.3.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -141,23 +141,31 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button
@@ -1,4 +1,4 @@
import { H, Text, Icon } from '@/primitives'
import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="login" />
<span className="material-symbols" aria-hidden={true}>
login
</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,5 +1,4 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
return <span className="material-symbols">speech_to_text</span>
}
return <Icon type="symbols" name="screen_record" />
return <span className="material-symbols">screen_record</span>
}
@@ -1,4 +1,4 @@
import { Button, Icon, H, Text } from '@/primitives'
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="person_raised_hand" />
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,5 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -46,7 +45,7 @@ export const RowWrapper = ({
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} />
<span className="material-icons">{iconName}</span>
</div>
<div
className={css({
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({
roomId,
@@ -228,20 +228,10 @@ export const Conference = ({
posthog.captureException(e)
}}
onDisconnected={(e) => {
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo('feedback')
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
}
}}
onMediaDeviceFailure={(e, kind) => {
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
@@ -138,12 +138,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
@@ -174,7 +173,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
@@ -5,9 +5,6 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -143,53 +140,11 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
@@ -13,32 +13,27 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
}
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
<div
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -63,37 +58,32 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
size={'sm'}
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
<RiArrowLeftLine size={20} />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
{title}
</Heading>
<Div
position="absolute"
top="5"
@@ -114,7 +104,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</aside>
</div>
)
type PanelProps = {
@@ -135,6 +125,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -153,7 +144,6 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -163,7 +153,6 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
@@ -1,4 +1,4 @@
import { A, Div, Icon, Text } from '@/primitives'
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'
@@ -87,7 +87,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<Icon type="symbols" name="chevron_forward" />
<span className="material-symbols">chevron_forward</span>
</div>
</RACButton>
)
@@ -163,7 +163,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
icon={<span className="material-symbols">speech_to_text</span>}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -171,7 +171,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<Icon type="symbols" name="mode_standby" />}
icon={<span className="material-symbols">mode_standby</span>}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
@@ -20,8 +19,6 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -34,24 +31,21 @@ export const ParticipantsToggle = ({
display: 'inline-block',
})}
>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
<div
className={css({
position: 'absolute',
@@ -10,7 +10,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -146,7 +145,7 @@ export const ReactionsToggle = () => {
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
aria-label={t('send', { emoji })}
variant="primaryTextDark"
size="sm"
square
@@ -9,13 +9,13 @@ import {
} from '../blur'
import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,17 +50,11 @@ export const EffectsConfiguration = ({
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -75,95 +69,16 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
@@ -189,7 +104,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (wasSelectedBeforeToggle) {
if (isSelected(type, options)) {
// Stop processor.
await clearEffect()
} else if (
@@ -216,8 +131,6 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -240,28 +153,8 @@ export const EffectsConfiguration = ({
)
}
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
@@ -372,60 +265,65 @@ export const EffectsConfiguration = ({
>
{t('blur.title')}
</H>
<div>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
>
<ToggleButton
ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div
className={css({
@@ -445,37 +343,39 @@ export const EffectsConfiguration = ({
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return (
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
<ToggleButton
variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
)
})}
</div>
@@ -1,24 +0,0 @@
import { useTranslation } from 'react-i18next'
export const getEmojiLabel = (
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
'clapping-hands': t('emojis.clapping-hands', {
defaultValue: 'clapping hands',
}),
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
defaultValue: 'face with tears of joy',
}),
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
defaultValue: 'surprised face',
}),
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
}
return emojiLabels[emoji] ?? emoji
}
@@ -3,9 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import { useLocation, useSearchParams } from 'wouter'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -22,40 +20,25 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const reasonKey = useMemo(() => {
const state = window.history.state
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -11,7 +10,6 @@ import {
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -23,7 +21,6 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -109,16 +106,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
<span className="material-symbols">speech_to_text</span>
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -132,7 +124,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
</Dialog>
@@ -1,40 +0,0 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -5,5 +5,4 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,63 +13,101 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime?: number
startTime: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant
extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime?: number
startTime: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptionSegments = (
const updateTranscriptions = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
const segment = segments[0]
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
})
}
return {
updateTranscriptionSegments,
transcriptionSegments,
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
}
}
@@ -154,54 +192,24 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionSegments, updateTranscriptionSegments } =
const { transcriptionRows, updateTranscriptions, updateParticipant } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
}
}, [room, updateTranscriptionSegments])
}, [room, updateTranscriptions])
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
}
return rows
}, [transcriptionSegments])
}, [room, updateParticipant])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -2,14 +2,10 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -24,18 +20,6 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles
}
useEffect(() => {
if (!room) return
const closeSubtitles = () => {
layoutStore.showSubtitles = false
}
room.on(RoomEvent.Disconnected, closeSubtitles)
return () => {
room.off(RoomEvent.Disconnected, closeSubtitles)
}
}, [room])
return {
areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles,
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
},
"slide2": {
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße."
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
},
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
}
}
}
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -86,7 +85,6 @@
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Verlassen",
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
"closed": "Alle anzeigen"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
"you": "Sie"
}
},
"options": {
@@ -248,34 +233,15 @@
"clear": "Effekt deaktivieren",
"blur": {
"title": "Hintergrundunschärfe",
"status": {
"none": "Hintergrund scharf.",
"light": "Hintergrund leicht verschwommen.",
"strong": "Hintergrund stark verschwommen."
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
"light": "Leichte Unschärfe",
"normal": "Unschärfe",
"apply": "Unschärfe aktivieren",
"clear": "Unschärfe deaktivieren"
},
"virtual": {
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
"apply": "Virtuellen Hintergrund aktivieren",
"clear": "Virtuellen Hintergrund deaktivieren"
},
"faceLandmarks": {
"title": "Visuelle Effekte",
@@ -290,8 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Make the switch to simplicity. Try us now!",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+15 -51
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by an administrator"
"duplicateIdentity": "You have joined the meeting from another device"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -16,7 +15,7 @@
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Backgrounds and Effects",
"effects": "Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
@@ -39,7 +38,7 @@
},
"join": {
"effects": {
"description": "Apply backgrounds and effects",
"description": "Apply effects",
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
@@ -86,7 +85,6 @@
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": {
"call": "Call:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
"closed": "See everyone"
},
"tools": {
"open": "Hide more tools",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "you",
"emojis": {
"thumbs-up": "thumbs up",
"thumbs-down": "thumbs down",
"clapping-hands": "clapping hands",
"red-heart": "red heart",
"face-with-tears-of-joy": "face with tears of joy",
"face-with-open-mouth": "surprised face",
"party-popper": "party popper",
"folded-hands": "folded hands"
}
"you": "you"
}
},
"options": {
@@ -231,7 +216,7 @@
"screenRecording": "Screen recording",
"transcript": "Transcription",
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"effects": "Apply effects",
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
@@ -248,34 +233,15 @@
"clear": "Disable effect",
"blur": {
"title": "Background blur",
"status": {
"none": "Background clear.",
"light": "Background slightly blurred.",
"strong": "Background strongly blurred."
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"virtual": {
"title": "Virtual background",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"faceLandmarks": {
"title": "Visual Effects",
@@ -290,11 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
"effects": "Backgrounds and Effects",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
@@ -304,7 +268,7 @@
},
"content": {
"participants": "participants",
"effects": "Backgrounds and Effects",
"effects": "effects",
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
@@ -579,7 +543,7 @@
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply backgrounds and effects",
"effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
@@ -108,18 +108,12 @@
"label": "Language"
},
"settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription"
}
}
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
}
}
}
+16 -52
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -16,7 +15,7 @@
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -40,8 +39,8 @@
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Arrière-plans et effets",
"title": "Arrière-plans et effets",
"description": "Effets d'arrière-plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
@@ -86,7 +85,6 @@
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
@@ -193,9 +191,7 @@
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
"closed": "Afficher les participants"
},
"tools": {
"open": "Masquer les outils de réunion",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "vous",
"emojis": {
"thumbs-up": "pouce levé",
"thumbs-down": "pouce baissé",
"clapping-hands": "applaudissements",
"red-heart": "cœur",
"face-with-tears-of-joy": "visage qui rit",
"face-with-open-mouth": "visage étonné",
"party-popper": "confettis",
"folded-hands": "mains jointes"
}
"you": "vous"
}
},
"options": {
@@ -231,7 +216,7 @@
"screenRecording": "Enregistrement",
"transcript": "Transcription",
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -245,37 +230,18 @@
"cameraDisabled": "Votre caméra est désactivée.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effet",
"clear": "Désactiver l'effect",
"blur": {
"title": "Flou d'arrière-plan",
"status": {
"none": "Arrière-plan net.",
"light": "Arrière-plan légèrement flouté.",
"strong": "Arrière-plan fortement flouté."
},
"light": {
"apply": "Flouter l'arrière-plan",
"clear": "Désactiver le flou"
},
"normal": {
"apply": "Flouter encore plus l'arrière-plan",
"clear": "Désactiver le flou"
}
"light": "Flou léger",
"normal": "Flou",
"apply": "Activer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
"apply": "Activer l'arrière-plan virtuel",
"clear": "Désactiver l'arrière-plan virtuel"
},
"faceLandmarks": {
"title": "Effets visuels",
@@ -290,11 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
"effects": "Arrière-plans et effets",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Transcrire",
"screenRecording": "Enregistrer",
@@ -579,7 +543,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
@@ -108,18 +108,12 @@
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres",
"accessibility": {
"announceReactions": {
"label": "Vocaliser les réactions"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription"
}
}
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Stap over op eenvoud. Probeer ons nu!",
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
},
"slide2": {
"title": "Houdt groepsgesprekken zonder limieten",
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
},
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
}
}
}
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -86,7 +85,6 @@
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Vertrekken",
"participants": {
"open": "Verberg iedereen",
"closed": "Toon iedereen",
"count_one": "{{count}} deelnemer",
"count_other": "{{count}} deelnemers"
"closed": "Toon iedereen"
},
"tools": {
"open": "Meer tools verbergen",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "U",
"emojis": {
"thumbs-up": "duim omhoog",
"thumbs-down": "duim omlaag",
"clapping-hands": "applaudisserende handen",
"red-heart": "rood hart",
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
"face-with-open-mouth": "verrast gezicht",
"party-popper": "feestknaller",
"folded-hands": "gevouwen handen"
}
"you": "U"
}
},
"options": {
@@ -248,34 +233,15 @@
"clear": "Effect uitschakelen",
"blur": {
"title": "Achtergrondvervaging",
"status": {
"none": "Achtergrond scherp.",
"light": "Achtergrond licht vervaagd.",
"strong": "Achtergrond sterk vervaagd."
},
"light": {
"apply": "Vervaag je achtergrond lichtjes",
"clear": "Vervaging uitschakelen"
},
"normal": {
"apply": "Vervaag je achtergrond",
"clear": "Vervaging uitschakelen"
}
"light": "Lichte vervaging",
"normal": "Vervaging",
"apply": "Vervaging inschakelen",
"clear": "Vervaging uitschakelen"
},
"virtual": {
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
"apply": "Virtuele achtergrond inschakelen",
"clear": "Virtuele achtergrond uitschakelen"
},
"faceLandmarks": {
"title": "Visuele effecten",
@@ -290,8 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
-71
View File
@@ -1,71 +0,0 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { ComponentPropsWithoutRef } from 'react'
const iconRecipe = cva({
base: {
fontWeight: 'normal',
fontStyle: 'normal',
display: 'inline-block',
lineHeight: 1,
textTransform: 'none',
letterSpacing: 'normal',
wordWrap: 'normal',
whiteSpace: 'nowrap',
direction: 'ltr',
},
variants: {
type: {
icons: {
fontFamily: 'Material Icons Outlined',
webkitFontSmoothing: 'antialiased',
mozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontFeatureSettings: '"liga"',
},
symbols: {
fontFamily: 'Material Symbols Outlined Variable',
},
},
size: {
sm: {
fontSize: '18px',
},
md: {
fontSize: '24px',
},
lg: {
fontSize: '32px',
},
xl: {
fontSize: '40px',
},
},
},
defaultVariants: {
type: 'icons',
size: 'md',
},
})
export type IconRecipeProps = RecipeVariantProps<typeof iconRecipe>
export type IconProps = IconRecipeProps &
ComponentPropsWithoutRef<'span'> & {
name: string
}
export const Icon = ({ name, ...props }: IconProps) => {
const [variantProps, componentProps] = iconRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<span
translate="no"
aria-hidden="true"
className={[iconRecipe(variantProps), className].join(' ')}
{...remainingComponentProps}
>
{name}
</span>
)
}
+21 -22
View File
@@ -1,10 +1,10 @@
import { forwardRef, ReactNode } from 'react'
import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
@@ -16,25 +16,24 @@ export type ToggleButtonProps = RACToggleButtonProps &
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
({ tooltip, tooltipType, ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
)
ToggleButton.displayName = 'ToggleButton'
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
@@ -1,88 +0,0 @@
import { type ReactNode, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
tooltip: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
tooltip,
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const getPosition = () => {
if (!wrapperRef.current) return null
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
left: rect.left + rect.width / 2,
}
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</div>
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
className={css({
position: 'fixed',
padding: '2px 8px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
borderRadius: '4px',
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
document.body
)}
</>
)
}
+1 -2
View File
@@ -112,8 +112,7 @@ export const buttonRecipe = cva({
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow:
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
},
'&[data-disabled]': {
backgroundColor: 'greyscale.100',
-1
View File
@@ -30,4 +30,3 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
export { Icon } from './Icon'
+5
View File
@@ -43,6 +43,11 @@ export const routes: Record<
feedback: {
name: 'feedback',
path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute,
},
legalTerms: {
-76
View File
@@ -1,76 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
type AccessibilityState = {
announceReactions: boolean
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
}
function getAccessibilityState(): AccessibilityState {
try {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
announceReactions:
typeof parsed.announceReactions === 'boolean'
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
}
}
// Legacy migration: if the setting was previously stored in notifications
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (legacy) {
try {
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
if (typeof parsedLegacy?.announceReactions === 'boolean') {
const migratedState: AccessibilityState = {
...DEFAULT_STATE,
...parsedLegacy,
announceReactions: parsedLegacy.announceReactions,
}
try {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(migratedState)
)
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
} catch {
// ignore persistence issues during migration
}
return migratedState
}
} catch {
// ignore legacy parsing issues
}
}
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const accessibilityStore = proxy<AccessibilityState>(
getAccessibilityState()
)
subscribe(accessibilityStore, () => {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(accessibilityStore)
)
})
-14
View File
@@ -1,14 +0,0 @@
import { css } from '@/styled-system/css'
// Reusable visually hidden style for screen-reader-only content
export const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
})
+27
View File
@@ -0,0 +1,27 @@
@import '@fontsource/material-icons-outlined';
.material-icons,
.material-symbols {
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.material-icons {
font-family: 'Material Icons Outlined';
}
.material-symbols {
font-family: 'Material Symbols Outlined Variable';
}
+1 -33
View File
@@ -1,4 +1,5 @@
@import './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities;
html,
body,
@@ -53,36 +54,3 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
transform: translateY(0);
}
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-icons-outlined/install
*/
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-display: block;
font-weight: 400;
src: url(@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2)
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-symbols-outlined/install
*/
/* material-symbols-outlined-latin-wght-normal */
@font-face {
font-family: 'Material Symbols Outlined Variable';
font-style: normal;
font-display: block;
font-weight: 100 700;
src: url(@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
-1
View File
@@ -4,5 +4,4 @@
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
ACCESSIBILITY: 'app_accessibility_settings',
} as const
@@ -267,7 +267,6 @@ agents:
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
image:
repository: localhost:5001/meet-agents
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.2.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.3.0",
"version": "1.2.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.2.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.3.0"
version = "1.2.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",