mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-06 08:57:46 +00:00
♻️(frontend) replace side panel context with valtio store
centralize refs/triggers in layout store to keep focus behavior
This commit is contained in:
@@ -189,12 +189,11 @@ const SidePanelContent = () => {
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
panelRef={panelRef}
|
||||
>
|
||||
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
|
||||
revisit if memory becomes a concern */}
|
||||
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
|
||||
{/* keepAlive stays only for Info to reduce memory footprint */}
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
</Panel>
|
||||
<Panel isOpen={isEffectsOpen} keepAlive={true}>
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
@@ -203,10 +202,10 @@ const SidePanelContent = () => {
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen} keepAlive={true}>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
<Panel isOpen={isInfoOpen} keepAlive={true}>
|
||||
<Panel isOpen={isInfoOpen} >
|
||||
<Info />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRef, ReactNode } from 'react'
|
||||
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
|
||||
|
||||
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
|
||||
participants: null,
|
||||
tools: null,
|
||||
info: null,
|
||||
admin: null,
|
||||
options: null,
|
||||
effects: null,
|
||||
cameraMenu: null,
|
||||
})
|
||||
|
||||
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
|
||||
triggersRef.current[key] = el
|
||||
}
|
||||
|
||||
const getTrigger = (key: SidePanelTriggerKey) => {
|
||||
return triggersRef.current[key] ?? null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type SidePanelTriggerKey =
|
||||
| 'participants'
|
||||
| 'tools'
|
||||
| 'info'
|
||||
| 'admin'
|
||||
| 'options'
|
||||
| 'effects'
|
||||
| 'cameraMenu'
|
||||
|
||||
export type SidePanelContextValue = {
|
||||
panelRef: React.RefObject<HTMLElement>
|
||||
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
|
||||
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
|
||||
}
|
||||
|
||||
export const SidePanelContext = createContext<SidePanelContextValue | null>(
|
||||
null
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
|
||||
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
@@ -19,6 +21,35 @@ export const useSidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const activePanelId = layoutSnap.activePanelId
|
||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
||||
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
|
||||
const prevPanelIdRef = useRef<PanelId | null>(activePanelId)
|
||||
|
||||
const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) {
|
||||
return layoutStore.sidePanelTriggers.options ?? activeEl
|
||||
}
|
||||
}
|
||||
const triggerKeyByPanel: Partial<Record<PanelId, SidePanelTriggerKey>> = {
|
||||
[PanelId.PARTICIPANTS]: 'participants',
|
||||
[PanelId.TOOLS]: 'tools',
|
||||
[PanelId.INFO]: 'info',
|
||||
[PanelId.ADMIN]: 'admin',
|
||||
[PanelId.EFFECTS]: 'effects',
|
||||
}
|
||||
const triggerKey = triggerKeyByPanel[panelId]
|
||||
return triggerKey
|
||||
? layoutStore.sidePanelTriggers[triggerKey] ?? activeEl
|
||||
: activeEl
|
||||
}
|
||||
|
||||
const storeLastTrigger = (panelId: PanelId) => {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
layoutStore.lastSidePanelTriggerRef.current = resolveTrigger(
|
||||
panelId,
|
||||
activeEl
|
||||
)
|
||||
}
|
||||
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
@@ -32,45 +63,92 @@ export const useSidePanel = () => {
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
const toggleAdmin = () => {
|
||||
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleParticipants = () => {
|
||||
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleTools = () => {
|
||||
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
|
||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
storeLastTrigger(PanelId.TOOLS)
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
storeLastTrigger(PanelId.TOOLS)
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = () => {
|
||||
lastInteractionRef.current = 'keyboard'
|
||||
}
|
||||
const handleMouseDown = () => {
|
||||
lastInteractionRef.current = 'mouse'
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('mousedown', handleMouseDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const wasOpen = prevPanelIdRef.current
|
||||
|
||||
if (wasOpen && !activePanelId) {
|
||||
const trigger = layoutStore.lastSidePanelTriggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
trigger.focus({ preventScroll: true })
|
||||
if (lastInteractionRef.current === 'keyboard') {
|
||||
trigger.setAttribute('data-restore-focus-visible', '')
|
||||
const handleBlur = () => {
|
||||
if (document.contains(trigger)) {
|
||||
trigger.removeAttribute('data-restore-focus-visible')
|
||||
}
|
||||
}
|
||||
trigger.addEventListener('blur', handleBlur, { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevPanelIdRef.current = activePanelId
|
||||
}, [activePanelId])
|
||||
|
||||
return {
|
||||
activePanelId,
|
||||
activeSubPanelId,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export const useSidePanelRef = () => {
|
||||
const context = useContext(SidePanelContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidePanelRef must be used within SidePanelProvider')
|
||||
}
|
||||
return context.panelRef
|
||||
return layoutStore.sidePanelRef
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useContext } from 'react'
|
||||
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
|
||||
|
||||
export const useSidePanelTriggers = () => {
|
||||
const context = useContext(SidePanelContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useSidePanelTriggers must be used within SidePanelProvider'
|
||||
)
|
||||
}
|
||||
return {
|
||||
setTrigger: context.setTrigger,
|
||||
getTrigger: context.getTrigger,
|
||||
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => {
|
||||
layoutStore.sidePanelTriggers[key] = el
|
||||
},
|
||||
getTrigger: (key: SidePanelTriggerKey) => {
|
||||
return layoutStore.sidePanelTriggers[key] ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
@@ -260,77 +259,74 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
>
|
||||
<SidePanelProvider>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ScreenShareErrorModal
|
||||
isOpen={isShareErrorVisible}
|
||||
onClose={() => setIsShareErrorVisible(false)}
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<div
|
||||
// todo - extract these magic values into constant
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapper>
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</SidePanelProvider>
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutWrapper>
|
||||
<Subtitles />
|
||||
<MainNotificationToast />
|
||||
</div>
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
console.error(e)
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() == 'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SidePanelTriggerKey =
|
||||
| 'participants'
|
||||
| 'tools'
|
||||
| 'info'
|
||||
| 'admin'
|
||||
| 'options'
|
||||
| 'effects'
|
||||
| 'cameraMenu'
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { createRef } from 'react'
|
||||
import { proxy, ref } from 'valtio'
|
||||
import {
|
||||
PanelId,
|
||||
SubPanelId,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes'
|
||||
import type { MutableRefObject, RefObject } from 'react'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
@@ -10,12 +13,32 @@ type State = {
|
||||
showSubtitles: boolean
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
sidePanelRef: RefObject<HTMLElement>
|
||||
sidePanelTriggers: Record<SidePanelTriggerKey, HTMLElement | null>
|
||||
lastSidePanelTriggerRef: MutableRefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
const sidePanelRef = ref(createRef<HTMLElement>())
|
||||
const lastSidePanelTriggerRef = ref({
|
||||
current: null,
|
||||
} as MutableRefObject<HTMLElement | null>)
|
||||
const sidePanelTriggers = ref<Record<SidePanelTriggerKey, HTMLElement | null>>({
|
||||
participants: null,
|
||||
tools: null,
|
||||
info: null,
|
||||
admin: null,
|
||||
options: null,
|
||||
effects: null,
|
||||
cameraMenu: null,
|
||||
})
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
showHeader: false,
|
||||
showFooter: false,
|
||||
showSubtitles: false,
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
sidePanelRef,
|
||||
sidePanelTriggers,
|
||||
lastSidePanelTriggerRef,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user