Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 5509f292c6 (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:28:20 +02:00
94 changed files with 1198 additions and 3927 deletions
-2
View File
@@ -82,7 +82,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -187,7 +186,6 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
-7
View File
@@ -85,13 +85,6 @@ clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
+11 -22
View File
@@ -13,12 +13,10 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
@@ -26,7 +24,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -3379,12 +3377,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"version": "3.8.16",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.31.0"
"@react-types/shared": "^3.30.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3440,9 +3438,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"version": "3.30.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -5296,15 +5294,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/derive-valtio": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
"license": "MIT",
"peerDependencies": {
"valtio": ">=2.0.0-rc.0"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -7460,9 +7449,9 @@
}
},
"node_modules/livekit-client": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
"version": "2.15.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
+1 -3
View File
@@ -18,12 +18,10 @@
"@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
@@ -31,7 +29,7 @@
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.5",
"livekit-client": "2.15.2",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
<defs>
<style>
.st0, .st1, .st2, .st3, .st4, .st5 {
fill: none;
}
.st6 {
fill: #6dd58c;
}
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
stroke-linecap: round;
stroke-linejoin: round;
}
.st6, .st1, .st2, .st7, .st8, .st10 {
stroke-width: 3px;
}
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
stroke: #191c1e;
}
.st11, .st7, .st12, .st9 {
fill: #fff;
}
.st11, .st3 {
stroke: #000;
stroke-miterlimit: 10;
}
.st11, .st3, .st9 {
stroke-width: 5px;
}
.st2, .st9 {
stroke: #898989;
}
.st8 {
fill: #ff8669;
}
.st13 {
fill: #e3e3fb;
}
.st14 {
fill: #f7f7f7;
}
.st4, .st5 {
stroke-width: 4px;
}
.st5 {
stroke-linecap: square;
}
.st15 {
fill: #ca3632;
}
.st10 {
fill: #ffb929;
}
</style>
</defs>
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<g>
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
<path class="st5" d="M624,299h63"/>
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
<path class="st5" d="M728,339h-63"/>
</g>
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
<g>
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
</g>
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

@@ -1,187 +1,100 @@
import { useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
import { 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 = ({
room,
roomId,
...dialogProps
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home')
const roomUrl = getRouteUrl('room', roomId)
const roomUrl = room && getRouteUrl('room', room?.slug)
const telephony = useTelephony()
const [isCopied, setIsCopied] = useState(false)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
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={!!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',
})}
>
<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')
) : (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<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>
<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',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'none',
textWrap: 'nowrap',
}}
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',
})}
/>
{roomUrl.replace(/^https?:\/\//, '')}
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</>
)}
)}
</>
)}
</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>
</Dialog>
)
}
@@ -18,7 +18,6 @@ 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 (
@@ -154,7 +153,7 @@ export const Home = () => {
} = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
const { data } = useConfig()
@@ -203,7 +202,7 @@ export const Home = () => {
onAction={() => {
const slug = generateRoomId()
createRoom({ slug, username }).then((data) =>
setLaterRoom(data)
setLaterRoomId(data.slug)
)
}}
data-attr="create-option-later"
@@ -252,8 +251,8 @@ export const Home = () => {
</RightColumn>
</Columns>
<LaterMeetingDialog
room={laterRoom}
onOpenChange={() => setLaterRoom(null)}
roomId={laterRoomId ?? ''}
onOpenChange={() => setLaterRoomId(null)}
/>
</Screen>
</UserAware>
@@ -98,7 +98,6 @@ export const MainNotificationToast = () => {
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.ALERT }
@@ -1,10 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
LiveKitRoom,
usePersistentUserChoices,
} from '@livekit/components-react'
import { LiveKitRoom } from '@livekit/components-react'
import {
DisconnectReason,
MediaDeviceFailure,
@@ -12,7 +9,6 @@ import {
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -35,20 +31,18 @@ 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, posthog])
@@ -99,23 +93,15 @@ export const Conference = ({
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
resolution: userConfig.videoPublishResolution
? VideoPresets[userConfig.videoPublishResolution].resolution
: undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
@@ -249,6 +235,7 @@ 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, Bold } from '@/primitives'
import { Div, Button, type DialogProps, P } from '@/primitives'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
@@ -10,13 +10,8 @@ import {
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useMemo } from 'react'
import { useEffect, useState } 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, {
@@ -39,27 +34,24 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug)
const [isCopied, setIsCopied] = useState(false)
const telephony = useTelephony()
const isTelephonyReadyForUse = useMemo(() => {
return telephony?.enabled && roomData?.pin_code
}, [telephony?.enabled, roomData?.pin_code])
const {
isCopied,
copyRoomToClipboard,
isRoomUrlCopied,
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => setIsCopied(false), 3000)
return () => clearTimeout(timeout)
}
}, [isCopied])
return (
<StyledRACDialog {...props}>
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VStack
alignItems="left"
@@ -68,7 +60,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('heading')}
{t('shareDialog.heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
@@ -76,7 +68,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
variant="tertiaryText"
size="xs"
onPress={() => {
props.onClose?.()
dialogProps.onClose?.()
close()
}}
aria-label={t('closeDialog')}
@@ -84,125 +76,49 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<RiCloseLine />
</Button>
</Div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<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>
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'hidden',
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
>
<div
<RiSpam2Fill
size={22}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fill: 'primary.500',
})}
>
<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>
) : (
<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>
)}
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('shareDialog.permissions')}
</Text>
</HStack>
</VStack>
)}
</StyledRACDialog>
@@ -2,26 +2,19 @@ import { useTranslation } from 'react-i18next'
import { usePreviewTracks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
createLocalVideoTrack,
createLocalAudioTrack,
LocalAudioTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
import { HStack, VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react'
import {
EffectsConfiguration,
EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration'
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core'
@@ -34,11 +27,7 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { LocalUserChoices } from '@/stores/userChoices'
const onError = (e: Error) => console.error('ERROR', e)
@@ -83,23 +72,45 @@ const Effects = ({
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
<div
className={css({
position: 'absolute',
right: 0,
bottom: '0',
padding: '1rem',
zIndex: '1',
})}
>
<RiImageCircleAiFill size={24} />
</Button>
<Button
variant="whiteCircle"
onPress={openDialog}
tooltip={t('description')}
aria-label={t('description')}
>
<RiImageCircleAiFill size={24} />
</Button>
</div>
<div
className={css({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '20%',
backgroundImage:
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
borderBottomRadius: '1rem',
})}
/>
</>
)
}
export const Join = ({
enterRoom,
onSubmit,
roomId,
}: {
enterRoom: () => void
onSubmit: (choices: LocalUserChoices) => void
roomId: string
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -109,13 +120,11 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
@@ -123,44 +132,28 @@ export const Join = ({
saveProcessorSerialized,
} = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null)
const [processor, setProcessor] = useState(
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
)
if (initialUserChoices.current === null) {
initialUserChoices.current = {
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
}
}
useEffect(() => {
saveProcessorSerialized(processor?.serialize())
}, [
processor,
saveProcessorSerialized,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks(
{
audio: !!initialUserChoices.current &&
initialUserChoices.current?.audioEnabled && {
deviceId: initialUserChoices.current.audioDeviceId,
},
video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId,
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
},
audio: { deviceId: audioDeviceId },
video: { deviceId: videoDeviceId },
},
onError
)
const [dynamicVideoTrack, setDynamicVideoTrack] =
useState<LocalVideoTrack | null>(null)
const [dynamicAudioTrack, setDynamicAudioTrack] =
useState<LocalAudioTrack | null>(null)
const previewVideoTrack = useMemo(
const videoTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Video
@@ -168,129 +161,56 @@ export const Join = ({
[tracks]
)
const previewAudioTrack = useMemo(
const audioTrack = useMemo(
() =>
tracks?.filter(
(track) => track.kind === Track.Kind.Audio
)[0] as LocalAudioTrack,
)[0] as LocalVideoTrack,
[tracks]
)
/*
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
*/
useEffect(() => {
const createVideoTrack = async () => {
try {
const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(
processorSerialized
),
})
setDynamicVideoTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
videoEnabled &&
!initialUserChoices.current?.videoEnabled &&
!previewVideoTrack &&
!dynamicVideoTrack
) {
createVideoTrack()
}
}, [
videoEnabled,
videoDeviceId,
processorSerialized,
previewVideoTrack,
dynamicVideoTrack,
])
useEffect(() => {
const createAudioTrack = async () => {
try {
const track = await createLocalAudioTrack({
deviceId: { exact: audioDeviceId },
})
setDynamicAudioTrack(track)
} catch (error) {
onError(error as Error)
}
}
if (
audioEnabled &&
!initialUserChoices.current?.audioEnabled &&
!previewAudioTrack &&
!dynamicAudioTrack
) {
createAudioTrack()
}
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
// Cleanup dynamic tracks
useEffect(() => {
return () => {
dynamicVideoTrack?.stop()
}
}, [dynamicVideoTrack])
useEffect(() => {
return () => {
dynamicAudioTrack?.stop()
}
}, [dynamicAudioTrack])
// Final tracks (dynamic takes precedence over preview)
const videoTrack = dynamicVideoTrack || previewVideoTrack
const audioTrack = dynamicAudioTrack || previewAudioTrack
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveInitiallyDefaultDeviceId(
audioDeviceId,
audioTrack,
saveAudioInputDeviceId
)
useResolveInitiallyDefaultDeviceId(
videoDeviceId,
videoTrack,
saveVideoInputDeviceId
)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
useEffect(() => {
const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
isVideoInitiated.current = true
videoElement.style.opacity = '1'
}
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute()
videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
}
return () => {
videoTrack?.detach()
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
}
}, [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
@@ -349,43 +269,15 @@ export const Join = ({
enterRoom()
}
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
const hintMessage = useMemo(() => {
if (isCameraDeniedOrPrompted) {
return isMicrophoneDeniedOrPrompted
? 'cameraAndMicNotGranted'
: 'cameraNotGranted'
// 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)
}
if (!videoEnabled) {
return 'cameraDisabled'
}
if (!isVideoInitiated.current) {
return 'cameraStarting'
}
if (videoTrack && videoEnabled) {
return ''
}
}, [
videoTrack,
videoEnabled,
isCameraDeniedOrPrompted,
isMicrophoneDeniedOrPrompted,
])
const permissionsButtonLabel = useMemo(() => {
if (!isMicrophoneDeniedOrPrompted && !isCameraDeniedOrPrompted) {
return null
}
if (isCameraDeniedOrPrompted && isMicrophoneDeniedOrPrompted) {
return 'cameraAndMicNotGranted'
}
if (isCameraDeniedOrPrompted && !isMicrophoneDeniedOrPrompted) {
return 'cameraNotGranted'
}
return null
}, [isMicrophoneDeniedOrPrompted, isCameraDeniedOrPrompted])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoTrack])
const renderWaitingState = () => {
switch (status) {
@@ -447,7 +339,6 @@ export const Join = ({
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
@@ -470,314 +361,140 @@ export const Join = ({
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
flexDirection: 'column',
flexGrow: 1,
gap: { base: '1rem', sm: '2rem', lg: 0 },
lg: {
flexDirection: 'row',
alignItems: 'center',
},
})}
>
<div
className={css({
display: 'flex',
height: 'auto',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
gap: '2rem',
padding: '0 2rem',
flexDirection: 'column',
minWidth: 0,
maxWidth: '764px',
width: '100%',
lg: {
height: '540px',
flexGrow: 1,
flexDirection: 'row',
width: 'auto',
height: '570px',
},
})}
>
<div
className={css({
display: 'inline-flex',
flexDirection: 'column',
flexGrow: 1,
minWidth: 0,
flexShrink: { base: 0, sm: 1 },
width: '100%',
lg: {
width: '740px',
},
})}
>
<div
className={css({
borderRadius: '1rem',
flex: '0 1',
minWidth: '320px',
margin: {
base: '0.5rem',
sm: '1rem',
lg: '1rem 0.5rem 1rem 1rem',
},
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
height: 'auto',
aspectRatio: 16 / 9,
'--tw-shadow':
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored':
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})}
>
<div
className={css({
position: 'absolute',
top: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'relative',
width: '100%',
height: 'fit-content',
aspectRatio: '16 / 9',
})}
>
<div
{videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
ref={videoEl}
width="1280"
height="720"
className={css({
backgroundColor: 'black',
position: 'absolute',
boxSizing: 'border-box',
top: 0,
display: 'block',
width: '100%',
height: '100%',
overflow: 'hidden',
objectFit: 'cover',
transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
borderRadius: '1rem',
})}
>
<div
aria-label={t(
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
)}
role="status"
className={css({
position: 'absolute',
top: 0,
width: '100%',
})}
>
<div
className={css({
width: '100%',
height: 'auto',
aspectRatio: '16 / 9',
overflow: 'hidden',
position: 'absolute',
top: '-2px',
left: '-2px',
pointerEvents: 'none',
transform: 'scale(1.02)',
})}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoEl}
width="1280"
height="720"
style={{
display:
!videoEnabled || isCameraDeniedOrPrompted
? 'none'
: undefined,
}}
className={css({
position: 'absolute',
transform: 'rotateY(180deg)',
opacity: 0,
height: '100%',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
})}
disablePictureInPicture
disableRemotePlayback
/>
</div>
</div>
<div
role="alert"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
justifyContent: 'center',
textAlign: 'center',
alignItems: 'center',
padding: '0.24rem',
boxSizing: 'border-box',
gap: '1rem',
})}
>
<p
className={css({
fontWeight: '400',
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
textWrap: 'balance',
color: 'white',
})}
>
{hintMessage && t(hintMessage)}
</p>
{isCameraDeniedOrPrompted && (
<Button
size="sm"
variant="tertiary"
onPress={openPermissionsDialog}
>
{t(`permissionsButton.${permissionsButtonLabel}`)}
</Button>
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
kind="audioinput"
context="join"
enabled={audioEnabled}
toggle={async () => {
saveAudioInputEnabled(!audioEnabled)
if (audioEnabled) {
await audioTrack?.mute()
} else {
await audioTrack?.unmute()
}
}}
/>
<ToggleDevice
kind="videoinput"
context="join"
enabled={videoEnabled}
toggle={async () => {
saveVideoInputEnabled(!videoEnabled)
if (videoEnabled) {
await videoTrack?.mute()
} else {
await videoTrack?.unmute()
}
}}
/>
</div>
<div
className={css({
position: 'absolute',
right: '1rem',
bottom: '1rem',
zIndex: '1',
})}
>
<Effects
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div>
</div>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2%',
width: '80%',
marginX: 'auto',
})}
>
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={async (id) => {
try {
saveAudioInputDeviceId(id)
if (audioTrack) {
await audioTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch microphone device', err)
}
}}
disablePictureInPicture
disableRemotePlayback
/>
</div>
{!isSafari() && (
) : (
<div
className={css({
width: '30%',
width: '100%',
height: '100%',
color: 'white',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
<p
className={css({
fontSize: '24px',
fontWeight: '300',
})}
>
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div>
)}
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={async (id) => {
try {
saveVideoInputDeviceId(id)
if (videoTrack) {
await await videoTrack.setDeviceId({ exact: id })
}
} catch (err) {
console.error('Failed to switch camera device', err)
}
}}
/>
</div>
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
</div>
<HStack justify="center" padding={1.5}>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/>
</HStack>
</div>
<div
className={css({
width: '100%',
flexShrink: 0,
padding: '0',
sm: {
width: '448px',
padding: '0 3rem 9rem 3rem',
},
})}
>
{renderWaitingState()}
</div>
</div>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flex: '0 0 448px',
position: 'relative',
margin: '1rem 1rem 1rem 0.5rem',
})}
>
{renderWaitingState()}
</div>
</div>
</Screen>
@@ -1,112 +0,0 @@
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
import { css } from '@/styled-system/css'
import { Dialog, H } from '@/primitives'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation'
import { isSafari } from '@/utils/livekit'
/**
* Singleton component - ensures permissions sync runs only once across the app.
* WARNING: This component should only be instantiated once in the interface.
* Multiple instances may cause unexpected behavior or performance issues.
*/
export const Permissions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
useWatchPermissions()
const permissions = useSnapshot(permissionsStore)
const permissionLabel = useMemo(() => {
if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
return 'cameraAndMicrophone'
} else if (permissions.isCameraDenied) {
return 'camera'
} else if (permissions.isMicrophoneDenied) {
return 'microphone'
} else {
return 'default'
}
}, [permissions])
const [descriptionBeforeIcon, descriptionAfterIcon] =
injectIconIntoTranslation(t('body.openMenu.others'))
useEffect(() => {
if (
permissions.isPermissionDialogOpen &&
permissions.isCameraGranted &&
permissions.isMicrophoneGranted
) {
closePermissionsDialog()
}
}, [permissions])
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
return (
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
minWidth: '290px',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</div>
</Dialog>
)
}
@@ -1,160 +0,0 @@
import { useEffect } from 'react'
import { permissionsStore } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
export const useWatchPermissions = () => {
useEffect(() => {
let cleanup: (() => void) | undefined
let intervalId: NodeJS.Timeout | undefined
let isCancelled = false
const checkPermissions = async () => {
try {
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
const [cameraPermission, microphonePermission] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
/**
* Safari Permission API Limitation Workaround
*
* Safari has a known issue where permission change events are not reliably fired
* when users interact with permission prompts. This is documented in Apple's forums:
* https://developer.apple.com/forums/thread/757353
*
* The problem:
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
* - This leaves the UI in an inconsistent state showing outdated permission status
*
* The solution:
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
* - Continue polling until both permissions are no longer in 'prompt' state
* - This ensures we catch permission changes even when Safari fails to fire events
*
* This polling is Safari-specific and only activates when needed to minimize performance impact.
*/
if (
isSafari() &&
(cameraPermission.state === 'prompt' ||
microphonePermission.state === 'prompt')
) {
// Start polling every 1 second if either permission is in 'prompt' state
if (!intervalId) {
intervalId = setInterval(async () => {
try {
const [updatedCamera, updatedMicrophone] = await Promise.all([
navigator.permissions.query({ name: 'camera' }),
navigator.permissions.query({ name: 'microphone' }),
])
if (isCancelled) return
const cameraChanged =
permissionsStore.cameraPermission !== updatedCamera.state
const microphoneChanged =
permissionsStore.microphonePermission !==
updatedMicrophone.state
if (cameraChanged) {
permissionsStore.cameraPermission = updatedCamera.state
}
if (microphoneChanged) {
permissionsStore.microphonePermission =
updatedMicrophone.state
}
if (
updatedCamera.state !== 'prompt' &&
updatedMicrophone.state !== 'prompt'
) {
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error polling permissions:', error)
}
}
}, POLLING_TIME)
}
}
permissionsStore.cameraPermission = cameraPermission.state
permissionsStore.microphonePermission = microphonePermission.state
const handleCameraChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.cameraPermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
const handleMicrophoneChange = (e: Event) => {
const target = e.target as PermissionStatus
permissionsStore.microphonePermission = target.state
if (
intervalId &&
target.state !== 'prompt' &&
microphonePermission.state !== 'prompt'
) {
clearInterval(intervalId)
intervalId = undefined
}
}
cameraPermission.addEventListener('change', handleCameraChange)
microphonePermission.addEventListener('change', handleMicrophoneChange)
cleanup = () => {
cameraPermission.removeEventListener('change', handleCameraChange)
microphonePermission.removeEventListener(
'change',
handleMicrophoneChange
)
if (intervalId) {
clearInterval(intervalId)
intervalId = undefined
}
}
} catch (error) {
if (!isCancelled) {
console.error('Error checking permissions:', error)
}
} finally {
if (!isCancelled) {
permissionsStore.isLoading = false
}
}
}
checkPermissions()
return () => {
isCancelled = true
cleanup?.()
}
}, [])
}
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useMemo, useRef } from 'react'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
@@ -18,7 +18,7 @@ export const FullScreenShareWarning = ({
const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef)
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
const isFullScreenSharing = useMemo(() => {
if (trackReference?.source !== 'screen_share') return false
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
}
const handleDismissWarning = () => {
screenSharePreferenceStore.enabled = false
ScreenSharePreferenceStore.enabled = false
}
if (!shouldShowWarning) return null
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import { useEffect, useState } from 'react'
import { VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
@@ -8,22 +8,24 @@ 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"
@@ -51,9 +53,9 @@ export const Info = () => {
})}
>
<Text as="p" variant="xsNote" wrap="pretty">
{roomUrl.replace(/^https?:\/\//, '')}
{roomUrl}
</Text>
{isTelephonyReadyForUse && (
{telephony?.enabled && data?.pin_code && (
<>
<Text as="p" variant="xsNote" wrap="pretty">
<Bold>{t('roomInformation.phone.call')}</Bold> (
@@ -70,7 +72,10 @@ export const Info = () => {
size="sm"
variant={isCopied ? 'success' : 'tertiaryText'}
aria-label={t('roomInformation.button.ariaLabel')}
onPress={copyRoomToClipboard}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setIsCopied(true)
}}
data-attr="copy-info-sidepannel"
style={{
marginLeft: '-8px',
@@ -1,130 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const AudioDevicesControl = ({
hideMenu,
...props
}: AudioDevicesControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
userChoices: { audioDeviceId, audioOutputDeviceId },
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Microphone,
onChange,
...props,
})
const kind = 'audioinput'
const cannotUseDevice = useCannotUseDevice(kind)
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={trackProps.toggle as () => Promise<void>}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
<SettingsButton
settingTab={SettingsDialogExtendedKey.AUDIO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -1,47 +0,0 @@
import { Button } from '@/primitives'
import { RiErrorWarningFill } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export const PermissionNeededButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
return (
<div
className={css({
position: 'absolute',
bottom: 'auto',
left: '-.55rem',
top: '-.55rem',
zIndex: 1,
})}
>
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={openPermissionsDialog}
variant="permission"
>
<div
className={css({
position: 'relative',
zIndex: 2,
})}
>
<RiErrorWarningFill size={28} />
</div>
<div
className={css({
width: '18px',
height: '18px',
position: 'absolute',
top: '4px',
left: '4px',
backgroundColor: 'black',
borderRadius: '100%',
})}
/>
</Button>
</div>
)
}
@@ -1,123 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
type DeviceItems = Array<{ value: string; label: string }>
type SelectDeviceContext = {
variant?: 'light' | 'dark'
placement?: Placement
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
const SelectDevicePermissions = <T extends string | number>({
id,
kind,
onSubmit,
iconComponent,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
const selectedKey = id || activeDeviceId
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={iconComponent}
placeholder={items.length === 0 ? t('loading') : t('select')}
selectedKey={selectedKey}
onSelectionChange={(key) => {
if (key === selectedKey) return
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
{...props}
/>
)
}
export const SelectDevice = ({
id,
onSubmit,
kind,
context = 'join',
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const contextProps = useMemo<SelectDeviceContext>(() => {
if (context == 'room') {
return { variant: 'dark', placement: 'top' }
}
return {}
}, [context])
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
if (cannotUseDevice) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
placeholder={t('permissionsNeeded')}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
@@ -1,32 +0,0 @@
import { Button } from '@/primitives'
import { RiSettings3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsButton = ({
settingTab,
onPress,
}: {
settingTab: SettingsDialogExtendedKey
onPress?: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { openSettingsDialog } = useSettingsDialog()
return (
<Button
size="sm"
square
tooltip={t(`settings.${settingTab}`)}
aria-label={t(`settings.${settingTab}`)}
variant="primaryDark"
onPress={() => {
openSettingsDialog(settingTab)
onPress?.()
}}
>
<RiSettings3Line size={24} />
</Button>
)
}
@@ -1,142 +0,0 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export type ToggleDeviceProps<T extends ToggleSource> = {
enabled: boolean
toggle: (
forceState?: boolean,
captureOptions?: CaptureOptionsBySource<T>
) => Promise<void | boolean | undefined>
context?: 'room' | 'join'
kind: 'audioinput' | 'videoinput'
overrideToggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = <T extends ToggleSource>({
kind,
enabled,
toggle,
context = 'room',
overrideToggleButtonProps,
}: ToggleDeviceProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const {
variant,
errorVariant,
toggleButtonProps: computedToggleButtonProps,
} = useMemo<ToggleDeviceStyleProps>(() => {
if (context === 'join') {
return {
variant: 'whiteCircle',
errorVariant: 'errorCircle',
toggleButtonProps: {
groupPosition: undefined,
},
} as ToggleDeviceStyleProps
}
return {
variant: 'primaryDark',
errorVariant: 'error2',
toggleButtonProps: {
groupPosition: 'left',
},
} as ToggleDeviceStyleProps
}, [context])
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'Space' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
})
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
}, [enabled, kind, deviceShortcut, t])
const Icon =
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
const roomContext = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && roomContext) {
return <ActiveSpeakerWrapper />
}
return (
<div style={{ position: 'relative' }}>
{cannotUseDevice && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !cannotUseDevice ? variant : errorVariant}
shySelected
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
</div>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -1,164 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { BackgroundProcessorFactory } from '../../blur'
import Source = Track.Source
import * as React from 'react'
import { SelectDevice } from './SelectDevice'
import { SettingsButton } from './SettingsButton'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { isEffectsOpen, toggleEffects } = useSidePanel()
return (
<Button
size="sm"
square
tooltip={t('effects')}
aria-label={t('effects')}
variant="primaryDark"
onPress={() => {
if (!isEffectsOpen) toggleEffects()
onPress()
}}
>
<RiImageCircleAiFill size={24} />
</Button>
)
}
type VideoDeviceControlProps = Omit<
UseTrackToggleProps<Source.Camera>,
'source' | 'onChange'
> & {
hideMenu?: boolean
}
export const VideoDeviceControl = ({
hideMenu,
...props
}: VideoDeviceControlProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Camera,
onChange,
...props,
})
const kind = 'videoinput'
const cannotUseDevice = useCannotUseDevice(kind)
const toggleWithProcessor = async () => {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
await toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
}
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
kind={kind}
toggle={toggleWithProcessor}
overrideToggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Popover variant="dark" withArrow={false}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
}
>
<RiArrowUpSLine />
</Button>
{({ close }) => (
<div
className={css({
maxWidth: '36rem',
padding: '0.15rem',
display: 'flex',
gap: '0.5rem',
})}
>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind={kind}
id={userChoices.videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
<EffectsButton onPress={close} />
<SettingsButton
settingTab={SettingsDialogExtendedKey.VIDEO}
onPress={close}
/>
</div>
)}
</Popover>
)}
</div>
)
}
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { useSettingsDialog } from '../SettingsDialogContext'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { openSettingsDialog } = useSettingsDialog()
const { setDialogOpen } = useSettingsDialog()
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={() => openSettingsDialog()}
onAction={() => setDialogOpen(true)}
>
<RiSettings3Line size={20} />
{t('settings')}
@@ -0,0 +1,199 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices()
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: config.kind, track })
/**
* When providing only track outside of a room context, activeDeviceId is undefined.
* So we need to initialize it with the initialDeviceId.
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
*/
useEffect(() => {
if (initialDeviceId !== undefined) {
setActiveMediaDevice(initialDeviceId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setActiveMediaDevice])
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={trackProps.enabled ? variant : 'error2'}
>
<RiArrowDownSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}
@@ -0,0 +1,36 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}
@@ -0,0 +1,86 @@
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
} from '@livekit/components-react'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export type ToggleDeviceProps = {
enabled: boolean
toggle: () => void
config: SelectToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
export const ToggleDevice = ({
config,
enabled,
toggle,
variant = 'primaryDark',
toggleButtonProps,
}: ToggleDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { kind, shortcut, iconOn, iconOff, longPress } = config
const [pushToTalk, setPushToTalk] = useState(false)
const onKeyDown = () => {
if (pushToTalk || enabled) return
toggle()
setPushToTalk(true)
}
const onKeyUp = () => {
if (!pushToTalk) return
toggle()
setPushToTalk(false)
}
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
const toggleLabel = useMemo(() => {
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `join.${kind}`,
})
return shortcut ? appendShortcutLabel(label, shortcut) : label
}, [enabled, kind, shortcut, t])
const Icon = enabled ? iconOn : iconOff
const context = useMaybeRoomContext()
if (kind === 'audioinput' && pushToTalk && context) {
return <ActiveSpeakerWrapper />
}
return (
<ToggleButton
isSelected={!enabled}
variant={enabled ? variant : 'error2'}
shySelected
onPress={() => toggle()}
aria-label={toggleLabel}
tooltip={toggleLabel}
groupPosition="left"
{...toggleButtonProps}
>
<Icon />
</ToggleButton>
)
}
const ActiveSpeakerWrapper = () => {
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -1,35 +0,0 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { permissionsStore } from '@/stores/permissions'
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
const {
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
} = useSnapshot(permissionsStore)
return useMemo(() => {
if (isLoading) return true
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return isMicrophoneDenied || isMicrophonePrompted
case 'videoinput':
return isCameraDenied || isCameraPrompted
default:
return false
}
}, [
kind,
isLoading,
isMicrophoneDenied,
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
])
}
@@ -1,79 +0,0 @@
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,
}
}
@@ -1,37 +0,0 @@
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
RiVolumeDownLine,
RiVolumeMuteLine,
} from '@remixicon/react'
export interface DeviceIcons {
toggleOn: RemixiconComponentType
toggleOff: RemixiconComponentType
select: RemixiconComponentType
}
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
audioinput: {
toggleOn: RiMicLine,
toggleOff: RiMicOffLine,
select: RiMicLine,
},
videoinput: {
toggleOn: RiVideoOnLine,
toggleOff: RiVideoOffLine,
select: RiVideoOnLine,
},
audiooutput: {
toggleOn: RiVolumeDownLine,
toggleOff: RiVolumeMuteLine,
select: RiVolumeDownLine,
},
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
}
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
ICONS[kind] ?? ICONS.default
@@ -1,21 +0,0 @@
import { useMemo } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<Shortcut | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
key: 'e',
ctrlKey: true,
}
case 'videoinput':
return {
key: 'd',
ctrlKey: true,
}
default:
return undefined
}
}, [kind])
}
@@ -1,8 +1,6 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
const userChoicesSnap = useSnapshot(userChoicesStore)
@@ -18,18 +16,9 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
saveVideoPublishResolution: (resolution: VideoResolution) => {
userChoicesStore.videoPublishResolution = resolution
},
saveVideoSubscribeQuality: (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality
},
saveUsername: (username: string) => {
userChoicesStore.username = username
},
@@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react'
export const useResolveInitiallyDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
const isInitiated = useRef(false)
useEffect(() => {
if (currentId !== 'default' || !track || isInitiated.current) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') {
isInitiated.current = true
save(actualDeviceId)
}
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -1,51 +0,0 @@
import { useEffect } from 'react'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useRoomContext } from '@livekit/components-react'
import {
RemoteParticipant,
RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
/**
* This hook sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const useVideoResolutionSubscription = () => {
const {
userChoices: { videoSubscribeQuality },
} = usePersistentUserChoices()
const room = useRoomContext()
useEffect(() => {
if (!room) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
}
room.on(RoomEvent.TrackPublished, handleTrackPublished)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, videoSubscribeQuality])
}
@@ -3,23 +3,98 @@ import * as React from 'react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({ onDeviceError }: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices()
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
if (isMobile) {
return <MobileControlBar onDeviceError={onDeviceError} />
}
return <DesktopControlBar onDeviceError={onDeviceError} />
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
@@ -2,6 +2,7 @@ import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
@@ -10,12 +11,14 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
export function DesktopControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
@@ -50,15 +53,27 @@ export function DesktopControlBar({
gap: '0.65rem',
})}
>
<AudioDevicesControl
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<VideoDeviceControl
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
@@ -4,6 +4,7 @@ import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
@@ -18,22 +19,24 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export function MobileControlBar({
onDeviceError,
}: Readonly<ControlBarAuxProps>) {
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { openSettingsDialog } = useSettingsDialog()
const { setDialogOpen } = useSettingsDialog()
const { data } = useConfig()
@@ -59,15 +62,27 @@ export function MobileControlBar({
})}
>
<LeaveButton />
<AudioDevicesControl
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<VideoDeviceControl
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button
@@ -152,7 +167,7 @@ export function MobileControlBar({
)}
<Button
onPress={() => {
openSettingsDialog()
setDialogOpen(true)
setIsMenuOpened(false)
}}
variant="primaryTextDark"
@@ -32,8 +32,6 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
const LayoutWrapper = styled(
'div',
@@ -79,7 +77,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[
@@ -241,7 +238,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
<SettingsDialogProvider />
</div>
)
}
+15 -18
View File
@@ -1,28 +1,21 @@
import { ReactNode, useEffect, useState } from 'react'
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'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
<UserAware>
<Permissions />
{children}
</UserAware>
)
}
import { LocalUserChoices } from '@/stores/userChoices'
export const Room = () => {
const { isLoggedIn } = useUser()
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
@@ -55,21 +48,25 @@ export const Room = () => {
return <ErrorScreen />
}
if (!hasSubmittedEntry && !skipJoinScreen) {
if (!userConfig && !skipJoinScreen) {
return (
<BaseRoom>
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
</BaseRoom>
<UserAware>
<Join onSubmit={setUserConfig} roomId={roomId} />
</UserAware>
)
}
return (
<BaseRoom>
<UserAware>
<Conference
initialRoomData={initialRoomData}
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...userConfig,
}}
/>
</BaseRoom>
</UserAware>
)
}
@@ -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')} #`
}
@@ -9,16 +9,13 @@ import {
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -47,9 +44,7 @@ const tabPanelContainerStyle = css({
export type SettingsDialogExtended = Pick<
DialogProps,
'isOpen' | 'onOpenChange'
> & {
defaultSelectedTab?: SettingsDialogExtendedKey
}
>
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
// display only icon on small screen
@@ -60,11 +55,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs
orientation="vertical"
className={tabsStyle}
defaultSelectedKey={props.defaultSelectedTab}
>
<Tabs orientation="vertical" className={tabsStyle}>
<div
className={tabListContainerStyle}
style={{
@@ -78,39 +69,30 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{t('dialog.heading')}
</Heading>
)}
<TabList border={false}>
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
<TabList border={false} aria-label="Chat log orientation example">
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
{isWideScreen && t('tabs.account')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
<Tab icon highlight id="2">
<RiSpeakerLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
{isWideScreen && t('tabs.audio')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
<RiVideoOnLine />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
<Tab icon highlight id="3">
<RiSettings3Line />
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
<Tab icon highlight id="4">
<RiNotification3Line />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
{isWideScreen && t('tabs.notifications')}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab
id={SettingsDialogExtendedKey.ACCOUNT}
onOpenChange={props.onOpenChange}
/>
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
</div>
</Tabs>
</Dialog>
@@ -1,20 +0,0 @@
import { SettingsDialogExtended } from './SettingsDialogExtended'
import { useSnapshot } from 'valtio'
import { settingsStore } from '@/stores/settings'
export const SettingsDialogProvider = () => {
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
return (
<SettingsDialogExtended
isOpen={areSettingsOpen}
defaultSelectedTab={defaultSelectedTab}
onOpenChange={(v) => {
if (!v && settingsStore.defaultSelectedTab) {
settingsStore.defaultSelectedTab = undefined
}
settingsStore.areSettingsOpen = v
}}
/>
)
}
@@ -1,4 +1,4 @@
import { DialogProps, Field, Switch, Text } from '@/primitives'
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -9,11 +9,78 @@ import {
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
type RowWrapperProps = {
heading: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -25,19 +92,24 @@ export const AudioTab = ({ id }: AudioTabProps) => {
const { localParticipant } = useRoomContext()
const {
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
useMediaDeviceSelect({ kind: 'audiooutput' })
const {
devices: devicesOut,
activeDeviceId: activeDeviceIdOut,
setActiveMediaDevice: setActiveMediaDeviceOut,
} = useMediaDeviceSelect({ kind: 'audiooutput' })
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'audioinput' })
const {
devices: devicesIn,
activeDeviceId: activeDeviceIdIn,
setActiveMediaDevice: setActiveMediaDeviceIn,
} = useMediaDeviceSelect({ kind: 'audioinput' })
const itemsOut: DeviceItems = devicesOut.map((d) => ({
value: d.deviceId,
@@ -62,6 +134,15 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey: undefined,
}
// No API to directly query the default audio device; this function heuristically finds it.
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const noiseReductionAvailable = useNoiseReductionAvailable()
return (
@@ -71,9 +152,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
selectedKey={audioDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
defaultSelectedKey={
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
}
onSelectionChange={(key) => {
setActiveMediaDeviceIn(key as string)
saveAudioInputDeviceId(key as string)
}}
{...disabledProps}
@@ -97,11 +180,12 @@ export const AudioTab = ({ id }: AudioTabProps) => {
type="select"
label={t('audio.speakers.label')}
items={itemsOut}
selectedKey={audioOutputDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
setActiveMediaDeviceOut(key as string)
}
{...disabledProps}
style={{
minWidth: 0,
@@ -1,238 +0,0 @@
import { DialogProps, Field } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
LocalVideoTrack,
Track,
VideoPresets,
VideoQuality,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
import { VideoResolution } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
const {
userChoices: {
videoDeviceId,
processorSerialized,
videoPublishResolution,
videoSubscribeQuality,
},
saveVideoInputDeviceId,
saveVideoPublishResolution,
saveVideoSubscribeQuality,
} = usePersistentUserChoices()
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
)
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
setVideoElement(element)
}, [])
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'videoinput' })
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
}
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const videoPublication = localParticipant.getTrackPublication(
Track.Source.Camera
)
const videoTrack = videoPublication?.track
if (videoTrack) {
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
})
}
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
const setUpVideoTrack = async () => {
if (videoElement) {
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
videoTrack.attach(videoElement)
}
}
setUpVideoTrack()
return () => {
if (videoElement && videoTrack) {
videoTrack.detach()
videoTrack.stop()
}
}
}, [videoDeviceId, videoElement])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
<Field
type="select"
label={t('camera.label')}
items={itemsIn}
selectedKey={videoDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveVideoInputDeviceId(key as string)
}}
{...disabledProps}
style={{
width: '100%',
}}
/>
<div
role="status"
aria-label={t(
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
)}
>
{localParticipant.isCameraEnabled ? (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoCallbackRef}
width="160px"
height="56px"
style={{
display: !localParticipant.isCameraEnabled
? 'none'
: undefined,
}}
className={css({
transform: 'rotateY(180deg)',
height: '69px',
width: '160px',
})}
disablePictureInPicture
disableRemotePlayback
/>
</>
) : (
<span
className={css({
display: 'flex',
justifyContent: 'center',
textAlign: 'center',
})}
>
{t('camera.disabled')}
</span>
)}
</div>
</RowWrapper>
<RowWrapper heading={t('resolution.heading')}>
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
<RowWrapper>
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
</TabPanel>
)
}
@@ -1,71 +0,0 @@
import { ReactNode } from 'react'
import { H } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
export type RowWrapperProps = {
heading?: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
{heading && (
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
)}
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
@@ -1,20 +0,0 @@
import { useSnapshot } from 'valtio/index'
import { settingsStore } from '@/stores/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
export const useSettingsDialog = () => {
const { areSettingsOpen } = useSnapshot(settingsStore)
const openSettingsDialog = (
defaultSelectedTab?: SettingsDialogExtendedKey
) => {
if (areSettingsOpen) return
if (defaultSelectedTab)
settingsStore.defaultSelectedTab = defaultSelectedTab
settingsStore.areSettingsOpen = true
}
return {
openSettingsDialog,
}
}
@@ -1,7 +0,0 @@
export enum SettingsDialogExtendedKey {
ACCOUNT = 'account',
AUDIO = 'audio',
VIDEO = 'video',
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
}
@@ -10,7 +10,7 @@ export const useKeyboardShortcuts = () => {
useEffect(() => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = async (e: KeyboardEvent) => {
const onKeyDown = (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
@@ -20,7 +20,7 @@ export const useKeyboardShortcuts = () => {
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
e.preventDefault()
await shortcut()
shortcut()
}
window.addEventListener('keydown', onKeyDown)
@@ -5,7 +5,6 @@ export type useLongPressProps = {
onKeyDown: () => void
onKeyUp: () => void
longPressThreshold?: number
isDisabled?: boolean
}
export const useLongPress = ({
@@ -13,18 +12,10 @@ export const useLongPress = ({
onKeyDown,
onKeyUp,
longPressThreshold = 300,
isDisabled = false,
}: useLongPressProps) => {
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isDisabled) {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code != keyCode || timeoutIdRef.current) return
timeoutIdRef.current = setTimeout(() => {
@@ -47,12 +38,8 @@ export const useLongPress = ({
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = null
}
}
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
return
}
@@ -5,22 +5,15 @@ import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
shortcut?: Shortcut
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
handler: () => void
}
export const useRegisterKeyboardShortcut = ({
shortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, shortcut, isDisabled])
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
}, [handler, shortcut])
}
-4
View File
@@ -51,9 +51,5 @@
"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}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Ihre Zugangsdaten",
"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:"
}
"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."
},
"introSlider": {
"previous": {
+9 -63
View File
@@ -7,18 +7,9 @@
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich",
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
},
"effects": "Effekte anwenden",
"join": {
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -26,17 +17,10 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
}
},
"join": {
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
@@ -53,17 +37,7 @@
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet",
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
"permissionsButton": {
"cameraAndMicNotGranted": "Zugriff auf Kamera und Mikrofon erlauben",
"cameraNotGranted": "Zugriff auf Kamera erlauben"
},
"videoPreview": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
},
"cameraStarting": "Kamera wird gestartet.",
"waiting": {
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
@@ -79,16 +53,12 @@
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "Informationen kopieren",
"copyUrl": "Link kopieren",
"copied": "Informationen kopiert",
"copy": "Meeting-Link kopieren",
"copyButton": "Link kopieren",
"copied": "Link in die Zwischenablage 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.",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
}
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +73,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
"microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
"cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
"default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
},
"body": {
"openMenu": {
"others": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
"safari": "Klicken Sie auf das Menü 'Safari' und öffnen Sie 'Einstellungen für {{appDomain}}'."
},
"details": {
"camera": "Zugriff auf die Kamera erlauben",
"microphone": "Zugriff auf das Mikrofon erlauben",
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
"default": "Aktivieren Sie die erforderlichen Berechtigungen."
}
}
},
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
},
"error": {
"createRoom": {
"heading": "Authentifizierung erforderlich",
@@ -280,9 +226,9 @@
"roomInformation": {
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert"
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
},
"phone": {
"call": "Rufen Sie an:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"video": {
"camera": {
"heading": "Kamera",
"label": "Wählen Sie Ihre Videoeingabe aus",
"disabled": "Kamera deaktiviert",
"previewAriaLabel": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
}
},
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
}
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen"
}
-4
View File
@@ -51,9 +51,5 @@
"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}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Your connection details",
"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:"
}
"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."
},
"introSlider": {
"previous": {
+9 -63
View File
@@ -7,18 +7,9 @@
"home": "Return to home",
"back": "Rejoin the meeting"
},
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed",
"settings": {
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Effects",
"join": {
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -26,17 +17,10 @@
},
"audioinput": {
"choose": "Select microphone",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
}
},
"join": {
"effects": {
"description": "Apply effects",
"title": "Effects",
@@ -53,17 +37,7 @@
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
"permissionsButton": {
"cameraAndMicNotGranted": "Allow access to camera and microphone",
"cameraNotGranted": "Allow access to camera"
},
"videoPreview": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
},
"cameraStarting": "Camera is starting.",
"waiting": {
"title": "Requesting to join...",
"body": "You will be able to join this call when someone authorizes you"
@@ -79,16 +53,12 @@
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copy": "Copy information",
"copyUrl": "Copy link",
"copied": "Information copied to clipboard",
"copy": "Copy the meeting link",
"copyButton": "Copy link",
"copied": "Link 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.",
"phone": {
"call": "Call:",
"pinCode": "Code:"
}
"permissions": "People with this link do not need your permission to join this meeting."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +73,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} is not allowed to use your camera",
"microphone": "{{appTitle}} is not allowed to use your microphone",
"cameraAndMicrophone": "{{appTitle}} is not allowed to use your microphone or camera",
"default": "{{appTitle}} is not allowed to use certain permissions"
},
"body": {
"openMenu": {
"others": "Click on the settings icon ICON_PLACEHOLDER in your browsers address bar",
"safari": "Click the 'Safari' menu, and open 'Settings for {{appDomain}}'."
},
"details": {
"camera": "Allow access to the camera",
"microphone": "Allow access to the microphone",
"cameraAndMicrophone": "Allow access to the camera and microphone",
"default": "Enable the necessary permissions."
}
}
},
"permissionsButton": {
"tooltip": "More info",
"ariaLabel": "Permissions issue. Show more info"
},
"error": {
"createRoom": {
"heading": "Authentication Required",
@@ -280,9 +226,9 @@
"roomInformation": {
"title": "Connection Information",
"button": {
"ariaLabel": "Copy the information from your meeting",
"copy": "Copy information",
"copied": "Information copied"
"ariaLabel": "Copy your meeting address",
"copy": "Copy address",
"copied": "Address copied"
},
"phone": {
"call": "Call:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Permissions required"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Select your video input",
"disabled": "Camera disabled",
"previewAriaLabel": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
}
},
"resolution": {
"heading": "Resolution",
"publish": {
"label": "Select your sending resolution (max.)",
"items": {
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
}
},
"subscribe": {
"label": "Select your reception resolution (max.)",
"items": {
"high": "High definition (automatic)",
"medium": "Standard definition",
"low": "Low definition"
}
}
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications"
}
-4
View File
@@ -51,9 +51,5 @@
"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}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Vos informations de connexion",
"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 :"
}
"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."
},
"introSlider": {
"previous": {
+13 -67
View File
@@ -7,18 +7,9 @@
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
},
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires",
"settings": {
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Effets d'arrière-plan",
"join": {
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -26,20 +17,13 @@
},
"audioinput": {
"choose": "Choisir le micro",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
}
},
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière-plan",
"description": "Effets d'arrière plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
@@ -53,17 +37,7 @@
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
"videoPreview": {
"enabled": "Apercu vidéo activé",
"disabled": "Apercu vidéo désactivé"
},
"permissionsButton": {
"cameraAndMicNotGranted": "Autorisez l'accès à la caméra et au micro",
"cameraNotGranted": "Autorisez l'accès à la caméra"
},
"cameraStarting": "La caméra va démarrer.",
"waiting": {
"title": "Demande de participation…",
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
@@ -79,16 +53,12 @@
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copy": "Copier les informations",
"copyUrl": "Copier le lien",
"copied": "Copiées dans le presse-papiers",
"copy": "Copier le lien de la réunion",
"copyButton": "Copier le lien",
"copied": "Lien copié dans le presse-papiers",
"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.",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
}
"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."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +73,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} n'est pas autorisé à utiliser votre caméra",
"microphone": "{{appTitle}} n'est pas autorisé à utiliser votre micro",
"cameraAndMicrophone": "{{appTitle}} n'est pas autorisé à utiliser votre micro ni votre caméra",
"default": "{{appTitle}} n'est pas autorisé à utiliser certaines fonctionnalités nécessaires."
},
"body": {
"openMenu": {
"others": "Cliquez sur l'icône des paramètres ICON_PLACEHOLDER dans la barre d'adresse de votre navigateur",
"safari": "Cliquez sur le menu \"Safari\", et ouvrez \"Paramètres pour {{appDomain}}\"."
},
"details": {
"camera": "Autorisez l'accès à la caméra",
"microphone": "Autorisez l'accès au microphone",
"cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone",
"default": "Activez les autorisations nécessaires."
}
}
},
"permissionsButton": {
"tooltip": "Plus d'infos",
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
},
"error": {
"createRoom": {
"heading": "Authentification requise",
@@ -198,7 +144,7 @@
"feedback": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Effets d'arrière-plan",
"effects": "Effets d'arrière plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -280,9 +226,9 @@
"roomInformation": {
"title": "Informations de connexions",
"button": {
"ariaLabel": "Copier les informations de votre réunion",
"copy": "Copier les informations",
"copied": "Informations copiées"
"ariaLabel": "Copier l'adresse de votre réunion",
"copy": "Copier l'adresse",
"copied": "Adresse copiée"
},
"phone": {
"call": "Appelez le :",
@@ -462,7 +408,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Effets d'arrière-plan",
"effects": "Effets d'arrière plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"video": {
"camera": {
"heading": "Caméra",
"label": "Sélectionner votre entrée vidéo",
"disabled": "Caméra désactivée",
"previewAriaLabel": {
"enabled": "Aperçu vidéo activé",
"disabled": "Aperçu vidéo désactivé"
}
},
"resolution": {
"heading": "Résolution",
"publish": {
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
}
},
"subscribe": {
"label": "Sélectionner votre résolution de réception (max.)",
"items": {
"high": "Haute définition (automatique)",
"medium": "Définition standard",
"low": "Basse définition"
}
}
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications"
}
-4
View File
@@ -50,9 +50,5 @@
"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}}"
}
}
+4 -9
View File
@@ -19,15 +19,10 @@
},
"laterMeetingDialog": {
"heading": "Uw verbindingsgegevens",
"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:"
}
"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."
},
"introSlider": {
"previous": {
+9 -63
View File
@@ -7,18 +7,9 @@
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
},
"selectDevice": {
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist",
"settings": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
},
"effects": "Pas effecten toe",
"join": {
"videoinput": {
"choose": "Selecteer camera",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -26,17 +17,10 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
}
},
"join": {
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
@@ -53,17 +37,7 @@
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraStarting": "Camera wordt ingeschakeld",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
"permissionsButton": {
"cameraAndMicNotGranted": "Toegang tot camera en microfoon toestaan",
"cameraNotGranted": "Toegang tot camera toestaan"
},
"videoPreview": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
},
"cameraStarting": "Camera wordt ingeschakeld.",
"waiting": {
"title": "Verzoek tot deelname...",
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
@@ -79,16 +53,12 @@
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
"shareDialog": {
"copy": "Informatie kopiëren",
"copyUrl": "Kopieerlink",
"copied": "Informatie gekopieerd",
"copy": "Kopieer de vergaderlink",
"copyButton": "Kopieerlink",
"copied": "Link gekopieerd naar het klembord",
"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.",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
}
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
},
"mediaErrorDialog": {
"DeviceInUse": {
@@ -103,30 +73,6 @@
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} mag uw camera niet gebruiken",
"microphone": "{{appTitle}} mag uw microfoon niet gebruiken",
"cameraAndMicrophone": "{{appTitle}} mag uw microfoon en camera niet gebruiken",
"default": "{{appTitle}} heeft geen toestemming voor bepaalde rechten"
},
"body": {
"openMenu": {
"others": "Klik op het instellingenpictogram ICON_PLACEHOLDER in de adresbalk van uw browser",
"safari": "Klik op het menu 'Safari' en open 'Instellingen voor {{appDomain}}'."
},
"details": {
"camera": "Toegang tot de camera toestaan",
"microphone": "Toegang tot de microfoon toestaan",
"cameraAndMicrophone": "Toegang tot camera en microfoon toestaan",
"default": "Schakel de vereiste machtigingen in."
}
}
},
"permissionsButton": {
"tooltip": "Meer info",
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
},
"error": {
"createRoom": {
"heading": "Verificatie vereist",
@@ -280,9 +226,9 @@
"roomInformation": {
"title": "Verbindingsinformatie",
"button": {
"ariaLabel": "Kopieer de informatie van uw vergadering",
"copy": "Informatie kopiëren",
"copied": "Informatie gekopieerd"
"ariaLabel": "Kopieer je vergaderadres",
"copy": "Adres kopiëren",
"copied": "Adres gekopieerd"
},
"phone": {
"call": "Bel:",
-32
View File
@@ -30,37 +30,6 @@
},
"permissionsRequired": "Machtigingen vereist"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Selecteer uw video-ingang",
"disabled": "Camera uitgeschakeld",
"previewAriaLabel": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
}
},
"resolution": {
"heading": "Resolutie",
"publish": {
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
},
"subscribe": {
"label": "Selecteer uw ontvangstresolutie (max.)",
"items": {
"high": "Hoge definitie (automatisch)",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
}
},
"permissionsRequired": "Machtigingen vereist"
},
"notifications": {
"heading": "Geluidsmeldingen",
"label": "Geluidsmeldingen voor",
@@ -85,7 +54,6 @@
"tabs": {
"account": "Profiel",
"audio": "Audio",
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen"
}
+7 -25
View File
@@ -46,18 +46,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
transform: 'rotate(180deg) translateY(-1px)',
},
},
variants: {
variant: {
light: {},
dark: {
fill: 'primaryDark.50',
stroke: 'primaryDark.50',
},
},
},
defaultVariants: {
variant: 'light',
},
})
/**
@@ -68,8 +56,6 @@ const StyledOverlayArrow = styled(OverlayArrow, {
*/
export const Popover = ({
children,
variant = 'light',
withArrow = true,
...dialogProps
}: {
children: [
@@ -78,24 +64,20 @@ export const Popover = ({
| (({ close }: { close: () => void }) => ReactNode)
| ReactNode,
]
variant?: 'dark' | 'light'
withArrow?: boolean
} & Omit<DialogProps, 'children'>) => {
} & DialogProps) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
{trigger}
<StyledPopover>
{withArrow && (
<StyledOverlayArrow variant={variant}>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
)}
<StyledOverlayArrow>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover" variant={variant}>
<Box size="sm" type="popover">
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
+11 -49
View File
@@ -6,14 +6,13 @@ import {
ListBox,
ListBoxItem,
Select as RACSelect,
SelectProps as RACSelectProps,
SelectProps,
SelectValue,
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { css } from '@/styled-system/css'
import type { Placement } from '@react-types/overlays'
const StyledButton = styled(Button, {
base: {
@@ -42,31 +41,6 @@ const StyledButton = styled(Button, {
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
},
},
variants: {
variant: {
light: {},
dark: {
backgroundColor: 'primaryDark.100',
fontWeight: 'medium !important',
color: 'white',
'&[data-pressed]': {
backgroundColor: 'primaryDark.900',
color: 'primaryDark.100',
},
'&[data-hovered]': {
backgroundColor: 'primaryDark.300',
color: 'white',
},
'&[data-selected]': {
backgroundColor: 'primaryDark.700 !important',
color: 'primaryDark.100 !important',
},
},
},
},
defaultVariants: {
variant: 'light',
},
})
const StyledSelectValue = styled(SelectValue, {
@@ -88,32 +62,23 @@ const StyledIcon = styled('div', {
},
})
export type SelectProps<T> = Omit<
RACSelectProps<object>,
'items' | 'label' | 'errors'
> & {
iconComponent?: RemixiconComponentType
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
placement?: Placement
variant?: 'light' | 'dark'
}
export const Select = <T extends string | number>({
label,
iconComponent,
items,
errors,
placement,
variant = 'light',
...props
}: SelectProps<T>) => {
}: 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 variant={variant}>
<StyledButton>
{!!IconComponent && (
<StyledIcon>
<IconComponent size={18} />
@@ -125,16 +90,13 @@ export const Select = <T extends string | number>({
className={css({ flexShrink: 0 })}
/>
</StyledButton>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<ListBoxItem
className={
menuRecipe({
extraPadding: true,
variant: variant,
}).item
menuRecipe({ extraPadding: true, variant: 'light' }).item
}
id={item.value}
key={item.value}
+6 -33
View File
@@ -22,15 +22,16 @@ export const StyledSwitch = styled(RACSwitch, {
borderRadius: '1.143rem',
transition: 'all 200ms, outline 200ms',
_before: {
willChange: 'transform',
content: '""',
display: 'block',
margin: '0.125rem',
width: '1.063rem',
height: '1.063rem',
borderRadius: '1.063rem',
background: 'primary.800',
transition: 'transform 200ms, background-color 200ms',
border: '2px solid',
borderColor: 'primary.800',
background: 'white',
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
},
@@ -43,29 +44,14 @@ export const StyledSwitch = styled(RACSwitch, {
color: 'primary.800',
fontSize: '0.75rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 0,
},
'& .cross': {
position: 'absolute',
display: 'block',
top: '50%',
left: '0.375rem',
transform: 'translateY(-50%)',
color: 'white',
fontSize: '0.70rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 1,
transition: 'opacity 200ms',
transitionDelay: '0ms',
},
'&[data-selected] .indicator': {
borderColor: 'primary.800',
background: 'primary.800',
_before: {
border: 'none',
background: 'white',
transform: 'translateX(100%)',
},
@@ -75,11 +61,6 @@ export const StyledSwitch = styled(RACSwitch, {
transition: 'opacity 30ms',
transitionDelay: '150ms',
},
'&[data-selected] .cross': {
opacity: 0,
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
@@ -87,9 +68,6 @@ export const StyledSwitch = styled(RACSwitch, {
background: 'primary.200',
},
},
'&[data-disabled] .cross': {
color: 'primary.500',
},
'&[data-focus-visible] .indicator': {
outline: '2px solid!',
outlineColor: 'focusRing!',
@@ -108,12 +86,7 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
export const Switch = ({ children, ...props }: SwitchProps) => (
<StyledSwitch {...props}>
<div className="indicator">
<span className="checkmark" aria-hidden="true">
</span>
<span className="cross" aria-hidden="true">
</span>
<span className="checkmark"></span>
</div>
{children}
</StyledSwitch>
@@ -68,7 +68,6 @@ export const buttonRecipe = cva({
},
secondaryText: {
backgroundColor: 'transparent',
fontWeight: 'medium !important',
color: 'primary.800',
'&[data-hovered]': {
backgroundColor: 'greyscale.100',
@@ -251,20 +250,6 @@ export const buttonRecipe = cva({
color: 'error.300',
},
},
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
@@ -284,20 +269,6 @@ export const buttonRecipe = cva({
color: 'primary !important',
},
},
permission: {
position: 'relative',
// background: 'None !important',
borderRadius: '100%',
// border: 'none !important',
color: 'amber.500',
width: 'fit-content',
height: 'fit-content',
padding: '0 !important',
margin: '0 !important',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
},
invisible: {
true: {
@@ -4,6 +4,6 @@ type State = {
enabled: boolean
}
export const screenSharePreferenceStore = proxy<State>({
export const ScreenSharePreferenceStore = proxy<State>({
enabled: true,
})
-61
View File
@@ -1,61 +0,0 @@
import { proxy } from 'valtio'
import { derive } from 'derive-valtio'
type PermissionState =
| undefined
| 'granted'
| 'prompt'
| 'denied'
| 'unavailable'
type BaseState = {
cameraPermission: PermissionState
microphonePermission: PermissionState
isLoading: boolean
isPermissionDialogOpen: boolean
}
type DerivedState = {
isCameraGranted: boolean
isMicrophoneGranted: boolean
isCameraDenied: boolean
isMicrophoneDenied: boolean
isCameraPrompted: boolean
isMicrophonePrompted: boolean
}
type State = BaseState & DerivedState
export const permissionsStore = proxy<BaseState>({
cameraPermission: undefined,
microphonePermission: undefined,
isLoading: true,
isPermissionDialogOpen: false,
}) as State
derive(
{
isCameraGranted: (get) =>
get(permissionsStore).cameraPermission == 'granted',
isMicrophoneGranted: (get) =>
get(permissionsStore).microphonePermission == 'granted',
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
isMicrophoneDenied: (get) =>
get(permissionsStore).microphonePermission == 'denied',
isCameraPrompted: (get) =>
get(permissionsStore).cameraPermission == 'prompt',
isMicrophonePrompted: (get) =>
get(permissionsStore).microphonePermission == 'prompt',
},
{
proxy: permissionsStore,
}
)
export const openPermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = true
}
export const closePermissionsDialog = () => {
permissionsStore.isPermissionDialogOpen = false
}
-12
View File
@@ -1,12 +0,0 @@
import { proxy } from 'valtio'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
type State = {
areSettingsOpen: boolean
defaultSelectedTab?: SettingsDialogExtendedKey
}
export const settingsStore = proxy<State>({
areSettingsOpen: false,
defaultSelectedTab: undefined,
})
-9
View File
@@ -5,24 +5,15 @@ import {
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
videoSubscribeQuality?: VideoQuality
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(),
}
}
-14
View File
@@ -1,14 +0,0 @@
/**
* FRAGILE: Splits translated text on placeholder to inject icons inline.
*
* Fragile because:
* - Relies on exact string matching - typos break it silently
* - Translators may accidentally modify/remove placeholders
* - No validation or error handling
*/
export const injectIconIntoTranslation = (
translation: string,
placeholder: string = 'ICON_PLACEHOLDER'
) => {
return translation.split(placeholder)
}
@@ -35,7 +35,7 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
@@ -78,7 +78,8 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
@@ -35,7 +35,7 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
@@ -69,8 +69,6 @@ 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
@@ -79,7 +77,15 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
@@ -94,6 +100,13 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
+17 -2
View File
@@ -57,7 +57,7 @@ backend:
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
@@ -100,7 +100,15 @@ backend:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
@@ -115,6 +123,13 @@ backend:
- "/bin/sh"
- "-c"
- |
while ! python manage.py check --database default > /dev/null 2>&1
do
echo "Database not ready"
sleep 2
done
echo "Database is ready"
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
+1 -1
View File
@@ -1,4 +1,4 @@
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
@@ -1,61 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: kc-postgres
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: tcp-postgresql
port: 5432
protocol: TCP
targetPort: tcp-postgresql
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: kc-postgresql
type: ClusterIP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kc-postgresql
namespace: {{ .Release.Namespace | quote }}
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: kc-postgresql
serviceName: "kc-postgres"
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: kc-postgresql
spec:
terminationGracePeriodSeconds: 10
containers:
- name: pg
image: postgres:16-alpine
ports:
- containerPort: 5432
name: tcp-postgresql
env:
- name: POSTGRES_PASSWORD
value: pass
- name: POSTGRES_USER
value: dinum
- name: POSTGRES_DB
value: keycloak
volumeMounts:
- name: data
mountPath: /var/lib/postgresql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
-104
View File
@@ -1,104 +0,0 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak
spec:
rules:
- host: "keycloak.127.0.0.1.nip.io"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 8080
tls:
- hosts:
- keycloak.127.0.0.1.nip.io
secretName: meet-tls
---
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: tcp-keycloak
port: 8080
protocol: TCP
targetPort: tcp-keycloak
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: keycloak
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: realm
data:
meet.json: |
{{ .Values.realm | indent 4 }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: keycloak
namespace: {{ .Release.Namespace | quote }}
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: keycloak
serviceName: "keycloak"
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: keycloak
spec:
terminationGracePeriodSeconds: 10
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:20.0.1
args:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname=keycloak.127.0.0.1.nip.io
- --hostname-strict=false
- --hostname-strict-https=false
ports:
- containerPort: 8080
name: tcp-keycloak
env:
- name: KEYCLOAK_ADMIN
value: admin
- name: KEYCLOAK_ADMIN_PASSWORD
value: admin
- name: PROXY_ADDRESS_FORWARDING
value: 'true'
- name: KC_DB_URL_HOST
value: kc_postgresql
- name: KC_DB_URL_DATABASE
value: keycloak
- name: KC_DB_PASSWORD
value: pass
- name: KC_DB_USERNAME
value: dinum
- name: KC_DB_SCHEMA
value: public
volumeMounts:
- name: realm
mountPath: "/opt/keycloak/data/import"
readOnly: true
volumes:
- name: realm
configMap:
name: realm
-107
View File
@@ -1,107 +0,0 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minio
spec:
rules:
- host: "minio.127.0.0.1.nip.io"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minio
port:
number: 9001
tls:
- hosts:
- minio.127.0.0.1.nip.io
secretName: meet-tls
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: client
port: 9000
protocol: TCP
targetPort: 9000
- name: console
port: 9001
protocol: TCP
targetPort: 9001
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: {{ .Release.Namespace | quote }}
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: minio
spec:
containers:
- name: minio
command:
- /bin/sh
- -c
- |
minio server --console-address :9001 /data
env:
- name: MINIO_ROOT_USER
value: meet
- name: MINIO_ROOT_PASSWORD
value: password
image: "minio/minio"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9000
name: client
- containerPort: 9001
name: console
volumeMounts:
- mountPath: /data
name: data
volumes:
- name: data
emptyDir:
---
apiVersion: batch/v1
kind: Job
metadata:
name: minio-bucket
spec:
template:
spec:
containers:
- name: mc
image: minio/mc
command:
- /bin/sh
- -c
- |
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0
restartPolicy: Never
backoffLimit: 1
@@ -1,70 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: tcp-postgresql
port: 5432
protocol: TCP
targetPort: tcp-postgresql
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: postgresql
type: ClusterIP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgresql
namespace: {{ .Release.Namespace | quote }}
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: postgresql
serviceName: "postgres"
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: postgresql
spec:
terminationGracePeriodSeconds: 10
containers:
- name: pg
image: postgres:16-alpine
readinessProbe:
exec:
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: [ "pg_isready", "-U", "dinum", "-d", "meet", "-h", "127.0.0.1" ]
initialDelaySeconds: 15
periodSeconds: 10
ports:
- containerPort: 5432
name: tcp-postgresql
env:
- name: POSTGRES_PASSWORD
value: pass
- name: POSTGRES_USER
value: dinum
- name: POSTGRES_DB
value: meet
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
-65
View File
@@ -1,65 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: redis-master
namespace: {{ .Release.Namespace | quote }}
spec:
ports:
- name: tcp-redis
port: 6379
protocol: TCP
targetPort: tcp-redis
selector:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: redis
type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
name: redis
data:
redis.conf: |
bind 0.0.0.0
port 6379
user default on >pass ~* &* +@all
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: {{ .Release.Namespace | quote }}
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: redis
spec:
selector:
matchLabels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: redis
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/instance: extra
app.kubernetes.io/name: redis
spec:
containers:
- name: redis
args:
- redis-server
- /usr/local/etc/redis/redis.conf
image: "redis:8.2-alpine"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379
name: tcp-redis
volumeMounts:
- name: redis
mountPath: "/usr/local/etc/redis"
readOnly: true
volumes:
- name: redis
configMap:
name: redis
+99 -2
View File
@@ -13,10 +13,109 @@ environments:
- env.d/{{ .Environment.Name }}/values.secrets.yaml
repositories:
- name: bitnami
url: registry-1.docker.io/bitnamicharts
oci: true
- name: livekit
url: https://helm.livekit.io
releases:
- name: postgres
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/postgresql
version: 13.1.5
values:
- auth:
username: dinum
password: pass
database: meet
- tls:
enabled: true
autoGenerated: true
- name: keycloak
installed: {{ or (eq .Environment.Name "dev-keycloak") (eq .Environment.Name "dev-dinum") | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/keycloak
version: 17.3.6
values:
- postgresql:
auth:
username: keycloak
password: keycloak
database: keycloak
- extraEnvVars:
- name: KEYCLOAK_EXTRA_ARGS
value: "--import-realm"
- name: KC_HOSTNAME_URL
value: https://keycloak.127.0.0.1.nip.io
- extraVolumes:
- name: import
configMap:
name: meet-keycloak
- extraVolumeMounts:
- name: import
mountPath: /opt/bitnami/keycloak/data/import/
- auth:
adminUser: su
adminPassword: su
- proxy: edge
- ingress:
enabled: true
hostname: keycloak.127.0.0.1.nip.io
- extraDeploy:
- apiVersion: v1
kind: ConfigMap
metadata:
name: meet-keycloak
data:
meet.json: |
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 14 }}
- name: minio
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
namespace: {{ .Namespace }}
missingFileHandler: Warn
chart: bitnami/minio
version: 12.10.10
values:
- auth:
rootUser: meet
rootPassword: password
- provisioning:
enabled: true
buckets:
- name: meet-media-storage
versioning: true
- ingress:
enabled: true
hostname: minio-console.127.0.0.1.nip.io
servicePort: 9001
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
kubernetes.io/ingress.class: nginx
extraVolumes:
- name: mkcert
secret:
secretName: mkcert
extraVolumeMounts:
- mountPath: /certs/CAs/
name: mkcert
- name: redis
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/redis
version: 18.19.2
values:
- auth:
password: pass
architecture: standalone
- name: extra
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
@@ -30,8 +129,6 @@ releases:
enablePermanentRedirect: {{ .Values | get "enablePermanentRedirect" "False"}}
oldDomain: {{ .Values | get "oldDomain" "demo.com" }}
newDomain: {{ .Values | get "newDomain" "demo.com" }}
- realm: |
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 8 }}
- name: meet
version: {{ .Values.version }}
+2 -11
View File
@@ -17,18 +17,9 @@ services:
depends_on:
- redis
celery_worker:
container_name: celery_worker_transcribe
container_name: celery_worker
build: .
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe_queue
volumes:
- .:/app
depends_on:
- redis
- app
celery_summarize_worker:
container_name: celery_worker_summarize
build: .
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize_queue
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug
volumes:
- .:/app
depends_on:
+2 -4
View File
@@ -6,13 +6,12 @@ from typing import Optional
from celery.result import AsyncResult
from fastapi import APIRouter
from pydantic import BaseModel
from summary.core.config import get_settings
from summary.core.celery_worker import (
process_audio_transcribe_summarize,
process_audio_transcribe_summarize_v2,
)
settings = get_settings()
class TaskCreation(BaseModel):
"""Task data."""
@@ -46,8 +45,7 @@ async def create_task(request: TaskCreation):
request.room,
request.recording_date,
request.recording_time,
],
queue=settings.transcribe_queue,
]
)
return {"id": task.id, "message": "Task created"}
-18
View File
@@ -48,24 +48,6 @@ class Analytics:
except Exception as e:
raise AnalyticsException("Failed to capture analytics event") from e
def is_feature_enabled(
self, feature_name: str, distinct_id: str = None
) -> bool:
"""
Check if a feature flag is enabled in PostHog for a dinstinct_id.
"""
if self.is_disabled:
return False
try:
logger.info(
f"Check feature flag {feature_name} for user {distinct_id}"
)
return self._client.feature_enabled(feature_name, distinct_id)
except Exception as e:
logger.error(f"Error checking feature flag {feature_name}: {e}")
return False
@lru_cache
def get_analytics():
-23
View File
@@ -1,23 +0,0 @@
from celery import Celery
from celery import signals
import sentry_sdk
from summary.core.config import get_settings
import summary.core.celery_signals
settings = get_settings()
celery = Celery(
__name__,
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
broker_connection_retry_on_startup=True,
)
celery.config_from_object("summary.core.celery_config")
if settings.sentry_dsn and settings.sentry_is_enabled:
@signals.celeryd_init.connect
def init_sentry(**_kwargs):
"""Initialize sentry."""
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
@@ -1,26 +0,0 @@
from summary.core.analytics import MetadataManager, get_analytics
from celery import signals
from summary.core.config import get_settings
analytics = get_analytics()
settings = get_settings()
metadata_manager = MetadataManager()
@signals.task_prerun.connect
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
task_args = args or []
metadata_manager.create(task_id, task_args)
@signals.task_retry.connect
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_event_failure)
@@ -1,123 +0,0 @@
"""Celery summarize workers."""
from celery import Celery, signals
from celery.utils.log import get_task_logger
import openai
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.prompt import (
PROMPT_SYSTEM_PLAN,
PROMPT_SYSTEM_TLDR,
PROMPT_SYSTEM_PART,
PROMPT_USER_PART,
PROMPT_SYSTEM_CLEANING,
PROMPT_SYSTEM_NEXT_STEP,
)
from summary.core.config import get_settings
from summary.core.celery_app import celery
settings = get_settings()
logger = get_task_logger(__name__)
def create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
return session
def post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = create_retry_session()
session.headers.update({"Authorization": f"Bearer {settings.webhook_api_token}"})
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
session.close()
def LLM_call(client, system_prompt, user_prompt, retry=2):
data = {
"model": settings.resume_llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
try:
response = client.chat.completions.create(**data)
return response.choices[0].message.content
except Exception as e:
logger.error("LLM call failed: %s", e)
return False
@celery.task(
bind=True, autoretry_for=[Exception], max_retries=3, queue=settings.summarize_queue
)
def summarize_transcription(self, transcript: str, email: str, sub: str, title: str):
logger.info("Starting summarization task")
logger.info("Initiating summarize client")
client_summary = openai.OpenAI(
base_url=settings.resume_endpoint, api_key=settings.resume_api_key
)
tldr = LLM_call(client_summary, PROMPT_SYSTEM_TLDR, transcript)
logger.info("TLDR generated")
parts = LLM_call(client_summary, PROMPT_SYSTEM_PLAN, transcript)
logger.info("Plan generated")
parts = parts.split("\n")
parts = [x for x in parts if x.strip() != ""]
logger.info("Empty parts removed")
parts_summarized = []
for part in parts:
prompt_user_part = PROMPT_USER_PART.format(part=part, transcript=transcript)
logger.info("Summarizing part: %s", part)
parts_summarized.append(
LLM_call(
client_summary, PROMPT_SYSTEM_PART, prompt_user_part.format(part=part)
)
)
logger.info("Parts summarized")
raw_summary = "\n\n".join(parts_summarized)
next_steps = LLM_call(client_summary, PROMPT_SYSTEM_NEXT_STEP, transcript)
logger.info("Next steps generated")
cleaned_summary = LLM_call(client_summary, PROMPT_SYSTEM_CLEANING, raw_summary)
logger.info("Summary cleaned")
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
data = {
"title": title + " - Summary",
"content": summary,
"email": email,
"sub": sub,
}
logger.debug("Submitting webhook to %s", settings.webhook_url)
response = post_with_retries(settings.webhook_url, data)
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text)
+41 -13
View File
@@ -19,18 +19,33 @@ from requests import Session, exceptions
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.prompt import get_instructions
from summary.core.celery_summarize_worker import summarize_transcription
from summary.core.celery_app import celery
from summary.core.celery_signals import metadata_manager
from summary.core.analytics import get_analytics
settings = get_settings()
analytics = get_analytics()
metadata_manager = MetadataManager()
logger = get_task_logger(__name__)
celery = Celery(
__name__,
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
broker_connection_retry_on_startup=True,
)
celery.config_from_object("summary.core.celery_config")
if settings.sentry_dsn and settings.sentry_is_enabled:
@signals.celeryd_init.connect
def init_sentry(**_kwargs):
"""Initialize sentry."""
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
DEFAULT_EMPTY_TRANSCRIPTION = """
**Aucun contenu audio na été détecté dans votre transcription.**
@@ -122,6 +137,25 @@ def post_with_retries(url, data):
session.close()
@signals.task_prerun.connect
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
task_args = args or []
metadata_manager.create(task_id, task_args)
@signals.task_retry.connect
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect
def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_event_failure)
@celery.task(max_retries=settings.celery_max_retries)
def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
"""Process an audio file by transcribing it and generating a summary.
@@ -175,7 +209,7 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
instructions = get_instructions(transcription)
summary_response = openai_client.chat.completions.create(
model=settings.resume_llm_model, messages=instructions
model=settings.openai_llm_model, messages=instructions
)
summary = summary_response.choices[0].message.content
@@ -202,7 +236,6 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
@@ -320,10 +353,5 @@ def process_audio_transcribe_summarize_v2(
logger.debug("Response body: %s", response.text)
metadata_manager.capture(task_id, settings.posthog_event_success)
if not analytics.is_feature_enabled("summary-enabled", distinct_id=sub):
logger.info("Summary generation skipped (feature flag disabled).")
else:
logger.info("Queuing summary generation task.")
summarize_transcription.apply_async(
args=[formatted_transcription, email, sub, title], queue=settings.summarize_queue
)
# TODO - integrate summarize the transcript and create a new document.
+2 -6
View File
@@ -35,10 +35,8 @@ class Settings(BaseSettings):
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1"
openai_asr_model: str = "whisper-1"
resume_llm_model: str = "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
openai_llm_model: str = "gpt-4o"
openai_max_retries: int = 0
resume_api_key: str
resume_endpoint: str
# Webhook-related settings
webhook_max_retries: int = 2
@@ -68,9 +66,7 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
# Queue redis
summarize_queue: str = "summarize_queue"
transcribe_queue: str = "transcribe_queue"
@lru_cache
def get_settings():
"""Load and cache application settings."""
-31
View File
@@ -50,34 +50,3 @@ def get_instructions(transcript):
},
{"role": "user", "content": prompt},
]
PROMPT_SYSTEM_TLDR = """Tu es un agent dont le rôle est de créer un TL;DR (résumé très concis) d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est de rédiger un résumé concis et structuré, en te concentrant uniquement sur les informations essentielles et pertinentes. Tu répondras en un paragraphe structuré (3 à 6 phrases), sans rien ajouter d'autre. Tu répondras dans le format suivant sans rien ajouter d'autre:
### Résumé TL;DR
[Résumé concis et structuré]"""
PROMPT_SYSTEM_PLAN = """Ta tâche est de diviser le contenu du transcript en sujets concrets correspondant aux grands axes discutés durant la réunion. Ne crée pas de catégories génériques. Les titres doivent être courts, précis et représentatifs des échanges. Veille à ce que chaque sujet soit distinct et quaucun thème ne soit répété. Tu te limitera à 5 ou 6 sujets maximum.
L'introducion, ordre du jour, conclusion etc seront rajoutés a posteriori. Tu répondra dans le format suivant sans rien ajouter d'autre:
"Titre du sujet 1
Titre du sujet 2
Titre du sujet 3
..."
"""
PROMPT_SYSTEM_PART = """Tu es un agent dont le rôle est de créer une partie du résumé d'un compte rendu de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript, et le titre du sujet correspondant. Ta tâche est de rédiger un résumé concis de cette partie et uniquement cette partie, en te concentrant uniquement sur les informations essentielles et pertinentes. Le résumé de chaque partie doit tenir en 4 à 6 phrases maximum, sans entrer dans les détails mineurs. Tu répondra dans le format suivant :
### Titre du sujet [Traduire ce titre selon la langue du transcript]
[Résumé concis et structuré de la partie du transcript]
"""
PROMPT_USER_PART = """Titre de la partie à résumer : {part}
Transcript complet :
{transcript}"""
PROMPT_SYSTEM_CLEANING = """Tu es un agent dont le rôle est de nettoyer un résumé de compte rendu de réunion. Tu recevras en entrée le résumé brut, potentiellement avec des erreurs de formatage, des incohérences ou des redondances. Ta tâche est de corriger les erreurs de formatage, d'améliorer la clarté et la cohérence du texte, et de t'assurer que le résumé est bien structuré et facile à lire. Ton but principal est de retirer les redondances et les répétitions. Assure la cohérence entre les titres, et homogénéise le style d’écriture entre les parties. Supprime les doublons dinformations entre les parties si présents. Si certaines parties sont plus secondaires, tu peux les fusionner ou les réduire en 1 à 2 phrases. Mets en avant les points centraux qui ont fait lobjet de décisions ou dactions. Tu répondra uniquement avec le résumé sans rien ajouter d'autre"""
PROMPT_SYSTEM_NEXT_STEP = """Tu es un agent dont le rôle est d'extraire les prochaines étapes d'un transcript de réunion. Tu utilisera un style synthétique administratif, à la troisième personne, sans affect. Tu recevras en entrée le transcript. Ta tâche est d'identifier et lister toutes les actions à entreprendre, en indiquant la ou les personnes assignées et en précisant les échéances si elles sont mentionnées. Ne retiens que les actions concrètes et à venir. Ignore les remarques générales ou les constats sans suite. Les actions doivent suivre ce format strict :
### Prochaines étapes
- [ ] [Action à effectuer] Assignée à : [Nom], Échéance : [Date si mentionnée]"""