Compare commits

...

11 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
Cyril 839a8f1c71 💄(frontend) add consistent spacing to picture-in-picture tiles
Add padding to StageFrame and bigger gaps so PiP tiles aren't flush with window.
2026-07-10 08:45:53 +02:00
Cyril 40ef420cda (frontend) use focus layout for solo PiP screen sharing
Solo cam in screen share PiP is now a BR thumbnail over shared screen
2026-07-10 08:45:51 +02:00
Cyril d995bd9041 (frontend) prioritize screen share in picture-in-picture layout
Show screen share large with cameras in a top row; match main room focus.
2026-07-10 08:45:51 +02:00
lebaudantoine 8ac4c2409d 💚(mail) update the node version for the mail building step
Broken with the dependencies update.
2026-07-09 16:22:54 +02:00
lebaudantoine e47ad42656 ⬆️(mail) update mjml to v5 and @html-to/text-cli
@html-to/text-cli was containing CVEs reported by the audit.
2026-07-09 16:22:54 +02:00
Florent Chehab 2509452c52 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
Add a deprecation warning when SUMMARY_SERVICE_VERSION=1 and
an endpoint is configured meaning summary service is in use.
2026-07-09 16:14:23 +02:00
Florent Chehab 1022780027 📝(upgrade) add note related to summary v1 removal
Add upgrade notes related to the summary changes in v1.23.0
2026-07-09 15:49:53 +02:00
25 changed files with 2479 additions and 1039 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: "18"
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@v5
+15
View File
@@ -8,6 +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
@@ -50,6 +64,7 @@ and this project adheres to
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
### Changed
+12
View File
@@ -15,3 +15,15 @@ the following command inside your docker container:
(Note : in your development environment, you can `make migrate`.)
## [Unreleased]
## v1.23.0
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
+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},
},
)
+15
View File
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines
import json
import warnings
from os import path
from socket import gethostbyname, gethostname
@@ -1128,6 +1129,20 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
)
if (
cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None
):
warnings.warn(
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
"The legacy v1 API has been removed from the experimental "
"Summary service. Please update your Summary service deployment to "
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
# We use UserWarning to make sure it shows up in production deployment
UserWarning,
stacklevel=2,
)
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
@@ -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 }
}
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} />
<ParticipantTile trackRef={track} disableTileControls />
</GridCell>
))}
</GridContainer>
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
gap: '0.5rem',
boxSizing: 'border-box',
},
})
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
@@ -0,0 +1,97 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
cameraTracks: TrackReferenceOrPlaceholder[]
}
/**
* Layout when a screen share is active.
* Camera tiles are shown as a compact row at the top; the screen share occupies
* the remaining space below, much larger than the camera tiles.
*/
export const PipScreenShareLayout = memo(
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
return (
<LayoutContainer>
{cameraTracks.length > 0 && (
<CameraTilesRow>
{cameraTracks.map((track) => (
<CameraTile key={getTrackKey(track)}>
<ParticipantTile trackRef={track} disableTileControls />
</CameraTile>
))}
</CameraTilesRow>
)}
<ScreenShareSlot>
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
</ScreenShareSlot>
</LayoutContainer>
)
}
)
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
const LayoutContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
boxSizing: 'border-box',
overflow: 'hidden',
},
})
const CameraTilesRow = styled('div', {
base: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
gap: '0.5rem',
flexShrink: 0,
height: '22%',
minHeight: '60px',
maxHeight: '120px',
},
})
const CameraTile = styled('div', {
base: {
position: 'relative',
flex: '0 1 auto',
height: '100%',
aspectRatio: '16 / 9',
minWidth: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
const ScreenShareSlot = styled('div', {
base: {
position: 'relative',
flex: 1,
minHeight: 0,
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
@@ -1,10 +1,11 @@
import { useMemo } from 'react'
import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
import {
@@ -13,9 +14,12 @@ import {
} from '@livekit/components-core'
/**
* PipStage picks between two layouts based on track count:
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
* PipStage picks between three layouts:
* - Screen share mode (any screen share active):
* small camera tiles in a row at the top, large screen share below.
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
* + one thumbnail overlay.
*/
export const PipStage = () => {
const tracks = useTracks(
@@ -41,51 +45,55 @@ export const PipStage = () => {
[tracks]
)
// Grid mode order: screen share leads, then cameras (already ordered by
// active speaker via the `ActiveSpeakersChanged` update above).
const gridTracks = useMemo(
() =>
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
[screenShareTrack, cameraTracks]
)
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
// page visually stable (active/recent speakers stay put) via its internal
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (gridTracks.length > 2) {
return (
<StageWrapper>
// Screen share active
if (screenShareTrack) {
// Solo presenter: screen share fills the area, camera as small thumbnail
if (cameraTracks.length <= 1) {
return (
<StageFrame>
<PipGridLayout tracks={pagination.tracks} />
<PipFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
/>
</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
)
}
// Multiple cameras: camera row at top, screen share below
return (
<PaginatedStage pagination={paginatedCameraTracks}>
<PipScreenShareLayout
screenShareTrack={screenShareTrack}
cameraTracks={paginatedCameraTracks.tracks}
/>
</StageWrapper>
</PaginatedStage>
)
}
// 3+ camera tracks → adaptive grid
if (cameraTracks.length > 2) {
return (
<PaginatedStage pagination={pagination}>
<PipGridLayout tracks={pagination.tracks} />
</PaginatedStage>
)
}
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const mainTrack = remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
@@ -96,6 +104,31 @@ export const PipStage = () => {
)
}
type PaginationResult = {
totalPageCount: number
currentPage: number
nextPage: () => void
prevPage: () => void
}
const PaginatedStage = ({
pagination,
children,
}: {
pagination: PaginationResult
children: React.ReactNode
}) => (
<StageWrapper>
<StageFrame>{children}</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
const StageWrapper = styled('div', {
base: {
display: 'flex',
@@ -25,8 +25,8 @@ const Container = styled('div', {
flex: 1,
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
marginRight: '0.5rem',
padding: '0.5rem',
boxSizing: 'border-box',
borderRadius: '4px',
overflow: 'hidden',
},
@@ -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%',
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
interface ParticipantTileExtendedProps extends ParticipantTileProps {
disableMetadata?: boolean
disableTileControls?: boolean
}
export const ParticipantTile: (
@@ -66,6 +67,7 @@ export const ParticipantTile: (
onParticipantClick,
disableSpeakingIndicator,
disableMetadata,
disableTileControls,
...htmlProps
}: ParticipantTileExtendedProps,
ref
@@ -231,7 +233,7 @@ export const ParticipantTile: (
)}
</>
)}
{!disableMetadata && (
{!disableMetadata && !disableTileControls && (
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
@@ -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%)`
+1 -1
View File
@@ -6,4 +6,4 @@ DIR_MAILS="../backend/core/templates/mail/html/"
if [ ! -d "${DIR_MAILS}" ]; then
mkdir -p "${DIR_MAILS}";
fi
mjml mjml/*.mjml -o "${DIR_MAILS}";
mjml mjml/*.mjml -o "${DIR_MAILS}" --config.allowIncludes true;
+7 -2
View File
@@ -2,6 +2,11 @@
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<!--
We load django tags here so they appear in the body of the HTML output.
This ensures html-to-text also includes them in the plain text template.
-->
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section css-class="wrapper-logo">
<mj-column>
@@ -59,7 +64,7 @@
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
<mj-include path="./partial/footer.mjml" />
</mj-body>
</mjml>
+4 -9
View File
@@ -1,21 +1,16 @@
<mj-head>
<mj-title>{{ title }}</mj-title>
<mj-preview>
<!--
We load django tags here, in this way there are put within the body in html output
so the html-to-text command includes it within its output
-->
{% load i18n static extra_tags %}
{{ title }}
</mj-preview>
<mj-preview>{{ title }}</mj-preview>
<mj-font name="Roboto" href="https://fonts.bunny.net/css?family=roboto:400,700,900" />
<mj-attributes>
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
<mj-all
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
font-size="16px"
line-height="1.5em"
color="#3A3A3A"
/>
<mj-text font-family="Roboto, sans-serif" />
<mj-button font-family="Roboto, sans-serif" />
</mj-attributes>
<mj-style>
/* Reset */
+5
View File
@@ -2,6 +2,11 @@
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<!--
We load django tags here so they appear in the body of the HTML output.
This ensures html-to-text also includes them in the plain text template.
-->
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
+1902 -940
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,8 +4,8 @@
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
"@html-to/text-cli": "0.5.4",
"mjml": "4.18.0"
"@html-to/text-cli": "0.6.0",
"mjml": "5.4.0"
},
"private": true,
"scripts": {