Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine d8ce399b06 💡(frontend) warn in vite config on MediaPipe asset/track-processor drift
Add a build-time warning in the Vite config that fires when the
MediaPipe assets copied by `vite-plugin-static-copy` fall out of
sync with the version used by `@livekit/track-processors`.

This catches at build time the kind of drift that would otherwise
only surface at runtime as broken virtual backgrounds.
2026-08-20 16:05:58 +02:00
15 changed files with 97 additions and 164 deletions
-11
View File
@@ -8,10 +8,6 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- 📈(frontend) track errors when starting or stopping a recording
### Changed ### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint - ✨(backend) accept form-urlencoded on the user token endpoint
@@ -19,18 +15,11 @@ and this project adheres to
- ⬆️(frontend) upgrade i18next and react-i18next patch versions - ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1 - ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react - ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed ### Fixed
- 📝(docs) fix minor typos in comments and docstrings - 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0 - ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
## [1.27.0] - 2026-08-14 ## [1.27.0] - 2026-08-14
+1 -1
View File
@@ -180,7 +180,7 @@ services:
working_dir: /app working_dir: /app
node: node:
image: node:22 image: node:18
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria' import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react' import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import Source = Track.Source import Source = Track.Source
@@ -11,7 +11,6 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer' import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout' import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, { const ClickableToast = styled(RACButton, {
base: { base: {
@@ -31,18 +30,14 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = useMemo(
() => cameraTrack ?? { participant, source: Source.Camera },
[cameraTrack, participant]
)
if (!participant) return if (!participant) return
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast <ClickableToast
@@ -1,9 +1,11 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -13,18 +15,31 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<>
<Button <Button
isDisabled={isMuted} isDisabled={isMuted}
size={'sm'} size={'sm'}
variant={'primaryTextDark'} variant={'primaryTextDark'}
square square
onPress={() => openMuteDialog(participant)} onPress={() => setIsAlertOpen(true)}
tooltip={t('muteParticipant', { name })} tooltip={t('muteParticipant', { name })}
> >
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button> </Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -19,11 +19,13 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -32,6 +34,7 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -39,6 +42,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -48,6 +52,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<>
<Button <Button
square square
variant="greyscale" variant="greyscale"
@@ -57,8 +62,10 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
isDisabled={isMuted || !canMute} isDisabled={isMuted || !canMute}
onPress={async () => onPress={async () =>
!isMuted && isLocal(participant) !isMuted && isLocal(participant)
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false) ? await (participant as LocalParticipant)?.setMicrophoneEnabled(
: openMuteDialog(participant) false
)
: setIsAlertOpen(true)
} }
data-attr="participants-mute" data-attr="participants-mute"
> >
@@ -76,6 +83,15 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
/> />
)} )}
</Button> </Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,20 +1,17 @@
import { useStartRecording, useStopRecording } from '@/features/recording' import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording' import { recordingStore } from '@/stores/recording'
import { captureEvent } from '@/features/analytics/telemetry'
export const useMutateRecording = () => { export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } = const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({ useStartRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'start' recordingStore.isErrorDialogOpen = 'start'
captureEvent('error-starting-recording')
}, },
}) })
const { mutateAsync: stopRecording, isPending: isPendingToStop } = const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({ useStopRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'stop' recordingStore.isErrorDialogOpen = 'stop'
captureEvent('error-stopping-recording')
}, },
}) })
@@ -6,8 +6,6 @@ import {
usePersistentUserChoices, usePersistentUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason, DisconnectReason,
MediaDeviceFailure, MediaDeviceFailure,
Room, Room,
@@ -27,11 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
@@ -233,16 +227,6 @@ export const Conference = ({
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e) const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
}) })
@@ -1,28 +0,0 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
import { MuteAlertDialog } from './MuteAlertDialog'
export const MuteAlertDialogProvider = () => {
const { participant } = useSnapshot(muteDialogStore)
const { muteParticipant } = useMuteParticipant()
const lastNameRef = useRef('')
if (participant) {
lastNameRef.current = participant.name || participant.identity
}
return (
<MuteAlertDialog
isOpen={!!participant}
name={lastNameRef.current}
onClose={closeMuteDialog}
onSubmit={() => {
const target = muteDialogStore.participant
if (!target) return
muteParticipant(target).then(closeMuteDialog)
}}
/>
)
}
@@ -105,7 +105,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
return throw new Error(
'Virtual background is only supported for virtual background'
)
} }
const needsUpdate = const needsUpdate =
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
LIGHT = 10, LIGHT = 5,
NORMAL = 20, NORMAL = 10,
} }
const isSupported = BackgroundProcessorFactory.isSupported() const isSupported = BackgroundProcessorFactory.isSupported()
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton' import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton' import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions' import { MoreOptions } from './MoreOptions'
import { RefObject, useMemo, useState } from 'react' import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen' import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
@@ -21,14 +21,7 @@ export function DesktopControlBar({
onDeviceError, onDeviceError,
}: Readonly<ControlBarAuxProps>) { }: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const [controlBarElement, setControlBarElement] =
useState<HTMLDivElement | null>(null)
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
() => ({ current: controlBarElement }),
[controlBarElement]
)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({}) const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
@@ -52,7 +45,7 @@ export function DesktopControlBar({
return ( return (
<div <div
ref={setControlBarElement} ref={desktopControlBarEl}
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex', display: 'flex',
@@ -19,7 +19,6 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -145,7 +144,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals /> <ReactionPortals />
</div> </div>
</> </>
-18
View File
@@ -1,18 +0,0 @@
import { proxy, ref } from 'valtio'
import type { Participant } from 'livekit-client'
type State = {
participant: Participant | null
}
export const muteDialogStore = proxy<State>({
participant: null,
})
export const openMuteDialog = (participant: Participant) => {
muteDialogStore.participant = ref(participant)
}
export const closeMuteDialog = () => {
muteDialogStore.participant = null
}
+8 -18
View File
@@ -9,7 +9,7 @@
"version": "1.27.0", "version": "1.27.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.0",
"mjml": "5.4.0" "mjml": "5.4.0"
} }
}, },
@@ -52,14 +52,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@html-to/text-cli": { "node_modules/@html-to/text-cli": {
"version": "0.6.1", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==", "integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0", "@selderee/plugin-htmlparser2": "~0.12.0",
"aspargvs": "~0.7.0", "aspargvs": "~0.7.0",
"deepmerge-ts": "^8.0.1", "deepmerge-ts": "^7.1.5",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
"selderee": "~0.12.0" "selderee": "~0.12.0"
}, },
@@ -656,19 +656,9 @@
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "8.0.1", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.0",
"mjml": "5.4.0" "mjml": "5.4.0"
}, },
"private": true, "private": true,