Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 3b074783ee ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
Case-insensitive user lookups during token issuance currently take
around 100 ms in production, accounting for roughly a third of the
total 300 ms token request trace.

Add an `UPPER(email)` index on the user table so these lookups can
use the index instead of scanning, cutting the dominant cost on
the token issuance path.
2026-10-07 18:55:55 +02:00
21 changed files with 131 additions and 486 deletions
-1
View File
@@ -75,7 +75,6 @@ db.sqlite3
# IDEs
.idea/
.vscode/
.cursor/
*.iml
.devcontainer
+1 -2
View File
@@ -10,12 +10,11 @@ and this project adheres to
### Added
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
### Changed
- ⚡️(backend) hash application secrets with SHA-256
- ⚡️(backend) reduce domain queries on the application token endpoint
### Fixed
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0025_application_client_secret_sha256'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
+5 -3
View File
@@ -246,6 +246,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null",
)
]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -903,12 +906,11 @@ class Application(BaseModel):
def can_delegate_email(self, email):
"""Check if this application can delegate the given email."""
allowed_domains = {d.domain for d in self.allowed_domains.all()}
if not allowed_domains:
if not self.allowed_domains.exists():
return True # No domain restrictions
domain = get_domain_from_email(email)
return bool(domain) and domain.lower() in allowed_domains
return self.allowed_domains.filter(domain__iexact=domain).exists()
class ApplicationDomain(BaseModel):
@@ -87,60 +87,6 @@ def test_api_applications_generate_token_success(settings):
}
@pytest.mark.parametrize(
"restricted", [True, False], ids=["restricted", "unrestricted"]
)
def test_api_applications_generate_token_success_query_count(
restricted, django_assert_num_queries
):
"""An existing user needs one query each for application, domains, and user."""
user = UserFactory(email="user@example.com")
plain_secret = "test-secret-123"
application = ApplicationFactory(client_secret=plain_secret)
if restricted:
ApplicationDomainFactory(application=application, domain="example.com")
client = APIClient()
with django_assert_num_queries(3):
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_invalid_credentials_query_count(
django_assert_num_queries,
):
"""An invalid secret must be rejected before querying domains or users."""
application = ApplicationFactory(client_secret="test-secret-123")
ApplicationDomainFactory(application=application, domain="example.com")
client = APIClient()
with django_assert_num_queries(1):
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": "user@example.com",
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
@@ -1,152 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
import { GridLayout } from '@/features/layout/components/GridLayout'
type OneToOneFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
mainTracks?: TrackReferenceOrPlaceholder[]
thumbnailTrack?: TrackReferenceOrPlaceholder
thumbnailMinimized?: boolean
disableTileControls?: boolean
/** Controls thumbnail dimensions – 'pip' for small PiP window, 'room' for the main viewport. */
context?: 'pip' | 'room'
}
/**
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
* with an optional thumbnail overlay at the bottom-right. When the user keeps
* their tile in the corner with more participants, remotes share the main area
* as a grid.
*
* Shared between PiP and the main room – pass `disableTileControls` in PiP
* where hover controls should be hidden.
*/
export const OneToOneFocusLayout = memo(
({
mainTrack,
mainTracks,
thumbnailTrack,
thumbnailMinimized = false,
disableTileControls,
context = 'room',
}: OneToOneFocusLayoutProps) => {
const resolvedMainTracks = mainTracks ?? (mainTrack ? [mainTrack] : [])
return (
<FocusContainer>
{resolvedMainTracks.length === 1 && (
<MainSlot letterbox>
<ParticipantTile
key={getTrackKey(resolvedMainTracks[0])}
trackRef={resolvedMainTracks[0]}
disableTileControls={disableTileControls}
/>
</MainSlot>
)}
{resolvedMainTracks.length > 1 && (
<MainSlot>
<GridLayout
tracks={resolvedMainTracks}
style={{ height: '100%', padding: 0 }}
>
<ParticipantTile disableTileControls={disableTileControls} />
</GridLayout>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail context={context} minimized={thumbnailMinimized}>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls={disableTileControls}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
minHeight: 0,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
variants: {
letterbox: {
true: {
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
},
},
})
const Thumbnail = styled(
'div',
cva({
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
variants: {
context: {
pip: {
width: '42%',
maxWidth: '220px',
minWidth: '140px',
},
room: {
width: '20%',
maxWidth: '320px',
minWidth: '180px',
},
},
minimized: {
true: {
width: '14%',
maxWidth: '180px',
minWidth: '128px',
},
},
},
defaultVariants: {
context: 'room',
},
})
)
@@ -12,12 +12,7 @@ import {
import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useMemo, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import {
shouldUseFocusLayout,
splitFocusTracks,
} from '@/features/layout/utils/selfTileLayout'
import { useEffect, useRef } from 'react'
export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack =
@@ -35,28 +30,12 @@ export const StageLayout = () => {
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare)
const { pinnedTrackRef, selfTileLayout, selfTileMinimized } =
useSnapshot(layoutStore)
const { pinnedTrackRef } = useSnapshot(layoutStore)
const carouselTracks = tracks.filter(
(track) => !isEqualTrackRef(track, pinnedTrackRef)
)
const cameraTracks = useMemo(
() => tracks.filter((t) => t.source === Track.Source.Camera),
[tracks]
)
const useFocusLayout =
!pinnedTrackRef &&
screenShareTracks.length === 0 &&
shouldUseFocusLayout(selfTileLayout, cameraTracks)
const { mainTracks, thumbnailTrack } = useMemo(
() => splitFocusTracks(cameraTracks),
[cameraTracks]
)
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
@@ -108,13 +87,7 @@ export const StageLayout = () => {
return (
<>
{useFocusLayout ? (
<OneToOneFocusLayout
mainTracks={mainTracks}
thumbnailTrack={thumbnailTrack}
thumbnailMinimized={selfTileLayout === 'corner' && selfTileMinimized}
/>
) : !pinnedTrackRef ? (
{!pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
@@ -1,32 +0,0 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import type { SelfTileLayout } from '@/stores/layout'
const remoteTracks = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
cameraTracks.filter((track) => !track.participant?.isLocal)
/**
* Focus layout (main area + optional local thumbnail) instead of the original grid.
* Pin and screen share are handled by the caller: they take the stage over this.
*/
export const shouldUseFocusLayout = (
layout: SelfTileLayout,
cameraTracks: TrackReferenceOrPlaceholder[]
): boolean => {
if (layout === 'classic') return false
if (layout === 'corner') return true
return cameraTracks.length <= 2
}
export const hasRemoteCamera = (cameraTracks: TrackReferenceOrPlaceholder[]) =>
remoteTracks(cameraTracks).length > 0
export const splitFocusTracks = (
cameraTracks: TrackReferenceOrPlaceholder[]
) => {
const local = cameraTracks.find((track) => track.participant?.isLocal)
const remotes = remoteTracks(cameraTracks)
return {
mainTracks: remotes.length > 0 ? remotes : local ? [local] : [],
thumbnailTrack: remotes.length > 0 ? local : undefined,
}
}
@@ -7,9 +7,6 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { FocusButton } from './FocusButton'
import { EffectsButton } from './EffectsButton'
import { MuteButton } from './MuteButton'
import { SelfTileMenu } from './SelfTileMenu'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
const MOUSE_IDLE_TIME = 3000
@@ -17,7 +14,6 @@ type FadeOverlayProps = {
children: ReactNode
hasKeyboardFocus: boolean
tileRef: React.RefObject<HTMLDivElement | null>
forceVisible?: boolean
}
// Pointer-events none so this overlay doesn't block the zoom surface below.
@@ -28,7 +24,6 @@ const FadeOverlay = ({
children,
hasKeyboardFocus,
tileRef,
forceVisible = false,
}: FadeOverlayProps) => {
const [active, setActive] = useState(false)
const idleTimerRef = useRef<number | null>(null)
@@ -68,7 +63,8 @@ const FadeOverlay = ({
}
}, [tileRef])
const isVisible = hasKeyboardFocus || active || forceVisible
const isVisible = hasKeyboardFocus || active
return (
<div
className={css({
@@ -103,18 +99,9 @@ export const ParticipantTileFocus = ({
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = participant.isLocal
const canMute = useCanMute(participant)
const [menuOpen, setMenuOpen] = useState(false)
const { pinnedTrackRef } = useSnapshot(layoutStore)
// A pin (screen shares auto-pin) replaces the corner layout with the carousel,
// where the self-tile actions have no effect.
const showSelfTileMenu = isLocal && !isScreenShare && !pinnedTrackRef
return (
<FadeOverlay
hasKeyboardFocus={hasKeyboardFocus}
tileRef={tileRef}
forceVisible={menuOpen}
>
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -140,7 +127,6 @@ export const ParticipantTileFocus = ({
)}
</>
)}
{showSelfTileMenu && <SelfTileMenu onOpenChange={setMenuOpen} />}
</HStack>
</div>
</FadeOverlay>
@@ -1,100 +0,0 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import {
RiContractLeftLine,
RiExpandRightLine,
RiMoreFill,
RiPictureInPictureExitLine,
RiPictureInPictureLine,
} from '@remixicon/react'
import { useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { Button, Menu } from '@/primitives'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { menuRecipe } from '@/primitives/menuRecipe'
import {
hideSelfTile,
layoutStore,
showSelfTile,
toggleSelfTileMinimized,
} from '@/stores/layout'
import {
hasRemoteCamera,
shouldUseFocusLayout,
} from '@/features/layout/utils/selfTileLayout'
type SelfTileMenuProps = {
onOpenChange?: (isOpen: boolean) => void
}
export const SelfTileMenu = ({ onOpenChange }: SelfTileMenuProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const announce = useScreenReaderAnnounce()
const { selfTileLayout, selfTileMinimized } = useSnapshot(layoutStore)
const cameraTracks = useTracks(
[{ source: Track.Source.Camera, withPlaceholder: true }],
{ updateOnlyOn: [], onlySubscribed: false }
)
if (!hasRemoteCamera(cameraTracks)) return null
const isInCorner = shouldUseFocusLayout(selfTileLayout, cameraTracks)
const isMinimized = selfTileLayout === 'corner' && selfTileMinimized
const itemClassName = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<Menu variant="dark" placement="top" onOpenChange={onOpenChange}>
<Button
size="sm"
variant="primaryTextDark"
square
aria-label={t('moreOptions')}
tooltip={t('moreOptions')}
>
<RiMoreFill />
</Button>
<RACMenu style={{ minWidth: '220px' }}>
{isInCorner && (
<MenuItem
className={itemClassName}
onAction={() => {
toggleSelfTileMinimized()
announce(
isMinimized
? t('announcements.expanded')
: t('announcements.reduced')
)
}}
>
{isMinimized ? (
<RiExpandRightLine size={20} />
) : (
<RiContractLeftLine size={20} />
)}
{isMinimized ? t('expand') : t('reduce')}
</MenuItem>
)}
<MenuItem
className={itemClassName}
onAction={() => {
if (isInCorner) {
hideSelfTile()
announce(t('announcements.hidden'))
return
}
showSelfTile()
announce(t('announcements.shown'))
}}
>
{isInCorner ? (
<RiPictureInPictureExitLine size={20} />
) : (
<RiPictureInPictureLine size={20} />
)}
{isInCorner ? t('hideTile') : t('showTile')}
</MenuItem>
</RACMenu>
</Menu>
)
}
@@ -0,0 +1,86 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
@@ -2,7 +2,7 @@ import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder
@@ -2,7 +2,7 @@ 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 { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout'
@@ -59,11 +59,9 @@ export const PipStage = () => {
if (cameraTracks.length <= 1) {
return (
<StageFrame>
<OneToOneFocusLayout
<PipFocusLayout
mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]}
disableTileControls
context="pip"
/>
</StageFrame>
)
@@ -101,12 +99,7 @@ export const PipStage = () => {
return (
<StageFrame>
<OneToOneFocusLayout
mainTrack={mainTrack}
thumbnailTrack={thumbnailTrack}
disableTileControls
context="pip"
/>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame>
)
}
+1 -12
View File
@@ -754,18 +754,7 @@
},
"effects": "Hintergründe und Effekte anwenden",
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild",
"moreOptions": "Weitere Optionen",
"reduce": "Verkleinern",
"expand": "Vergrößern",
"hideTile": "Diese Kachel entfernen",
"showTile": "Kachel anzeigen",
"announcements": {
"reduced": "Deine Kachel ist verkleinert.",
"expanded": "Deine Kachel ist vergrößert.",
"shown": "Deine Kachel wird unten rechts angezeigt.",
"hidden": "Das ursprüngliche Layout ist wiederhergestellt."
}
"fullScreen": "Vollbild"
},
"screenShareZoom": {
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
+1 -12
View File
@@ -754,18 +754,7 @@
},
"effects": "Apply backgrounds and effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen",
"moreOptions": "More options",
"reduce": "Minimize",
"expand": "Enlarge",
"hideTile": "Remove this tile",
"showTile": "Show tile",
"announcements": {
"reduced": "Your tile is minimized.",
"expanded": "Your tile is enlarged.",
"shown": "Your tile is shown at the bottom right.",
"hidden": "The original layout is restored."
}
"fullScreen": "Full screen"
},
"screenShareZoom": {
"toolbarLabel": "Screen share zoom controls",
+1 -12
View File
@@ -754,18 +754,7 @@
},
"effects": "Arrière-plans et effets",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran",
"moreOptions": "Plus d'options",
"reduce": "Réduire",
"expand": "Agrandir",
"hideTile": "Supprimer cette tuile",
"showTile": "Afficher la tuile",
"announcements": {
"reduced": "Votre tuile est réduite.",
"expanded": "Votre tuile est agrandie.",
"shown": "Votre tuile est affichée en bas à droite.",
"hidden": "La disposition d’origine est rétablie."
}
"fullScreen": "Plein écran"
},
"screenShareZoom": {
"toolbarLabel": "Contrôles de zoom du partage d'écran",
+1 -12
View File
@@ -754,18 +754,7 @@
},
"effects": "Pas visuele effecten toe",
"muteParticipant": "Demp {{name}}",
"fullScreen": "Volledig scherm",
"moreOptions": "Meer opties",
"reduce": "Verkleinen",
"expand": "Vergroten",
"hideTile": "Deze tegel verwijderen",
"showTile": "Tegel weergeven",
"announcements": {
"reduced": "Je tegel is verkleind.",
"expanded": "Je tegel is vergroot.",
"shown": "Je tegel wordt rechtsonder weergegeven.",
"hidden": "De oorspronkelijke indeling is hersteld."
}
"fullScreen": "Volledig scherm"
},
"screenShareZoom": {
"toolbarLabel": "Zoombediening voor schermdeling",
+1 -3
View File
@@ -10,16 +10,14 @@ export const Menu = ({
children,
variant = 'light',
placement,
onOpenChange,
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
placement?: 'bottom' | 'top' | 'left' | 'right'
onOpenChange?: (isOpen: boolean) => void
}) => {
const [trigger, menu] = children
return (
<MenuTrigger onOpenChange={onOpenChange}>
<MenuTrigger>
{trigger}
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
-32
View File
@@ -7,14 +7,6 @@ import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
type PinnedTrackRef = ReturnType<typeof ref<TrackReferenceOrPlaceholder>>
/**
* Where the local camera sits in the stage.
* - auto: corner thumbnail in 1-to-1, original grid otherwise
* - corner: keep the local tile bottom-right even with more participants
* - classic: original grid, including in 1-to-1
*/
export type SelfTileLayout = 'auto' | 'corner' | 'classic'
type State = {
showHeader: boolean
showFooter: boolean
@@ -23,8 +15,6 @@ type State = {
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
pinnedTrackRef?: PinnedTrackRef
selfTileLayout: SelfTileLayout
selfTileMinimized: boolean
}
export const layoutStore = proxy<State>({
@@ -35,8 +25,6 @@ export const layoutStore = proxy<State>({
activeSubPanelId: null,
showReactionsToolbar: false,
pinnedTrackRef: undefined,
selfTileLayout: 'auto',
selfTileMinimized: false,
})
export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
@@ -47,26 +35,6 @@ export const clearPinnedTrack = (): void => {
layoutStore.pinnedTrackRef = undefined
}
/** Also opts into the corner layout, so the size choice survives past 2 participants. */
export const toggleSelfTileMinimized = (): void => {
const isMinimized =
layoutStore.selfTileLayout === 'corner' && layoutStore.selfTileMinimized
layoutStore.selfTileLayout = 'corner'
layoutStore.selfTileMinimized = !isMinimized
}
/** Show the local tile as a bottom-right thumbnail and keep it past 2 participants. */
export const showSelfTile = (): void => {
layoutStore.selfTileLayout = 'corner'
layoutStore.selfTileMinimized = false
}
/** Leave the corner thumbnail and return to the original grid. */
export const hideSelfTile = (): void => {
layoutStore.selfTileLayout = 'classic'
layoutStore.selfTileMinimized = false
}
export const closeSidePanel = (): void => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null