Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5465f3b85 | |||
| 872ce1ecc6 | |||
| e2c3b745ca | |||
| 965d823d08 | |||
| 1db189ace2 | |||
| 199e0908e9 | |||
| 8518f83211 | |||
| 0240d85837 | |||
| 162896c93c | |||
| 483a219ac4 | |||
| 1b26dea178 | |||
| bdaf4245da | |||
| be63993ba2 | |||
| 3d245c3bd4 | |||
| 66a36eff73 | |||
| 387bc2e1f4 | |||
| d44b45b6aa | |||
| 224b98fd9a | |||
| 031852d0b1 | |||
| 24915b0485 |
@@ -35,9 +35,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
|
||||
@@ -217,9 +217,6 @@ DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -339,6 +336,8 @@ These are the environmental options available on meet backend.
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
|
||||
@@ -50,6 +50,11 @@ def get_frontend_configuration(request):
|
||||
else None,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -492,6 +492,14 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
|
||||
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
|
||||
)
|
||||
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
|
||||
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.30"
|
||||
version = "0.1.33"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+2
-54
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-hook/size": "2.1.2",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
@@ -1211,12 +1210,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@juggle/resize-observer": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/components-core": {
|
||||
"version": "0.12.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
|
||||
@@ -2708,51 +2701,6 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-hook/latest": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
|
||||
"integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-hook/passive-layout-effect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-hook/resize-observer": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz",
|
||||
"integrity": "sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@react-hook/latest": "^1.0.2",
|
||||
"@react-hook/passive-layout-effect": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-hook/size": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-hook/size/-/size-2.1.2.tgz",
|
||||
"integrity": "sha512-BmE5asyRDxSuQ9p14FUKJ0iBRgV9cROjqNG9jT/EjCM+xHha1HVqbPoT+14FQg1K7xIydabClCibUY4+1tw/iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-hook/passive-layout-effect": "^1.2.0",
|
||||
"@react-hook/resize-observer": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/autocomplete": {
|
||||
"version": "3.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -18,7 +18,6 @@
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@react-hook/size": "2.1.2",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface ApiConfig {
|
||||
default_country?: string
|
||||
}
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
force_wss_protocol: boolean
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -155,17 +154,14 @@ export const MainNotificationToast = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationReceived = (
|
||||
prevMetadataStr: string | undefined,
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant) return
|
||||
if (isMobileBrowser()) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const prevMetadata = safeParseMetadata(prevMetadataStr)
|
||||
const metadata = safeParseMetadata(participant.metadata)
|
||||
|
||||
if (prevMetadata?.raised == metadata?.raised) return
|
||||
if (!('handRaisedAt' in changedAttributes)) return
|
||||
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
@@ -173,12 +169,12 @@ export const MainNotificationToast = () => {
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
)
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
if (existingToast && !changedAttributes?.handRaisedAt) {
|
||||
toastQueue.close(existingToast.key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
if (!existingToast && !!changedAttributes?.handRaisedAt) {
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
toastQueue.add(
|
||||
{
|
||||
@@ -190,10 +186,13 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
room.off(
|
||||
RoomEvent.ParticipantAttributesChanged,
|
||||
handleNotificationReceived
|
||||
)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LiveKitRoom } from '@livekit/components-react'
|
||||
import {
|
||||
LiveKitRoom,
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
@@ -20,29 +23,38 @@ import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
userConfig,
|
||||
initialRoomData,
|
||||
mode = 'join',
|
||||
}: {
|
||||
roomId: string
|
||||
userConfig: LocalUserChoices
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const posthog = usePostHog()
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
}, [roomId, posthog])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
status: createStatus,
|
||||
@@ -74,10 +86,13 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isAdaptiveStreamSupported = supportsAdaptiveStream()
|
||||
const isDynacastSupported = supportsDynacast()
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: supportsAdaptiveStream(),
|
||||
dynacast: supportsDynacast(),
|
||||
adaptiveStream: isAdaptiveStreamSupported,
|
||||
dynacast: isDynacastSupported,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
@@ -89,10 +104,60 @@ export const Conference = ({
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||
}, [
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.audioDeviceId,
|
||||
isAdaptiveStreamSupported,
|
||||
isDynacastSupported,
|
||||
])
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
@@ -102,6 +167,20 @@ export const Conference = ({
|
||||
kind: null,
|
||||
})
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
||||
*/
|
||||
const serverUrl = useMemo(() => {
|
||||
const livekit_url = apiConfig?.livekit.url
|
||||
if (!livekit_url) return
|
||||
if (apiConfig?.livekit.force_wss_protocol) {
|
||||
return livekit_url.replace('https://', 'wss://')
|
||||
}
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
@@ -125,9 +204,9 @@ export const Conference = ({
|
||||
<Screen header={false} footer={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
connect={isConnectionWarmedUp}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
@@ -140,6 +219,9 @@ export const Conference = ({
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
posthog.captureException(e)
|
||||
}}
|
||||
onDisconnected={(e) => {
|
||||
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||
navigateTo('feedback', { duplicateIdentity: false })
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import {
|
||||
@@ -27,7 +27,6 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -72,45 +71,23 @@ const Effects = ({
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: '0',
|
||||
padding: '1rem',
|
||||
zIndex: '1',
|
||||
})}
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
onPress={openDialog}
|
||||
tooltip={t('description')}
|
||||
aria-label={t('description')}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
enterRoom,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
enterRoom: () => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
@@ -132,23 +109,14 @@ export const Join = ({
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: audioDeviceId },
|
||||
video: { deviceId: videoDeviceId },
|
||||
video: {
|
||||
deviceId: videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
},
|
||||
},
|
||||
onError
|
||||
)
|
||||
@@ -170,12 +138,14 @@ export const Join = ({
|
||||
)
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -192,25 +162,6 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
const enterRoom = useCallback(() => {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processorSerialized: processor?.serialize(),
|
||||
})
|
||||
}, [
|
||||
onSubmit,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processor,
|
||||
])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
@@ -269,15 +220,19 @@ export const Join = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
// This hook is used to setup the persisted user choice processor on initialization.
|
||||
// So it's on purpose that processor is not included in the deps.
|
||||
// We just want to wait for the videoTrack to be loaded to apply the default processor.
|
||||
useEffect(() => {
|
||||
if (processor && videoTrack && !videoTrack.getProcessor()) {
|
||||
videoTrack.setProcessor(processor)
|
||||
const hintMessage = useMemo(() => {
|
||||
if (!videoEnabled) {
|
||||
return 'cameraDisabled'
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
|
||||
if (!isVideoInitiated.current) {
|
||||
return 'cameraStarting'
|
||||
}
|
||||
|
||||
if (videoTrack && videoEnabled) {
|
||||
return ''
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
const renderWaitingState = () => {
|
||||
switch (status) {
|
||||
@@ -361,141 +316,216 @@ 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: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
maxWidth: '764px',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
height: '540px',
|
||||
flexGrow: 1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: { base: 0, sm: 1 },
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
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',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{videoTrack && videoEnabled ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
borderRadius: '1rem',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'fit-content',
|
||||
aspectRatio: '16 / 9',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'black',
|
||||
position: 'absolute',
|
||||
boxSizing: 'border-box',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
<div
|
||||
aria-label={t(
|
||||
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
|
||||
)}
|
||||
role="status"
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: '300',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{!videoEnabled && t('cameraDisabled')}
|
||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||
</p>
|
||||
<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 ? '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',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: '400',
|
||||
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
|
||||
textWrap: 'balance',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{hintMessage && t(hintMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const useLowerHandParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
const newMetadata = safeParseMetadata(participant.metadata) || {}
|
||||
newMetadata.raised = !newMetadata.raised
|
||||
|
||||
const newAttributes = {
|
||||
...participant.attributes,
|
||||
handRaisedAt: '',
|
||||
}
|
||||
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
metadata: JSON.stringify(newMetadata),
|
||||
attributes: newAttributes,
|
||||
permission: participant.permissions,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useMemo, useRef } from 'react'
|
||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSize from '@react-hook/size'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
@@ -16,7 +16,7 @@ export const FullScreenShareWarning = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const containerWidth = useSize(warningContainerRef)[0]
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useIsSpeaking } from '@livekit/components-react'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import useSize from '@react-hook/size'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
@@ -28,7 +28,7 @@ export const ParticipantPlaceholder = ({
|
||||
const participantColor = getParticipantColor(participant)
|
||||
|
||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||
const [width, height] = useSize(placeholderEl)
|
||||
const { width, height } = useSize(placeholderEl)
|
||||
|
||||
const minDimension = Math.min(width, height)
|
||||
const avatarSize = useMemo(
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand } from '../hooks/useRaisedHand'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
@@ -97,6 +97,10 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
@@ -141,24 +145,32 @@ export const ParticipantTile: (
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { createInteractingObservable } from '@livekit/components-core'
|
||||
import { usePagination } from '@livekit/components-react'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
|
||||
export interface PaginationControlProps
|
||||
extends Pick<
|
||||
ReturnType<typeof usePagination>,
|
||||
'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage'
|
||||
> {
|
||||
/** Reference to an HTML element that holds the pages, while interacting (`mouseover`)
|
||||
* with it, the pagination controls will appear for a while. */
|
||||
pagesContainer?: React.RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
export function PaginationControl({
|
||||
totalPageCount,
|
||||
nextPage,
|
||||
prevPage,
|
||||
currentPage,
|
||||
pagesContainer: connectedElement,
|
||||
}: PaginationControlProps) {
|
||||
const [interactive, setInteractive] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
let subscription:
|
||||
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
|
||||
| undefined
|
||||
if (connectedElement) {
|
||||
subscription = createInteractingObservable(
|
||||
connectedElement.current,
|
||||
2000
|
||||
).subscribe(setInteractive)
|
||||
}
|
||||
return () => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [connectedElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lk-pagination-control"
|
||||
data-lk-user-interaction={interactive}
|
||||
>
|
||||
<button className="lk-button" onClick={prevPage}>
|
||||
<RiArrowLeftSLine />
|
||||
</button>
|
||||
<span className="lk-pagination-count">{`${currentPage} of ${totalPageCount}`}</span>
|
||||
<button className="lk-button" onClick={nextPage}>
|
||||
<RiArrowRightSLine />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export interface PaginationIndicatorProps {
|
||||
totalPageCount: number
|
||||
currentPage: number
|
||||
}
|
||||
|
||||
export const PaginationIndicator: (
|
||||
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PaginationIndicatorProps
|
||||
>(function PaginationIndicator(
|
||||
{ totalPageCount, currentPage }: PaginationIndicatorProps,
|
||||
ref
|
||||
) {
|
||||
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
|
||||
if (index + 1 === currentPage) {
|
||||
return <span data-lk-active key={index} />
|
||||
} else {
|
||||
return <span key={index} />
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className="lk-pagination-indicator">
|
||||
{bubbles}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
+9
-5
@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
|
||||
...sortedRemoteParticipants,
|
||||
]
|
||||
|
||||
const raisedHandParticipants = participants.filter((participant) => {
|
||||
const data = safeParseMetadata(participant.metadata)
|
||||
return data.raised
|
||||
})
|
||||
const raisedHandParticipants = participants
|
||||
.filter((participant) => !!participant.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
||||
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import useSize from '@react-hook/size'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
const MIN_VISIBLE_TILES = 1
|
||||
const ASPECT_RATIO = 16 / 10
|
||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||
|
||||
/** @public */
|
||||
export interface CarouselLayoutProps
|
||||
extends React.HTMLAttributes<HTMLMediaElement> {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
children: React.ReactNode
|
||||
/** Place the tiles vertically or horizontally next to each other.
|
||||
* If undefined orientation is guessed by the dimensions of the container. */
|
||||
orientation?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CarouselLayout` component displays a list of tracks in a scroll container.
|
||||
* It will display as many tiles as possible and overflow the rest.
|
||||
* @remarks
|
||||
* To ensure visual stability when tiles are reordered due to track updates,
|
||||
* the component uses the `useVisualStableUpdate` hook.
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tracks = useTracks([Track.Source.Camera]);
|
||||
* <CarouselLayout tracks={tracks}>
|
||||
* <ParticipantTile />
|
||||
* </CarouselLayout>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function CarouselLayout({
|
||||
tracks,
|
||||
orientation,
|
||||
...props
|
||||
}: CarouselLayoutProps) {
|
||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||
const [width, height] = useSize(asideEl)
|
||||
const carouselOrientation = orientation
|
||||
? orientation
|
||||
: height >= width
|
||||
? 'vertical'
|
||||
: 'horizontal'
|
||||
|
||||
const tileSpan =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||
const scrollBarWidth = getScrollBarWidth()
|
||||
|
||||
const tilesThatFit =
|
||||
carouselOrientation === 'vertical'
|
||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||
|
||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||
maxVisibleTiles = Math.round(prevTiles)
|
||||
} else if (prevTiles !== tilesThatFit) {
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||
asideEl.current.style.setProperty(
|
||||
'--lk-max-visible-tiles',
|
||||
maxVisibleTiles.toString()
|
||||
)
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
className="lk-carousel"
|
||||
ref={asideEl}
|
||||
{...props}
|
||||
>
|
||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import {
|
||||
TrackLoop,
|
||||
usePagination,
|
||||
UseParticipantsOptions,
|
||||
useSwipe,
|
||||
} from '@livekit/components-react'
|
||||
import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||
import { PaginationControl } from '../controls/PaginationControl'
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
||||
children: React.ReactNode
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The `GridLayout` component displays the nested participants in a grid where every participants has the same size.
|
||||
* It also supports pagination if there are more participants than the grid can display.
|
||||
* @remarks
|
||||
* To ensure visual stability when tiles are reordered due to track updates,
|
||||
* the component uses the `useVisualStableUpdate` hook.
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <GridLayout tracks={tracks}>
|
||||
* <ParticipantTile />
|
||||
* </GridLayout>
|
||||
* <LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
|
||||
const elementProps = React.useMemo(
|
||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||
[props]
|
||||
)
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
onRightSwipe: pagination.prevPage,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridEl}
|
||||
data-lk-pagination={pagination.totalPageCount > 1}
|
||||
{...elementProps}
|
||||
>
|
||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||
{tracks.length > layout.maxTiles && (
|
||||
<>
|
||||
<PaginationIndicator
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
/>
|
||||
<PaginationControl pagesContainer={gridEl} {...pagination} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core'
|
||||
import type {
|
||||
GridLayoutDefinition,
|
||||
GridLayoutInfo,
|
||||
} from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import useSize from '@react-hook/size'
|
||||
|
||||
/**
|
||||
* The `useGridLayout` hook tries to select the best layout to fit all tiles.
|
||||
* If the available screen space is not enough, it will reduce the number of maximum visible
|
||||
* tiles and select a layout that still works visually within the given limitations.
|
||||
* As the order of tiles changes over time, the hook tries to keep visual updates to a minimum
|
||||
* while trying to display important tiles such as speaking participants or screen shares.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { layout } = useGridLayout(gridElement, trackCount);
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function useGridLayout(
|
||||
/** HTML element that contains the grid. */
|
||||
gridElement: React.RefObject<HTMLDivElement>,
|
||||
/** Count of tracks that should get layed out */
|
||||
trackCount: number,
|
||||
options: {
|
||||
gridLayouts?: GridLayoutDefinition[]
|
||||
} = {}
|
||||
): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } {
|
||||
const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS
|
||||
const [width, height] = useSize(gridElement)
|
||||
const layout = selectGridLayout(gridLayouts, trackCount, width, height)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (gridElement.current && layout) {
|
||||
gridElement.current.style.setProperty(
|
||||
'--lk-col-count',
|
||||
layout?.columns.toString()
|
||||
)
|
||||
gridElement.current.style.setProperty(
|
||||
'--lk-row-count',
|
||||
layout?.rows.toString()
|
||||
)
|
||||
}
|
||||
}, [gridElement, layout])
|
||||
|
||||
return {
|
||||
layout,
|
||||
containerWidth: width,
|
||||
containerHeight: height,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalytics
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
|
||||
export const useNoiseReductionAvailable = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.noiseReduction)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const isMobile = isMobileBrowser()
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import { LocalParticipant, Participant } from 'livekit-client'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import {
|
||||
useParticipantAttribute,
|
||||
useParticipants,
|
||||
} from '@livekit/components-react'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type useRaisedHandProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
// fixme - refactor this part to rely on attributes
|
||||
const { metadata } = useParticipantInfo({ participant })
|
||||
const parsedMetadata = JSON.parse(metadata || '{}')
|
||||
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
|
||||
const toggleRaisedHand = () => {
|
||||
if (isLocal(participant)) {
|
||||
parsedMetadata.raised = !parsedMetadata.raised
|
||||
const localParticipant = participant as LocalParticipant
|
||||
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
|
||||
const participants = useParticipants()
|
||||
|
||||
const positionInQueue = useMemo(() => {
|
||||
if (!isHandRaised) return
|
||||
|
||||
return (
|
||||
participants
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
return dateA.getTime() - dateB.getTime()
|
||||
})
|
||||
.findIndex((p) => p.identity === participant.identity) + 1
|
||||
)
|
||||
}, [participants, participant, isHandRaised])
|
||||
|
||||
return {
|
||||
positionInQueue,
|
||||
firstInQueue: positionInQueue == 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
||||
participant,
|
||||
})
|
||||
|
||||
const isHandRaised = !!handRaisedAtAttribute
|
||||
|
||||
const toggleRaisedHand = async () => {
|
||||
if (!isLocal(participant)) return
|
||||
const localParticipant = participant as LocalParticipant
|
||||
|
||||
const attributes: Record<string, string> = {
|
||||
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
|
||||
}
|
||||
|
||||
await localParticipant.setAttributes(attributes)
|
||||
}
|
||||
|
||||
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
|
||||
return { isHandRaised, toggleRaisedHand }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import * as React from 'react'
|
||||
|
||||
const useLatest = <T>(current: T) => {
|
||||
const storedValue = React.useRef(current)
|
||||
React.useEffect(() => {
|
||||
storedValue.current = current
|
||||
})
|
||||
return storedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
|
||||
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useResizeObserver<T extends HTMLElement>(
|
||||
target: React.RefObject<T>,
|
||||
callback: UseResizeObserverCallback
|
||||
) {
|
||||
const resizeObserver = getResizeObserver()
|
||||
const storedCallback = useLatest(callback)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
let didUnsubscribe = false
|
||||
|
||||
const targetEl = target.current
|
||||
if (!targetEl) return
|
||||
|
||||
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
|
||||
if (didUnsubscribe) return
|
||||
storedCallback.current(entry, observer)
|
||||
}
|
||||
|
||||
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
|
||||
|
||||
return () => {
|
||||
didUnsubscribe = true
|
||||
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
|
||||
}
|
||||
}, [target.current, resizeObserver, storedCallback])
|
||||
|
||||
return resizeObserver?.observer
|
||||
}
|
||||
|
||||
function createResizeObserver() {
|
||||
let ticking = false
|
||||
let allEntries: ResizeObserverEntry[] = []
|
||||
|
||||
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(
|
||||
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
|
||||
allEntries = allEntries.concat(entries)
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const triggered = new Set<Element>()
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
if (triggered.has(allEntries[i].target)) continue
|
||||
triggered.add(allEntries[i].target)
|
||||
const cbs = callbacks.get(allEntries[i].target)
|
||||
cbs?.forEach((cb) => cb(allEntries[i], obs))
|
||||
}
|
||||
allEntries = []
|
||||
ticking = false
|
||||
})
|
||||
}
|
||||
ticking = true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
observer,
|
||||
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||
observer.observe(target)
|
||||
const cbs = callbacks.get(target) ?? []
|
||||
cbs.push(callback)
|
||||
callbacks.set(target, cbs)
|
||||
},
|
||||
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||
const cbs = callbacks.get(target) ?? []
|
||||
if (cbs.length === 1) {
|
||||
observer.unobserve(target)
|
||||
callbacks.delete(target)
|
||||
return
|
||||
}
|
||||
const cbIndex = cbs.indexOf(callback)
|
||||
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
|
||||
callbacks.set(target, cbs)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _resizeObserver: ReturnType<typeof createResizeObserver>
|
||||
|
||||
const getResizeObserver = () =>
|
||||
!_resizeObserver
|
||||
? (_resizeObserver = createResizeObserver())
|
||||
: _resizeObserver
|
||||
|
||||
export type UseResizeObserverCallback = (
|
||||
entry: ResizeObserverEntry,
|
||||
observer: ResizeObserver
|
||||
) => unknown
|
||||
|
||||
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
|
||||
const [size, setSize] = React.useState({ width: 0, height: 0 })
|
||||
React.useLayoutEffect(() => {
|
||||
if (target.current) {
|
||||
const { width, height } = target.current.getBoundingClientRect()
|
||||
setSize({ width, height })
|
||||
}
|
||||
}, [target.current])
|
||||
|
||||
const resizeCallback = React.useCallback(
|
||||
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
|
||||
[]
|
||||
)
|
||||
// Where the magic happens
|
||||
useResizeObserver(target, resizeCallback)
|
||||
return size
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { ParticipantsToggle } from '../../components/controls/Participants/Parti
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { InfoToggle } from '../../components/controls/InfoToggle'
|
||||
import { AdminToggle } from '../../components/AdminToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||
import { Button } from '@/primitives'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSize from '@react-hook/size'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT = 1100
|
||||
|
||||
@@ -70,7 +70,7 @@ export const MoreOptions = ({
|
||||
}: {
|
||||
parentElement: RefObject<HTMLDivElement>
|
||||
}) => {
|
||||
const parentWidth = useSize(parentElement)[0]
|
||||
const { width: parentWidth } = useSize(parentElement)
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RoomEvent, Track } from 'livekit-client'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
CarouselLayout,
|
||||
ConnectionStateToast,
|
||||
FocusLayoutContainer,
|
||||
GridLayout,
|
||||
@@ -31,7 +32,6 @@ import { RecordingStateToast } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useLocation, useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
@@ -10,12 +9,10 @@ import {
|
||||
isRoomValid,
|
||||
normalizeRoomId,
|
||||
} from '@/features/rooms/utils/isRoomValid'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
@@ -48,10 +45,10 @@ export const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -62,10 +59,6 @@ export const Room = () => {
|
||||
initialRoomData={initialRoomData}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
userConfig={{
|
||||
...existingUserChoices,
|
||||
...userConfig,
|
||||
}}
|
||||
/>
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export const safeParseMetadata = (
|
||||
metadataStr: string | null | undefined
|
||||
): Record<string, unknown> => {
|
||||
if (!metadataStr) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataStr)
|
||||
|
||||
// Ensure the result is an object
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
console.warn('Metadata parsed to non-object value:', parsed)
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
console.error('Failed to parse metadata:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const useKeyboardShortcuts = () => {
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { RiArrowDropDownLine } from '@remixicon/react'
|
||||
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
const StyledButton = styled(Button, {
|
||||
base: {
|
||||
width: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.25,
|
||||
border: '1px solid',
|
||||
@@ -53,22 +55,40 @@ const StyledSelectValue = styled(SelectValue, {
|
||||
},
|
||||
})
|
||||
|
||||
const StyledIcon = styled('div', {
|
||||
base: {
|
||||
marginRight: '0.35rem',
|
||||
flexShrink: 0,
|
||||
},
|
||||
})
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
label,
|
||||
iconComponent,
|
||||
items,
|
||||
errors,
|
||||
...props
|
||||
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
|
||||
iconComponent?: RemixiconComponentType
|
||||
label: ReactNode
|
||||
items: Array<{ value: T; label: ReactNode }>
|
||||
errors?: ReactNode
|
||||
}) => {
|
||||
const IconComponent = iconComponent
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{label}
|
||||
<StyledButton>
|
||||
{!!IconComponent && (
|
||||
<StyledIcon>
|
||||
<IconComponent size={18} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<StyledSelectValue />
|
||||
<RiArrowDropDownLine aria-hidden="true" />
|
||||
<RiArrowDropDownLine
|
||||
aria-hidden="true"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
</StyledButton>
|
||||
<StyledPopover>
|
||||
<Box size="sm" type="popover" variant="control">
|
||||
|
||||
@@ -40,9 +40,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
|
||||
@@ -40,9 +40,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
@@ -52,6 +49,8 @@ backend:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
LIVEKIT_FORCE_WSS_PROTOCOL: True
|
||||
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||
|
||||
@@ -62,9 +62,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
{{- with .Values.livekit.keys }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.9
|
||||
version: 0.0.10
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| `ingress.path` | Path to use for the Ingress | `/` |
|
||||
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
|
||||
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
|
||||
| `ingress.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
|
||||
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
|
||||
@@ -30,6 +31,7 @@
|
||||
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
|
||||
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
|
||||
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
|
||||
| `ingressAdmin.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
|
||||
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
|
||||
@@ -163,6 +165,7 @@
|
||||
| `posthog.ingress.path` | URL path prefix for the ingress routes (e.g., /) | `/` |
|
||||
| `posthog.ingress.hosts` | Additional hostnames array to be included in the ingress | `[]` |
|
||||
| `posthog.ingress.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
|
||||
| `posthog.ingress.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `posthog.ingress.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
|
||||
| `posthog.ingress.customBackends` | Custom backend service configurations for the ingress | `[]` |
|
||||
| `posthog.ingress.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
|
||||
@@ -172,6 +175,7 @@
|
||||
| `posthog.ingressAssets.path` | URL path prefix for the ingress routes (e.g., /) | `/static` |
|
||||
| `posthog.ingressAssets.hosts` | Additional hostnames array to be included in the ingress | `[]` |
|
||||
| `posthog.ingressAssets.tls.enabled` | Enable or disable TLS/HTTPS for the ingress | `true` |
|
||||
| `posthog.ingressAssets.tls.secretName` | Secret name for TLS config | `nil` |
|
||||
| `posthog.ingressAssets.tls.additional` | Additional TLS configurations for extra hosts/certificates | `[]` |
|
||||
| `posthog.ingressAssets.customBackends` | Custom backend service configurations for the ingress | `[]` |
|
||||
| `posthog.ingressAssets.annotations` | Additional Kubernetes annotations to apply to the ingress | `{}` |
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingress.host }}
|
||||
- secretName: {{ $fullName }}-tls
|
||||
- secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingress.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -30,6 +30,7 @@ spec:
|
||||
tls:
|
||||
{{- if .Values.ingressAdmin.host }}
|
||||
- secretName: {{ $fullName }}-tls
|
||||
- secretName: {{ .Values.ingressAdmin.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressAdmin.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.posthog.ingress.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.posthog.ingress.host }}
|
||||
- secretName: {{ $fullName }}-posthog-tls
|
||||
- secretName: {{ .Values.posthog.ingress.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.posthog.ingress.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -29,7 +29,7 @@ spec:
|
||||
{{- if .Values.posthog.ingressAssets.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.posthog.ingressAssets.host }}
|
||||
- secretName: {{ $fullName }}-posthog-tls
|
||||
- secretName: {{ .Values.posthog.ingressAssets.tls.secretName | default (printf "%s-posthog-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.posthog.ingressAssets.host | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -38,10 +38,12 @@ ingress:
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
## @param ingress.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingress.tls.secretName Secret name for TLS config
|
||||
## @skip ingress.tls.additional
|
||||
## @extra ingress.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
@@ -62,10 +64,12 @@ ingressAdmin:
|
||||
hosts: [ ]
|
||||
# - chart-example.local
|
||||
## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressAdmin.tls.secretName Secret name for TLS config
|
||||
## @skip ingressAdmin.tls.additional
|
||||
## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
@@ -87,8 +91,8 @@ ingressMedia:
|
||||
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
|
||||
@@ -358,6 +362,7 @@ posthog:
|
||||
## @param posthog.ingress.path URL path prefix for the ingress routes (e.g., /)
|
||||
## @param posthog.ingress.hosts Additional hostnames array to be included in the ingress
|
||||
## @param posthog.ingress.tls.enabled Enable or disable TLS/HTTPS for the ingress
|
||||
## @param posthog.ingress.tls.secretName Secret name for TLS config
|
||||
## @param posthog.ingress.tls.additional Additional TLS configurations for extra hosts/certificates
|
||||
## @param posthog.ingress.customBackends Custom backend service configurations for the ingress
|
||||
## @param posthog.ingress.annotations Additional Kubernetes annotations to apply to the ingress
|
||||
@@ -368,6 +373,7 @@ posthog:
|
||||
path: /
|
||||
hosts: [ ]
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: [ ]
|
||||
|
||||
@@ -380,6 +386,7 @@ posthog:
|
||||
## @param posthog.ingressAssets.path URL path prefix for the ingress routes (e.g., /)
|
||||
## @param posthog.ingressAssets.hosts Additional hostnames array to be included in the ingress
|
||||
## @param posthog.ingressAssets.tls.enabled Enable or disable TLS/HTTPS for the ingress
|
||||
## @param posthog.ingressAssets.tls.secretName Secret name for TLS config
|
||||
## @param posthog.ingressAssets.tls.additional Additional TLS configurations for extra hosts/certificates
|
||||
## @param posthog.ingressAssets.customBackends Custom backend service configurations for the ingress
|
||||
## @param posthog.ingressAssets.annotations Additional Kubernetes annotations to apply to the ingress
|
||||
@@ -390,6 +397,7 @@ posthog:
|
||||
path: /static
|
||||
hosts: [ ]
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: [ ]
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "0.1.30",
|
||||
"version": "0.1.33",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "0.1.30"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user