Compare commits

..

40 Commits

Author SHA1 Message Date
Sylvain Zimmer c7fdd7c317 🚀(paas) add PaaS deployment scripts, tested on Scalingo 2025-08-08 10:40:12 +02:00
lebaudantoine 201069aa4c ♻️(frontend) refactor clipboard logic into dedicated reusable hook
Extract clipboard content logic from UI components into a separate
custom hook to decouple interface elements from clipboard functionality.

Creates a reusable hook that can better adapt to future UX changes
without requiring modifications to individual UI components.
2025-08-07 12:51:03 +02:00
lebaudantoine b6a5b1a805 🎨(frontend) remove space between PIN digits and # symbol
Update PIN formatting to remove space before the # symbol to clearly
indicate that # is part of the complete PIN code.

Improves user understanding that the hash symbol is an integral part
of the PIN entry sequence, not a separate element.
2025-08-07 12:51:03 +02:00
lebaudantoine f3af637fd6 (frontend) add telephony info to encourage phone participation
Add telephony information to the share dialog when available to help
users take advantage of the newly introduced phone join feature.

Promotes phone participation as an alternative connection method when
users need it, improving meeting accessibility and user adoption of
telephony capabilities.
2025-08-07 12:51:03 +02:00
lebaudantoine de3a5aa404 💄(frontend) update secondaryText button variant to medium font weight
Change secondaryText button style from default to medium font weight
for improved visual comfort and better readability.

I haven't tested this change with Marianne.
2025-08-07 12:51:03 +02:00
lebaudantoine 5e9d20e685 🐛(frontend) fix public room warning showing for all rooms
Remove incorrect public room warning that was always displayed
regardless of room privacy settings.

Warning should only appear for genuinely public rooms since the lobby
system introduction changed room privacy behavior.

Prevents user confusion about room privacy settings.
2025-08-07 12:51:03 +02:00
lebaudantoine b54445739a (frontend) add telephony info to meeting dialog with layout stability
Add telephony information display to the later meeting dialog while
preserving existing layout when telephony is disabled.

Prevent layout shift on modal close by collapsing all modal content
immediately when room becomes undefined.

Critical enhancement for users creating meeting links to have complete
connection information available.
2025-08-07 12:51:03 +02:00
lebaudantoine 7c67bacd94 🚸(frontend) fix locale issues and improve meeting dialog copywriting
Remove ProConnect mentions from frontend locale and enhance the meeting
dialog description text to clearly explain that meeting links are
permanent and persistent.

Improves user understanding of meeting link permanence and cleans up
branding references.
2025-08-07 12:51:03 +02:00
lebaudantoine 1fd1b184ee 💬(frontend) update wording from "room address" to "room information"
Change UI text to use "room information" instead of "room address"
to better reflect the functionality whether copying just the meeting
link or complete meeting details.
2025-08-07 12:51:03 +02:00
lebaudantoine adb517a336 🎨(frontend) harmonize room info sidepanel display with meeting dialog
Remove protocol prefix from room URLs in the information sidepanel to
match the syntax used in the meeting dialog.

This creates consistent URL display formatting across both UI components
for better user experience.
2025-08-07 12:51:03 +02:00
lebaudantoine eec9ff9f26 🚸(frontend) fix copy to include all meeting info from side panel
Rework clipboard functionality to copy complete meeting information when
users click the copy button in the information side panel.

Previously only partial data was copied, causing user confusion. Now
includes all relevant meeting details as expected.

Improves user experience by meeting user expectations for copy behavior.
2025-08-07 12:51:03 +02:00
lebaudantoine e0258a1765 🔧(tilt) configure telephony in tilt stack for development
Add default telephony configuration to the tilt stack to enable
development workflow around authentication features.

Note: This is a fake/mock configuration and is not functional for
production use. It's intended solely for development purposes.
2025-08-07 12:51:03 +02:00
lebaudantoine 872ce1ecc6 (frontend) add icon support to select primitive component
Enhance select component by adding icon display capability for better
visual representation of options.
2025-08-01 17:45:48 +02:00
lebaudantoine e2c3b745ca 🩹(frontend) avoid video glitch on the prejoin while init a processor
We've introduced simplifications that improve performance, enhance ux,
and contribute to the overall perception of experience quality.

Previously, our processor handling was overly complex. LiveKit allows us to set
a processor before starting the local video preview track, which eliminates
the black blink glitch that appeared when loading the join component
with a default processor.

This change prevents the unnecessary stopping and restarting
of the local video track.

I'm glad this issue is now resolved.

We also simplified component communication by avoiding props drilling.
Now, we use a single flag to indicate when the user is ready to enter the room.
This significantly reduces the complexity of props passed through components.
2025-08-01 17:40:11 +02:00
lebaudantoine 965d823d08 (frontend) display position of a raised hand in the queue
Users requested an enhanced visual indicator
for raised hands on the participant tile.

Most major video conferencing platforms display the position of a raised hand
in the queue. This helps hosts quickly see who is requesting to speak,
and in what order, without needing to open the full participant list.

While a minor feature, this improvement is especially valuable for power user
2025-08-01 16:54:24 +02:00
lebaudantoine 1db189ace2 🚸(frontend) sort raised hand by order of arrival
Previously, the participant list was sorted alphabetically by name.
This unintentionally affected the raised hands list,
which inherited the same sorting behavior.

Users requested that raised hands be sorted by order of arrival.
This simple change improves the UX by ensuring that raised hands
are displayed in the correct order.
2025-08-01 16:54:24 +02:00
lebaudantoine 199e0908e9 ♻️(frontend) refactor raised hand to rely on participant's attributes
Previously managed participant hand raised using raw metadata. LiveKit
introduced attributes concept, saves as metadata under hood but makes
update/handling easier.

Still pain that attributes must be strings, cannot pass boolean…

Refactor whole app to use attributes instead of metadata for the raised
hand feature. This commit might introduce regressions. I've double
checked participant list side pannel and the notification features.

Previously I persisted a boolean, I now persist the timestamp at which
the hand was raised. This will be useful in upcoming commits, especially
for sorting raised hands by order of arrival.
2025-08-01 16:54:24 +02:00
Jacques ROUSSEL 8518f83211 (helm) add the ability to configure tls secretName
Yesterday during a deployment, we were unable to configure the tls
secretName for ingress.
2025-08-01 16:53:36 +02:00
lebaudantoine 0240d85837 📝(backend) add Firefox proxy workaround parameter to installation guide
Document LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND setting that enables
connection warmup by pre-calling WebSocket endpoint to resolve proxy
connectivity issues.
2025-08-01 16:23:22 +02:00
lebaudantoine 162896c93c 🩹(backend) allow enforcing WSS protocol to resolve browser compatibility
The LiveKit API URL is necessary to interact with the API. It uses https
protocol.

Eplicit wss protocol is necessary in Websocket constructor for some
older browsers.

This resolves critical compatibility issues with legacy browsers
(notably Firefox <124, Chrome <125, Edge <125) that lack support
for HTTPS URLs in the WebSocket() constructor. Without explicit WSS
URLs, WebSocket signaling connections may fail, crash, or be blocked
entirely in these environments.

The setting is optional and defaults to the current behavior when
not specified, ensuring zero breaking changes for existing deployments.
2025-08-01 16:23:22 +02:00
Jacques ROUSSEL 483a219ac4 ♻️(documentation) remove unused environment variables
Yesterday during a deployment, we discovered that these variables are
unused:
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
2025-08-01 16:14:55 +02:00
lebaudantoine 1b26dea178 🐛(frontend) use feature detection for adaptiveStream and dynacast
Replace hardcoded true values with supportsAdaptiveStream() and
supportsDynacast() checks. LiveKit SDK supports broad browser range but
requires specific APIs - modern features need explicit compatibility checks.

Prevents enabling unsupported WebRTC features on incompatible browsers,
which could led to a poor user experience.

One alternative solution could be to install polyfills.
2025-08-01 15:46:17 +02:00
lebaudantoine bdaf4245da 🔖(minor) bump release to 0.1.33
Warmup with WebSocket pre-authentication on FF
2025-07-25 08:50:33 +02:00
lebaudantoine be63993ba2 🩹(frontend) fix connection warmup with WebSocket pre-authentication
Connection warmup wasn't working properly - only works when trying to
establish WebSocket first, then workaround kicks in. Call WebSocket
endpoint without auth info expecting 401 error, but enough to initiate
cache for subsequent WebSocket functionality.

Scope this **dirty** trick to Firefox users only. Haven't figured out
how to detect proxy from JS code simply.

Tested in staging and works on our constrained WiFi.
2025-07-25 08:50:33 +02:00
lebaudantoine 3d245c3bd4 🔖(minor) bump release to 0.1.32
warmup livekit connection.
2025-07-24 14:57:48 +02:00
lebaudantoine 66a36eff73 ♻️(frontend) refactor connection warmup to use LiveKit methods
Replace custom connection warmup implementation with LiveKit's built-in
methods for better reliability and consistency.
2025-07-24 14:49:49 +02:00
lebaudantoine 387bc2e1f4 🐛(frontend) add LiveKit connection warmup for Firefox+proxy fixes
Implement HTTPS prefetch before joining rooms to resolve WebSocket
handshake failures where Firefox+proxy returns HTTP 200 instead of 101.

Reproduced locally with Squid container. No proxy configuration fixes
found - HTTPS warmup is only working workaround. Issue doesn't occur
when signaling server shares webapp domain, making warmup unnecessary.

Use HEAD request to minimize bandwidth.
2025-07-24 14:32:51 +02:00
lebaudantoine d44b45b6aa 🐛(frontend) prevent shortcut handling when key is undefined
Add null check to avoid processing keyboard shortcuts with undefined
key values that could cause errors.

Exception caught in PostHog
2025-07-23 12:06:39 +02:00
lebaudantoine 224b98fd9a 🧪(frontend) capture LiveKit exceptions in PostHog for debugging
Test tracking signaling failures to determine root causes when connection
fails. Will remove if it spams analytics - goal is understanding failure
patterns and reasons.
2025-07-23 12:06:39 +02:00
lebaudantoine 031852d0b1 🔖(minor) bump release to 0.1.31
Fix noise reduction feature flag
2025-07-21 12:00:06 +02:00
lebaudantoine 24915b0485 🐛(frontend) fix wrong feature flag for noise reduction
Replace incorrect faceLandmarks flag with proper noiseReduction flag
to ensure correct feature availability checking.
2025-07-21 10:29:36 +02:00
ericboucher 0862203d5d 🚸(frontend) add Safari warning for unavailable speaker settings
Display notice explaining that missing browser settings are due to Safari
limitations, not app bugs, to prevent user confusion.
2025-07-20 18:35:32 +02:00
ericboucher ee604abe00 🐛(frontend) replace useSize with useMediaQuery in account settings
Fix buggy layout transitions on Safari mobile by using media queries
instead of size detection for smoother settings panel responsive behavior.
2025-07-20 18:35:32 +02:00
lebaudantoine 26d668b478 🐛(frontend) prevent Crisp crash when superuser has no email
Fix crash when switching from admin session to app with superuser account
that lacks email field. Add null check to prevent Crisp initialization
errors.
2025-07-20 18:00:08 +02:00
lebaudantoine 04081f04fc 🌐(frontend) internationalize missing error message
Add translation support for previously untranslated error message to
complete localization coverage.
2025-07-20 18:00:08 +02:00
ericboucher f7268c507b 🚸(frontend) display email with username to clarify logged-in account
Show email alongside full name when available to prevent user confusion
about which account is currently logged in. Enhances general app UX.
2025-07-20 18:00:08 +02:00
lebaudantoine cadb20793a 🔖(minor) bump release to 0.1.30
various fixes
2025-07-18 11:48:02 +02:00
lebaudantoine 8a417806e4 🐛(backend) fix lobby notification type error breaking participant alerts
Correct data type issue that was preventing lobby notifications from
being sent to other participants in the room.
2025-07-18 11:42:43 +02:00
lebaudantoine 223c744e3f 🚨(summary) lint celery_config module to pass ruff checks
Apply code formatting and style fixes to celery configuration
module to meet linting standards.
2025-07-18 11:29:13 +02:00
lebaudantoine f67335f306 🐛(summary) fix Celery task execution by number of required args
Forgot to update the args verification while enriching the task
with more recording metadata.
2025-07-18 11:29:13 +02:00
72 changed files with 917 additions and 385 deletions
+2
View File
@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
mv src/backend/* ./
mv src/nginx/* ./
echo "3.13" > .python-version
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b :8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
-3
View File
@@ -35,9 +35,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+2 -3
View File
@@ -217,9 +217,6 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
@@ -339,6 +336,8 @@ These are the environmental options available on meet backend.
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
+5
View File
@@ -50,6 +50,11 @@ def get_frontend_configuration(request):
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration)
+1 -1
View File
@@ -212,7 +212,7 @@ class LobbyService:
try:
utils.notify_participants(
room_name=room_id,
room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
@@ -442,7 +442,7 @@ def test_enter_success(
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=room.id, notification_data={"type": "participantWaiting"}
room_name=str(room.id), notification_data={"type": "participantWaiting"}
)
+13 -2
View File
@@ -11,11 +11,12 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
from os import path
from os import environ, path
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -86,7 +87,9 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if environ.get("DATABASE_URL")
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -492,6 +495,14 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
default=False,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
+2 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.29"
version = "0.1.33"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -29,6 +29,7 @@ dependencies = [
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.5.3",
"dj-database-url==2.3.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python
"""Setup file for the impress module. All configuration stands in the setup.cfg file."""
# coding: utf-8
from setuptools import setup
setup()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.29",
"version": "0.1.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.29",
"version": "0.1.33",
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.29",
"version": "0.1.33",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+5
View File
@@ -37,6 +37,11 @@ export interface ApiConfig {
default_country?: string
}
manifest_link?: string
livekit: {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
}
}
const fetchConfig = (): Promise<ApiConfig> => {
@@ -1,100 +1,187 @@
import { useEffect, useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { ApiAccessLevel, ApiRoom } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({
roomId,
room,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false)
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && room?.pin_code
}, [telephony?.enabled, room?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined)
return (
<Dialog
isOpen={!!roomId}
{...dialogProps}
title={t('laterMeetingDialog.heading')}
>
<P>{t('laterMeetingDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('laterMeetingDialog.copy')}
style={{
justifyContent: 'start',
}}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('laterMeetingDialog.copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('laterMeetingDialog.copy')
) : (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
<P>{t('description')}</P>
{!!roomUrl && (
<>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
backgroundColor: 'gray.50',
borderRadius: '0.75rem',
display: 'flex',
flexDirection: 'column',
padding: '1.75rem 1.5rem',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
})}
>
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
{roomUrl.replace(/^https?:\/\//, '')}
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
)}
</>
)}
</Button>
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('laterMeetingDialog.permissions')}
</Text>
</HStack>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(room?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'tertiaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'primary'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
onHoverChange={setIsHovered}
data-attr="later-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
size={18}
style={{ marginRight: '8px', minWidth: '18px' }}
/>
{isHovered ? (
t('copy')
) : (
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
>
{roomUrl?.replace(/^https?:\/\//, '')}
</div>
)}
</>
)}
</Button>
)}
{room?.access_level == ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</>
)}
</Dialog>
)
}
@@ -18,6 +18,7 @@ import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -153,7 +154,7 @@ export const Home = () => {
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const { data } = useConfig()
@@ -202,7 +203,7 @@ export const Home = () => {
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoomId(data.slug)
setLaterRoom(data)
)
}}
data-attr="create-option-later"
@@ -251,8 +252,8 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
roomId={laterRoomId ?? ''}
onOpenChange={() => setLaterRoomId(null)}
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
/>
</Screen>
</UserAware>
@@ -18,7 +18,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -155,17 +154,14 @@ export const MainNotificationToast = () => {
useEffect(() => {
const handleNotificationReceived = (
prevMetadataStr: string | undefined,
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant) return
if (isMobileBrowser()) return
if (participant.isLocal) return
const prevMetadata = safeParseMetadata(prevMetadataStr)
const metadata = safeParseMetadata(participant.metadata)
if (prevMetadata?.raised == metadata?.raised) return
if (!('handRaisedAt' in changedAttributes)) return
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
@@ -173,12 +169,12 @@ export const MainNotificationToast = () => {
toast.content.type === NotificationType.HandRaised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
if (existingToast && !changedAttributes?.handRaisedAt) {
toastQueue.close(existingToast.key)
return
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
if (!existingToast && !!changedAttributes?.handRaisedAt) {
triggerNotificationSound(NotificationType.HandRaised)
toastQueue.add(
{
@@ -190,10 +186,13 @@ export const MainNotificationToast = () => {
}
}
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
return () => {
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
room.off(
RoomEvent.ParticipantAttributesChanged,
handleNotificationReceived
)
}
}, [room, triggerNotificationSound])
@@ -1,12 +1,17 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom } from '@livekit/components-react'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -18,29 +23,38 @@ import { ApiRoom } from '../api/ApiRoom'
import { useCreateRoom } from '../api/createRoom'
import { InviteDialog } from './InviteDialog'
import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
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'
export const Conference = ({
roomId,
userConfig,
initialRoomData,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices
}
useEffect(() => {
posthog.capture('visit-room', { slug: roomId })
}, [roomId])
}, [roomId, posthog])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
const {
mutateAsync: createRoom,
status: createStatus,
@@ -72,10 +86,13 @@ export const Conference = ({
retry: false,
})
const isAdaptiveStreamSupported = supportsAdaptiveStream()
const isDynacastSupported = supportsDynacast()
const roomOptions = useMemo((): RoomOptions => {
return {
adaptiveStream: true,
dynacast: true,
adaptiveStream: isAdaptiveStreamSupported,
dynacast: isDynacastSupported,
publishDefaults: {
videoCodec: 'vp9',
},
@@ -87,10 +104,60 @@ export const Conference = ({
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
useEffect(() => {
/**
* Warm up connection to LiveKit server before joining room
* This prefetch helps reduce initial connection latency by establishing
* an early HTTP connection to the WebRTC signaling server
*
* It should cache DNS and TLS keys.
*/
const prepareConnection = async () => {
if (!apiConfig || isConnectionWarmedUp) return
await room.prepareConnection(apiConfig.livekit.url)
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
try {
const wssUrl =
apiConfig.livekit.url
.replace('https://', 'wss://')
.replace(/\/$/, '') + '/rtc'
/**
* FIREFOX + PROXY WORKAROUND:
*
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
*
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
*
* Note: This issue is reproducible on LiveKit's demo app.
* Reference: livekit-examples/meet/issues/466
*/
const ws = new WebSocket(wssUrl)
// 401 unauthorized response is expected
ws.onerror = () => ws.readyState <= 1 && ws.close()
} catch (e) {
console.debug('Firefox WebSocket workaround failed.', e)
}
}
setIsConnectionWarmedUp(true)
}
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
@@ -100,6 +167,20 @@ export const Conference = ({
kind: null,
})
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
@@ -123,9 +204,9 @@ export const Conference = ({
<Screen header={false} footer={false}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
serverUrl={serverUrl}
token={data?.livekit?.token}
connect={true}
connect={isConnectionWarmedUp}
audio={userConfig.audioEnabled}
video={
userConfig.videoEnabled && {
@@ -138,6 +219,9 @@ export const Conference = ({
className={css({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
posthog.captureException(e)
}}
onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
@@ -156,7 +240,6 @@ export const Conference = ({
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, type DialogProps, P } from '@/primitives'
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
@@ -10,8 +10,13 @@ import {
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useEffect, useState } from 'react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
// fixme - extract in a proper primitive this dialog without overlay
const StyledRACDialog = styled(Dialog, {
@@ -34,24 +39,27 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const [isCopied, setIsCopied] = useState(false)
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
return (
<StyledRACDialog {...dialogProps}>
<StyledRACDialog {...props}>
{({ close }) => (
<VStack
alignItems="left"
@@ -60,7 +68,7 @@ export const InviteDialog = ({
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('shareDialog.heading')}
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -68,7 +76,7 @@ export const InviteDialog = ({
variant="tertiaryText"
size="xs"
onPress={() => {
dialogProps.onClose?.()
props.onClose?.()
close()
}}
aria-label={t('closeDialog')}
@@ -76,49 +84,125 @@ export const InviteDialog = ({
<RiCloseLine />
</Button>
</Div>
<P>{t('shareDialog.description')}</P>
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('shareDialog.copy')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('shareDialog.copyButton')}
</>
)}
</Button>
<HStack>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
})}
>
<RiSpam2Fill
size={22}
<div
className={css({
fill: 'primary.500',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
/>
>
<Text as="p" wrap="pretty">
{roomUrl?.replace(/^https?:\/\//, '')}
</Text>
{isTelephonyReadyForUse && roomUrl && (
<Button
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
</Button>
)}
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
})}
>
<Text as="p" wrap="pretty">
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
{telephony?.internationalPhoneNumber}
</Text>
<Text as="p" wrap="pretty">
<Bold>{t('phone.pinCode')}</Bold>{' '}
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
/>
{t('copy')}
</>
)}
</Button>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('shareDialog.permissions')}
</Text>
</HStack>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
{isCopied ? (
<>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
)}
</StyledRACDialog>
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
@@ -27,7 +27,6 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -107,10 +106,10 @@ const Effects = ({
}
export const Join = ({
onSubmit,
enterRoom,
roomId,
}: {
onSubmit: (choices: LocalUserChoices) => void
enterRoom: () => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -132,23 +131,14 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
video: {
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
},
},
onError
)
@@ -192,25 +182,6 @@ export const Join = ({
}
}, [videoTrack, videoEnabled])
const enterRoom = useCallback(() => {
onSubmit({
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processorSerialized: processor?.serialize(),
})
}, [
onSubmit,
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
username,
processor,
])
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
@@ -269,16 +240,6 @@ export const Join = ({
enterRoom()
}
// This hook is used to setup the persisted user choice processor on initialization.
// So it's on purpose that processor is not included in the deps.
// We just want to wait for the videoTrack to be loaded to apply the default processor.
useEffect(() => {
if (processor && videoTrack && !videoTrack.getProcessor()) {
videoTrack.setProcessor(processor)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
const renderWaitingState = () => {
switch (status) {
case ApiLobbyStatus.TIMEOUT:
@@ -453,7 +414,12 @@ export const Join = ({
</p>
</div>
)}
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
export const useLowerHandParticipant = () => {
const data = useRoomData()
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
if (!data || !data?.livekit) {
throw new Error('Room data is not available')
}
const newMetadata = safeParseMetadata(participant.metadata) || {}
newMetadata.raised = !newMetadata.raised
const newAttributes = {
...participant.attributes,
handRaisedAt: '',
}
return fetchServerApi(
buildServerApiUrl(
data.livekit.url,
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
body: JSON.stringify({
room: data.livekit.room,
identity: participant.identity,
metadata: JSON.stringify(newMetadata),
attributes: newAttributes,
permission: participant.permissions,
}),
}
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { useMemo } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -8,24 +8,22 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
import { useRoomData } from '../hooks/useRoomData'
import { formatPinCode } from '../../utils/telephony'
import { useTelephony } from '../hooks/useTelephony'
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && data?.pin_code
}, [telephony?.enabled, data?.pin_code])
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
return (
<Div
display="flex"
@@ -53,9 +51,9 @@ export const Info = () => {
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl}
{roomUrl.replace(/^https?:\/\//, '')}
</Text>
{telephony?.enabled && data?.pin_code && (
{isTelephonyReadyForUse && (
<>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.call')}</Bold> (
@@ -72,10 +70,7 @@ export const Info = () => {
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
onPress={copyRoomToClipboard}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
@@ -22,7 +22,7 @@ import {
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand } from '../hooks/useRaisedHand'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
@@ -97,6 +97,10 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
@@ -141,24 +145,32 @@ export const ParticipantTile: (
style={{
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare ? 'white' : undefined,
isHandRaised && !isScreenShare
? firstInQueue
? '#fde047'
: 'white'
: undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{isHandRaised && !isScreenShare && (
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
<>
<span>{positionInQueue}</span>
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
marginLeft: '0.1rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
</>
)}
{isScreenShare && (
<ScreenShareIcon
@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants.filter((participant) => {
const data = safeParseMetadata(participant.metadata)
return data.raised
})
const raisedHandParticipants = participants
.filter((participant) => !!participant.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
return timeA - timeB
})
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
@@ -0,0 +1,79 @@
import { useTelephony } from './useTelephony'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { formatPinCode } from '@/features/rooms/utils/telephony'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { getRouteUrl } from '@/navigation/getRouteUrl'
const COPY_SUCCESS_TIMEOUT = 3000
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
const telephony = useTelephony()
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
const [isCopied, setIsCopied] = useState(false)
const [isRoomUrlCopied, setIsRoomUrlCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), COPY_SUCCESS_TIMEOUT)
return () => clearTimeout(timeout)
}
}, [isCopied])
useEffect(() => {
if (isRoomUrlCopied) {
const timeout = setTimeout(
() => setIsRoomUrlCopied(false),
COPY_SUCCESS_TIMEOUT
)
return () => clearTimeout(timeout)
}
}, [isRoomUrlCopied])
const roomUrl = useMemo(() => {
return room?.slug ? getRouteUrl('room', room.slug) : ''
}, [room?.slug])
const hasTelephonyInfo = useMemo(() => {
return telephony.enabled && room?.pin_code
}, [telephony.enabled, room?.pin_code])
const content = useMemo(() => {
if (!roomUrl || !room) return ''
if (!hasTelephonyInfo) return roomUrl
return [
t('url', { roomUrl }),
t('numberAndPin', {
phoneNumber: telephony?.internationalPhoneNumber,
pinCode: formatPinCode(room.pin_code),
}),
].join('\n')
}, [roomUrl, hasTelephonyInfo, telephony, room, t])
const copyRoomToClipboard = async () => {
try {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
console.error(error)
}
}
const copyRoomUrlToClipboard = async () => {
try {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
console.error(error)
}
}
return {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
}
}
@@ -4,7 +4,7 @@ import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalytics
import { isMobileBrowser } from '@livekit/components-core'
export const useNoiseReductionAvailable = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.noiseReduction)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isMobile = isMobileBrowser()
@@ -1,23 +1,58 @@
import { LocalParticipant, Participant } from 'livekit-client'
import { useParticipantInfo } from '@livekit/components-react'
import {
useParticipantAttribute,
useParticipants,
} from '@livekit/components-react'
import { isLocal } from '@/utils/livekit'
import { useMemo } from 'react'
type useRaisedHandProps = {
participant: Participant
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
// fixme - refactor this part to rely on attributes
const { metadata } = useParticipantInfo({ participant })
const parsedMetadata = JSON.parse(metadata || '{}')
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
const { isHandRaised } = useRaisedHand({ participant })
const toggleRaisedHand = () => {
if (isLocal(participant)) {
parsedMetadata.raised = !parsedMetadata.raised
const localParticipant = participant as LocalParticipant
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
const participants = useParticipants()
const positionInQueue = useMemo(() => {
if (!isHandRaised) return
return (
participants
.filter((p) => !!p.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
return dateA.getTime() - dateB.getTime()
})
.findIndex((p) => p.identity === participant.identity) + 1
)
}, [participants, participant, isHandRaised])
return {
positionInQueue,
firstInQueue: positionInQueue == 1,
}
}
export function useRaisedHand({ participant }: useRaisedHandProps) {
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
participant,
})
const isHandRaised = !!handRaisedAtAttribute
const toggleRaisedHand = async () => {
if (!isLocal(participant)) return
const localParticipant = participant as LocalParticipant
const attributes: Record<string, string> = {
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
}
await localParticipant.setAttributes(attributes)
}
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
return { isHandRaised, toggleRaisedHand }
}
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
@@ -10,12 +9,10 @@ import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -48,10 +45,10 @@ export const Room = () => {
return <ErrorScreen />
}
if (!userConfig && !skipJoinScreen) {
if (!hasSubmittedEntry && !skipJoinScreen) {
return (
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</UserAware>
)
}
@@ -62,10 +59,6 @@ export const Room = () => {
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</UserAware>
)
@@ -1,22 +0,0 @@
export const safeParseMetadata = (
metadataStr: string | null | undefined
): Record<string, unknown> => {
if (!metadataStr) {
return {}
}
try {
const parsed = JSON.parse(metadataStr)
// Ensure the result is an object
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
console.warn('Metadata parsed to non-object value:', parsed)
return {}
}
return parsed as Record<string, unknown>
} catch (error) {
console.error('Failed to parse metadata:', error)
return {}
}
}
@@ -19,5 +19,5 @@ export const parseConfigPhoneNumber = (
}
export function formatPinCode(pinCode?: string) {
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')} #`
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#`
}
@@ -14,8 +14,8 @@ import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -51,8 +51,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null)
const { width } = useSize(dialogEl)
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
@@ -1,4 +1,4 @@
import { DialogProps, Field, H, Switch } from '@/primitives'
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -174,7 +174,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
</RowWrapper>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() && (
{!isSafari() ? (
<RowWrapper heading={t('audio.speakers.heading')}>
<Field
type="select"
@@ -193,6 +193,13 @@ export const AudioTab = ({ id }: AudioTabProps) => {
/>
<SoundTester />
</RowWrapper>
) : (
<RowWrapper heading={t('audio.speakers.heading')}>
<Text variant="warning" margin="md">
{t('audio.speakers.safariWarning')}
</Text>
<div />
</RowWrapper>
)}
{noiseReductionAvailable && (
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
@@ -12,6 +12,7 @@ export const useKeyboardShortcuts = () => {
// Issues might occur. First draft.
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
+4
View File
@@ -51,5 +51,9 @@
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
},
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Ihre Zugangsdaten",
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
"copy": "Meeting-Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert",
"copyUrl": "Meeting-Link kopieren",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
+11 -7
View File
@@ -53,12 +53,16 @@
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "Meeting-Link kopieren",
"copyButton": "Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"copy": "Informationen kopieren",
"copyUrl": "Link kopieren",
"copied": "Informationen kopiert",
"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."
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -226,9 +230,9 @@
"roomInformation": {
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert"
},
"phone": {
"call": "Rufen Sie an:",
+2 -1
View File
@@ -25,7 +25,8 @@
"heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe",
"test": "Testen",
"ongoingTest": "Soundtest läuft…"
"ongoingTest": "Soundtest läuft…",
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
},
"permissionsRequired": "Berechtigungen erforderlich"
},
+4
View File
@@ -51,5 +51,9 @@
"ariaLabel": "Close the suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "To join the video conference, click on this link: {{roomUrl}}",
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Your connection details",
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without ProConnect.",
"copy": "Copy the meeting link",
"copied": "Link copied to clipboard",
"permissions": "People with this link do not need your permission to join this meeting."
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
"permissions": "People with this link do not need your permission to join this meeting.",
"copy": "Copy information",
"copied": "Information copied to clipboard",
"copyUrl": "Copy the meeting link",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
+11 -7
View File
@@ -53,12 +53,16 @@
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy the meeting link",
"copyButton": "Copy link",
"copied": "Link copied to clipboard",
"copy": "Copy information",
"copyUrl": "Copy link",
"copied": "Information copied to clipboard",
"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."
"permissions": "People with this link do not need your permission to join this meeting.",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -226,9 +230,9 @@
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
"ariaLabel": "Copy the information from your meeting",
"copy": "Copy information",
"copied": "Information copied"
},
"phone": {
"call": "Call:",
+2 -1
View File
@@ -25,7 +25,8 @@
"heading": "Speakers",
"label": "Select your audio output",
"test": "Test",
"ongoingTest": "Testing sound…"
"ongoingTest": "Testing sound…",
"safariWarning": "Speaker selection is not available yet on Safari due to browser limitations."
},
"permissionsRequired": "Permissions required"
},
+4
View File
@@ -51,5 +51,9 @@
"ariaLabel": "Fermer la suggestion",
"label": "OK"
}
},
"clipboardContent": {
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans ProConnect.",
"copy": "Copier le lien de la réunion",
"copied": "Lien copié dans le presse-papiers",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"copy": "Copier les informations",
"copied": "Copiées dans le presse-papiers",
"copyUrl": "Copier le lien de la réunion",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
},
"introSlider": {
"previous": {
+12 -8
View File
@@ -53,12 +53,16 @@
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier le lien de la réunion",
"copyButton": "Copier le lien",
"copied": "Lien copié dans le presse-papiers",
"copy": "Copier les informations",
"copyUrl": "Copier le lien",
"copied": "Copiées dans le presse-papiers",
"heading": "Votre réunion est prête",
"description": "Partagez ce lien 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."
"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.",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -226,9 +230,9 @@
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
"ariaLabel": "Copier les informations de votre réunion",
"copy": "Copier les informations",
"copied": "Informations copiées"
},
"phone": {
"call": "Appelez le :",
+2 -1
View File
@@ -25,7 +25,8 @@
"heading": "Haut-parleurs",
"label": "Sélectionner votre sortie audio",
"test": "Tester",
"ongoingTest": "Test du son…"
"ongoingTest": "Test du son…",
"safariWarning": "La sélection du haut-parleur n'est pas encore disponible sur Safari en raison de limitations du navigateur."
},
"permissionsRequired": "Autorisations nécessaires"
},
+4
View File
@@ -50,5 +50,9 @@
"ariaLabel": "Sluit de suggestie",
"label": "OK"
}
},
"clipboardContent": {
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
}
}
+9 -4
View File
@@ -19,10 +19,15 @@
},
"laterMeetingDialog": {
"heading": "Uw verbindingsgegevens",
"description": "Stuur deze link naar de mensen die u wilt uitnodigen voor de vergadering. Zij kunnen deelnemen zonder ProConnect.",
"copy": "Kopieer de vergaderlink",
"copied": "Link gekopieerd naar klembord",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
"description": "Deel deze informatie met de genodigden. Zij kunnen deelnemen aan de vergadering zonder zich aan te melden. Deze vergadering is permanent en kan hergebruikt worden.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd",
"copyUrl": "Kopieer de vergaderlink",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
},
"introSlider": {
"previous": {
+11 -7
View File
@@ -53,12 +53,16 @@
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
"shareDialog": {
"copy": "Kopieer de vergaderlink",
"copyButton": "Kopieerlink",
"copied": "Link gekopieerd naar het klembord",
"copy": "Informatie kopiëren",
"copyUrl": "Kopieerlink",
"copied": "Informatie gekopieerd",
"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."
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -226,9 +230,9 @@
"roomInformation": {
"title": "Verbindingsinformatie",
"button": {
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
"ariaLabel": "Kopieer de informatie van uw vergadering",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd"
},
"phone": {
"call": "Bel:",
+2 -1
View File
@@ -25,7 +25,8 @@
"heading": "Luidsprekers",
"label": "Selecteer uw audio-uitvoer",
"test": "Test",
"ongoingTest": "Testgeluid ..."
"ongoingTest": "Testgeluid ...",
"safariWarning": "Luidsprekerselectie is nog niet beschikbaar op Safari vanwege browserbeperkingen."
},
"permissionsRequired": "Machtigingen vereist"
},
+22 -2
View File
@@ -1,6 +1,6 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { RiArrowDropDownLine } from '@remixicon/react'
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
ListBox,
@@ -12,12 +12,14 @@ import {
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
const StyledButton = styled(Button, {
base: {
width: 'full',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
@@ -53,22 +55,40 @@ const StyledSelectValue = styled(SelectValue, {
},
})
const StyledIcon = styled('div', {
base: {
marginRight: '0.35rem',
flexShrink: 0,
},
})
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
...props
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
}) => {
const IconComponent = iconComponent
return (
<RACSelect {...props}>
{label}
<StyledButton>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
</StyledIcon>
)}
<StyledSelectValue />
<RiArrowDropDownLine aria-hidden="true" />
<RiArrowDropDownLine
aria-hidden="true"
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
+3
View File
@@ -53,6 +53,9 @@ export const text = cva({
note: {
color: 'default.subtle-text',
},
warning: {
color: 'danger.subtle-text',
},
smNote: {
color: 'default.subtle-text',
textStyle: 'sm',
@@ -68,6 +68,7 @@ export const buttonRecipe = cva({
},
secondaryText: {
backgroundColor: 'transparent',
fontWeight: 'medium !important',
color: 'primary.800',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
@@ -40,9 +40,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
@@ -40,9 +40,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
@@ -52,6 +49,8 @@ backend:
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
@@ -70,6 +69,8 @@ backend:
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
ROOM_TELEPHONY_ENABLED: True
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
@@ -62,9 +62,6 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.9
version: 0.0.10
+4
View File
@@ -21,6 +21,7 @@
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.secretName` | Secret name for TLS config | `nil` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
@@ -30,6 +31,7 @@
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
@@ -163,6 +165,7 @@
| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` |
| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` |
| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
| `posthog.ingress.tls.secretName` | Secret name for TLS config | `nil` |
| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` |
| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
@@ -172,6 +175,7 @@
| `posthog.ingressAssets.path` | URL path prefix for the ingress routes (e.g., /) | `/static` |
| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` |
| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
| `posthog.ingressAssets.tls.secretName` | Secret name for TLS config | `nil` |
| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` |
| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.ingress.tls.enabled }}
tls:
{{- if .Values.ingress.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingress.host | quote }}
{{- end }}
@@ -30,6 +30,7 @@ spec:
tls:
{{- if .Values.ingressAdmin.host }}
- secretName: {{ $fullName }}-tls
- secretName: {{ .Values.ingressAdmin.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressAdmin.host | quote }}
{{- end }}
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
{{- if .Values.posthog.ingress.tls.enabled }}
tls:
{{- if .Values.posthog.ingress.host }}
- secretName: {{ $fullName }}-posthog-tls
- secretName: {{ .Values.posthog.ingress.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
hosts:
- {{ .Values.posthog.ingress.host | quote }}
{{- end }}
@@ -29,7 +29,7 @@ spec:
{{- if .Values.posthog.ingressAssets.tls.enabled }}
tls:
{{- if .Values.posthog.ingressAssets.host }}
- secretName: {{ $fullName }}-posthog-tls
- secretName: {{ .Values.posthog.ingressAssets.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
hosts:
- {{ .Values.posthog.ingressAssets.host | quote }}
{{- end }}
+9 -1
View File
@@ -38,10 +38,12 @@ ingress:
hosts: []
# - chart-example.local
## @param ingress.tls.enabled Weather to enable TLS for the Ingress
## @param ingress.tls.secretName Secret name for TLS config
## @skip ingress.tls.additional
## @extra ingress.tls.additional[].secretName Secret name for additional TLS config
## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
@@ -62,10 +64,12 @@ ingressAdmin:
hosts: [ ]
# - chart-example.local
## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress
## @param ingressAdmin.tls.secretName Secret name for TLS config
## @skip ingressAdmin.tls.additional
## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config
tls:
secretName: null
enabled: true
additional: []
@@ -87,8 +91,8 @@ ingressMedia:
## @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
enabled: true
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
@@ -358,6 +362,7 @@ posthog:
## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /)
## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress
## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress
## @param posthog.ingress.tls.secretName Secret name for TLS config
## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates
## @param posthog.ingress.customBackends Custom backend service configurations for the ingress
## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress
@@ -368,6 +373,7 @@ posthog:
path: /
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]
@@ -380,6 +386,7 @@ posthog:
## @param posthog.ingressAssets.path URL path prefix for the ingress routes (e.g., /)
## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress
## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress
## @param posthog.ingressAssets.tls.secretName Secret name for TLS config
## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates
## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress
## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress
@@ -390,6 +397,7 @@ posthog:
path: /static
hosts: [ ]
tls:
secretName: null
enabled: true
additional: [ ]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.29",
"version": "0.1.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.29",
"version": "0.1.33",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.29",
"version": "0.1.33",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+50
View File
@@ -0,0 +1,50 @@
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.29",
"version": "0.1.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.29",
"version": "0.1.33",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.29",
"version": "0.1.33",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.29"
version = "0.1.33"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+3 -2
View File
@@ -107,12 +107,13 @@ class MetadataManager:
"retries": 0,
}
_required_args_count = 4
_required_args_count = 7
if len(task_args) != _required_args_count:
logger.error("Invalid number of arguments.")
return
filename, email, _, received_at = task_args
filename, email, _, received_at, *_ = task_args
initial_metadata = {
**initial_metadata,
"filename": filename,
+2 -1
View File
@@ -1,8 +1,9 @@
"""Celery Config."""
# https://github.com/danihodovic/celery-exporter
# Enable task events for Prometheus monitoring via celery-exporter.
# worker_send_task_events: Sends task lifecycle events (e.g., started, succeeded, failed),
# worker_send_task_events: Sends task lifecycle events (e.g., started, succeeded),
# allowing the exporter to track task execution metrics and durations.
worker_send_task_events = True
+1 -1
View File
@@ -37,7 +37,7 @@ celery = Celery(
broker_connection_retry_on_startup=True,
)
celery.config_from_object('summary.core.celery_config')
celery.config_from_object("summary.core.celery_config")
if settings.sentry_dsn and settings.sentry_is_enabled: