Compare commits

...

4 Commits

Author SHA1 Message Date
Cyril b7d89600ea wip 2026-07-10 17:37:24 +02:00
lebaudantoine 10231b0333 🩹(backend) identify externally provisioned users to PostHog
In the external API, applications authenticate with a
client_id/secret and can provision users with only an email. In that
flow, users are not identified to PostHog, so the user DB id alone
is not enough to identify them in analytics afterwards.

The issue does not exist in the public API, where users are
authenticated and therefore already identified.

Inspired by @flochehab's approach in summary.
2026-07-10 10:47:04 +02:00
Cyril 9b9e6578ee (frontend) add participant color gradient when camera is off
use participant color radial gradient for video-off placeholder
2026-07-10 10:46:46 +02:00
lebaudantoine 0c81d29ee7 (backend) allow searching the recording admin table by owner email
Add the owner's email to the searchable fields of the recording
admin table. This makes it much more convenient for the support team
to look up recordings by user.
2026-07-10 10:15:57 +02:00
10 changed files with 343 additions and 41 deletions
+9
View File
@@ -8,11 +8,20 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(backend) allow searching the recording admin table by owner email
- ✨(frontend) add participant color gradient when camera is off #1490
### Changed
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
### Fixed
- 🩹(backend) identify externally provisioned users to PostHog
## [1.23.0] - 2026-07-08
### Added
+8 -1
View File
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
search_fields = [
"status",
"=id",
"worker_id",
"room__slug",
"=room__id",
"accesses__user__email",
]
list_display = (
"id",
"status",
@@ -215,5 +215,6 @@ class RoomViewSet(
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
)
@@ -0,0 +1,53 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { FocusLayout } from '@/features/rooms/livekit/components/FocusLayout'
import { SecondaryScreenShareStrip } from './SecondaryScreenShareStrip'
export interface FocusAreaProps {
focusTrack: TrackReferenceOrPlaceholder
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
}
/**
* Main focus track with an optional strip of secondary screen shares below.
*/
export function FocusArea({
focusTrack,
secondaryScreenShareTracks,
}: FocusAreaProps) {
return (
<FocusColumn>
<MainFocusSlot>
<FocusLayout trackRef={focusTrack} />
</MainFocusSlot>
<SecondaryScreenShareStrip tracks={secondaryScreenShareTracks} />
</FocusColumn>
)
}
const FocusColumn = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
},
})
const MainFocusSlot = styled('div', {
base: {
position: 'relative',
flex: 1,
minHeight: 0,
overflow: 'hidden',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -0,0 +1,66 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { memo } from 'react'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '@/features/pip/utils/pipTrackSelection'
export interface SecondaryScreenShareStripProps {
tracks: TrackReferenceOrPlaceholder[]
}
/**
* Compact horizontal strip for non-focused screen shares.
* Each tile keeps pin controls so users can switch focus to any share.
*/
export const SecondaryScreenShareStrip = memo(
({ tracks }: SecondaryScreenShareStripProps) => {
if (tracks.length === 0) return null
return (
<StripContainer>
{tracks.map((track) => (
<ShareTile key={getTrackKey(track)}>
<ParticipantTile trackRef={track} />
</ShareTile>
))}
</StripContainer>
)
}
)
SecondaryScreenShareStrip.displayName = 'SecondaryScreenShareStrip'
const StripContainer = styled('div', {
base: {
display: 'flex',
flexDirection: 'row',
gap: '0.5rem',
flexShrink: 0,
height: '120px',
minHeight: '80px',
maxHeight: '140px',
padding: '0.5rem 0 0',
overflowX: 'auto',
overflowY: 'hidden',
boxSizing: 'border-box',
},
})
const ShareTile = styled('div', {
base: {
position: 'relative',
flex: '0 0 auto',
height: '100%',
aspectRatio: '16 / 9',
minWidth: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -0,0 +1,84 @@
import {
isEqualTrackRef,
isTrackReference,
type TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
export function getSubscribedScreenShareTracks(
tracks: TrackReferenceOrPlaceholder[]
): TrackReferenceOrPlaceholder[] {
return tracks
.filter(isTrackReference)
.filter(
(track) =>
track.publication.source === Track.Source.ScreenShare &&
track.publication.isSubscribed
)
}
export function getNewScreenShareTracks(
screenShareTracks: TrackReferenceOrPlaceholder[],
knownTrackSids: Set<string>
): TrackReferenceOrPlaceholder[] {
return screenShareTracks.filter(
(track) =>
track.publication.trackSid &&
!knownTrackSids.has(track.publication.trackSid)
)
}
export function syncKnownScreenShareSids(
screenShareTracks: TrackReferenceOrPlaceholder[],
knownTrackSids: Set<string>
): void {
const currentSids = new Set(
screenShareTracks
.map((track) => track.publication.trackSid)
.filter((sid): sid is string => !!sid)
)
screenShareTracks.forEach((track) => {
if (track.publication.trackSid) {
knownTrackSids.add(track.publication.trackSid)
}
})
knownTrackSids.forEach((sid) => {
if (!currentSids.has(sid)) {
knownTrackSids.delete(sid)
}
})
}
export function getNewestScreenShareTrack(
screenShareTracks: TrackReferenceOrPlaceholder[]
): TrackReferenceOrPlaceholder | undefined {
if (screenShareTracks.length === 0) return undefined
return screenShareTracks[screenShareTracks.length - 1]
}
export function splitTracksForFocusLayout(
tracks: TrackReferenceOrPlaceholder[],
focusTrack: TrackReferenceOrPlaceholder | undefined,
screenShareTracks: TrackReferenceOrPlaceholder[]
): {
carouselTracks: TrackReferenceOrPlaceholder[]
secondaryScreenShareTracks: TrackReferenceOrPlaceholder[]
} {
const hasActiveScreenShares = screenShareTracks.length > 0
const secondaryScreenShareTracks = screenShareTracks.filter(
(track) => !focusTrack || !isEqualTrackRef(track, focusTrack)
)
const carouselTracks = tracks.filter((track) => {
if (focusTrack && isEqualTrackRef(track, focusTrack)) return false
if (hasActiveScreenShares && track.source === Track.Source.ScreenShare) {
return false
}
return true
})
return { carouselTracks, secondaryScreenShareTracks }
}
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react'
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
backgroundColor: 'primaryDark.100',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor),
[participantColor]
)
const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl)
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return (
<StyledParticipantPlaceHolder ref={placeholderEl}>
<StyledParticipantPlaceHolder
ref={placeholderEl}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<div
style={{
borderRadius: '50%',
@@ -1,21 +1,52 @@
import { useFocusToggle } from '@livekit/components-react'
import {
isTrackReferencePinned,
} from '@livekit/components-core'
import { useFocusToggle, useMaybeLayoutContext } from '@livekit/components-react'
import { type Participant, Track } from 'livekit-client'
import { useCallback } from 'react'
import type { MouseEvent } from 'react'
import Source = Track.Source
export const useFocusToggleParticipant = (participant: Participant) => {
const trackRef = {
participant: participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const getParticipantPinTrackRef = (participant: Participant) => {
const screenSharePublication = participant.getTrackPublication(
Source.ScreenShare
)
const source =
participant.isScreenShareEnabled && screenSharePublication
? Source.ScreenShare
: Source.Camera
const { mergedProps, inFocus } = useFocusToggle({
return {
participant,
publication: participant.getTrackPublication(source),
source,
}
}
export const useFocusToggleParticipant = (participant: Participant) => {
const layoutContext = useMaybeLayoutContext()
const trackRef = getParticipantPinTrackRef(participant)
const { mergedProps, inFocus: isFocusedTrack } = useFocusToggle({
trackRef,
props: {},
})
const inFocus =
isFocusedTrack ||
(!!layoutContext?.pin.state &&
[Source.Camera, Source.ScreenShare].some((source) => {
const ref = {
participant,
publication: participant.getTrackPublication(source),
source,
}
return (
!!ref.publication &&
isTrackReferencePinned(ref, layoutContext.pin.state)
)
}))
const toggle = useCallback(() => {
const syntheticEvent = {
preventDefault: () => {},
@@ -20,7 +20,6 @@ import {
import { useTranslation } from 'react-i18next'
import { ControlBar } from './ControlBar/ControlBar'
import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording'
@@ -39,8 +38,16 @@ import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { FocusArea } from '@/features/layout/components/FocusArea'
import { GridLayout } from '@/features/layout/components/GridLayout'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
import {
getNewScreenShareTracks,
getNewestScreenShareTrack,
getSubscribedScreenShareTracks,
splitTracksForFocusLayout,
syncKnownScreenShareSids,
} from '@/features/layout/utils/screenShareLayout'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
@@ -73,6 +80,7 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack =
useRef<TrackReferenceOrPlaceholder | null>(null)
const knownScreenShareTrackSids = useRef<Set<string>>(new Set())
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
@@ -114,14 +122,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useNoiseReduction()
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const screenShareTracks = getSubscribedScreenShareTracks(tracks)
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, focusTrack)
)
const { carouselTracks, secondaryScreenShareTracks } =
splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks)
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
@@ -181,31 +186,48 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: screenShareTracks[0],
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: screenShareTracks[0],
})
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
const newScreenShares = getNewScreenShareTracks(
screenShareTracks,
knownScreenShareTrackSids.current
)
syncKnownScreenShareSids(
screenShareTracks,
knownScreenShareTrackSids.current
)
if (newScreenShares.length > 0) {
const newestScreenShare = getNewestScreenShareTrack(newScreenShares)
if (newestScreenShare) {
log.debug('Auto set screen share focus:', {
newScreenShareTrack: newestScreenShare,
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: newestScreenShare,
})
lastAutoFocusedScreenShareTrack.current = newestScreenShare
}
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) =>
track.publication.trackSid ===
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
)
focusTrack?.source === Track.Source.ScreenShare &&
!screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack))
) {
log.debug('Auto clearing screen share focus.')
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
lastAutoFocusedScreenShareTrack.current = null
const newestRemaining = getNewestScreenShareTrack(screenShareTracks)
if (newestRemaining) {
log.debug('Auto switching to remaining screen share:', {
screenShareTrack: newestRemaining,
})
layoutContext.pin.dispatch?.({
msg: 'set_pin',
trackReference: newestRemaining,
})
lastAutoFocusedScreenShareTrack.current = newestRemaining
} else {
log.debug('Auto clearing screen share focus.')
layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
lastAutoFocusedScreenShareTrack.current = null
}
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) =>
@@ -281,7 +303,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
{focusTrack && (
<FocusArea
focusTrack={focusTrack}
secondaryScreenShareTracks={
secondaryScreenShareTracks
}
/>
)}
</FocusLayoutContainer>
</div>
)}
@@ -0,0 +1,12 @@
/**
* Builds a radial gradient from the participant's avatar color:
* brighter in the center (where the avatar sits), darker at the edges.
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
*/
export const getParticipantBackgroundGradient = (color: string): string =>
`radial-gradient(circle at 50% 45%,
color-mix(in oklch, ${color} 82%, white) 0%,
color-mix(in oklch, ${color} 90%, white) 8%,
${color} 35%,
color-mix(in oklch, ${color} 85%, black) 65%,
color-mix(in oklch, ${color} 65%, black) 100%)`