(frontend) add PiP reactions toolbar and isolated state

PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
This commit is contained in:
Cyril
2026-04-09 13:26:00 +02:00
parent 284c1ccc8a
commit 40da9c0bf9
6 changed files with 290 additions and 10 deletions
@@ -7,8 +7,8 @@ import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveB
import { SubtitlesToggle } from '@/features/rooms/livekit/components/controls/SubtitlesToggle'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { PipReactionsToggle } from './PipReactionsToggle'
export type CollapsibleControl =
| 'hand'
@@ -94,7 +94,7 @@ export const PipControlBar = ({
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has('reactions') && <ReactionsToggle />}
{!hidden.has('reactions') && <PipReactionsToggle />}
{showScreenShare && !hidden.has('screenShare') && <ScreenShareToggle />}
{!hidden.has('subtitles') && <SubtitlesToggle />}
{!hidden.has('hand') && <HandToggle />}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { useSnapshot } from 'valtio'
import { pipLayoutStore } from '../stores/pipLayoutStore'
export const PipReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
const toggle = () => {
pipLayoutStore.showReactionsToolbar = !pipLayoutStore.showReactionsToolbar
}
return (
<ToggleButton
id="pip-reactions-toggle"
data-attr="pip-reactions-toggle"
square
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
>
<RiEmotionLine />
</ToggleButton>
)
}
@@ -0,0 +1,241 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useSnapshot } from 'valtio'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
import { Emoji } from '@/features/reactions/types'
const EMOJIS = Object.values(Emoji)
const EMOJI_SLOT_WIDTH = 40
const ARROW_SLOT_WIDTH = 32
const PILL_HORIZONTAL_PADDING = 12
export const PipReactionsToolbar = () => {
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const [availableWidth, setAvailableWidth] = useState(0)
const [pageStart, setPageStart] = useState(0)
useEffect(() => {
if (isOpen) {
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
const updateWidth = useCallback(() => {
const el = wrapperRef.current
if (!el) return
setAvailableWidth(el.getBoundingClientRect().width)
}, [])
useEffect(() => {
const el = wrapperRef.current
if (!el) return
updateWidth()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver
const observer = new RO(() => updateWidth())
observer.observe(el)
return () => observer.disconnect()
}, [updateWidth])
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight } = useMemo(() => {
const maxWithoutArrows = Math.max(
1,
Math.floor((availableWidth - PILL_HORIZONTAL_PADDING) / EMOJI_SLOT_WIDTH)
)
if (EMOJIS.length <= maxWithoutArrows) {
return {
visibleEmojis: EMOJIS,
hasOverflow: false,
canGoLeft: false,
canGoRight: false,
}
}
const visibleCount = Math.max(
1,
Math.floor(
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
EMOJI_SLOT_WIDTH
)
)
const clampedStart = Math.min(pageStart, EMOJIS.length - visibleCount)
return {
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
hasOverflow: true,
canGoLeft: clampedStart > 0,
canGoRight: clampedStart + visibleCount < EMOJIS.length,
}
}, [pageStart, availableWidth])
useEffect(() => {
if (!hasOverflow) {
setPageStart(0)
return
}
const visibleCount = Math.max(
1,
Math.floor(
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
EMOJI_SLOT_WIDTH
)
)
const maxStart = Math.max(0, EMOJIS.length - visibleCount)
if (pageStart > maxStart) {
setPageStart(maxStart)
}
}, [hasOverflow, pageStart, availableWidth])
const paginate = useCallback((direction: 'left' | 'right') => {
setPageStart((current) =>
direction === 'left' ? Math.max(0, current - 1) : current + 1
)
}, [])
return (
<ToolbarWrapper ref={wrapperRef} isOpen={isOpen}>
<ToolbarPill isVisible={isVisible}>
{hasOverflow && (
<ArrowSlot>
{canGoLeft && (
<ArrowButton
onClick={() => paginate('left')}
aria-label="Previous reactions"
>
<RiArrowLeftSLine size={16} />
</ArrowButton>
)}
</ArrowSlot>
)}
<EmojiRow>
{visibleEmojis.map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</EmojiRow>
{hasOverflow && (
<ArrowSlot>
{canGoRight && (
<ArrowButton
onClick={() => paginate('right')}
aria-label="Next reactions"
>
<RiArrowRightSLine size={16} />
</ArrowButton>
)}
</ArrowSlot>
)}
</ToolbarPill>
</ToolbarWrapper>
)
}
const ToolbarWrapper = styled('div', {
base: {
display: 'flex',
justifyContent: 'center',
overflow: 'hidden',
maxHeight: 0,
padding: '0 0.5rem',
transition:
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
},
variants: {
isOpen: {
true: {
maxHeight: '60px',
padding: '0.5rem 0.5rem 0.25rem',
},
},
},
})
const ToolbarPill = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
overflow: 'hidden',
width: 'fit-content',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
},
})
const EmojiRow = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
'& > *': {
flexShrink: 0,
},
},
})
const ArrowSlot = styled('div', {
base: {
width: '32px',
minWidth: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
})
const ArrowButton = ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
type="button"
className={css({
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '50%',
border: 'none',
backgroundColor: 'primaryDark.200',
color: 'white',
cursor: 'pointer',
opacity: 0.85,
_hover: {
opacity: 1,
backgroundColor: 'primaryDark.300',
},
})}
{...props}
>
{children}
</button>
)
@@ -10,6 +10,7 @@ import { ParticipantTile } from '@/features/rooms/livekit/components/Participant
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { PipControlBar } from './PipControlBar'
import { PipReactionsToolbar } from './PipReactionsToolbar'
const pickTrackForPip = (
tracks: TrackReferenceOrPlaceholder[]
@@ -72,7 +73,9 @@ export const PipView = () => {
<PipStage>
{hasMultipleTiles ? (
<>
{mainTrackRef && <ParticipantTile trackRef={mainTrackRef} disableMetadata />}
{mainTrackRef && (
<ParticipantTile trackRef={mainTrackRef} disableMetadata />
)}
{thumbnailTrackRef && (
<PipThumbnail>
<ParticipantTile trackRef={thumbnailTrackRef} disableMetadata />
@@ -83,7 +86,7 @@ export const PipView = () => {
<ParticipantTile trackRef={trackRef} disableMetadata />
)}
</PipStage>
{/* Compact control bar for PiP; extend here when adding more actions. */}
<PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
{/* Side panel (effects, settings, etc.) opens within PiP window. */}
<SidePanel store={pipLayoutStore} />
@@ -96,7 +99,7 @@ const PipContainer = styled('div', {
width: '100%',
height: '100%',
display: 'grid',
gridTemplateRows: 'minmax(0, 1fr) auto',
gridTemplateRows: 'minmax(0, 1fr) auto auto',
backgroundColor: 'primaryDark.50',
'& .lk-participant-tile': {
height: '100%',
@@ -119,6 +122,9 @@ const PipStage = styled('div', {
base: {
position: 'relative',
minHeight: 0,
margin: '0.5rem',
borderRadius: 'lg',
overflow: 'hidden',
},
})
@@ -18,7 +18,7 @@ import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { useAreSubtitlesAvailable } from '@/features/subtitle/hooks/useAreSubtitlesAvailable'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useSnapshot } from 'valtio'
import type { CollapsibleControl } from '../PipControlBar'
type PipOptionsMenuItemsProps = {
@@ -74,7 +74,10 @@ const OverflowItems = ({
})
const { areSubtitlesOpen, toggleSubtitles } = useSubtitles()
const areSubtitlesAvailable = useAreSubtitlesAvailable()
const { toggle: toggleReactions } = useReactionsToolbar()
const pipSnap = useSnapshot(pipLayoutStore)
const toggleReactions = () => {
pipLayoutStore.showReactionsToolbar = !pipSnap.showReactionsToolbar
}
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
@@ -107,9 +110,7 @@ const OverflowItems = ({
{overflowControls.has('hand') && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised
? t('controls.hand.lower')
: t('controls.hand.raise')}
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</>
@@ -4,6 +4,7 @@ import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
type PipLayoutState = {
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
showReactionsToolbar: boolean
}
/**
@@ -14,4 +15,5 @@ type PipLayoutState = {
export const pipLayoutStore = proxy<PipLayoutState>({
activePanelId: null,
activeSubPanelId: null,
showReactionsToolbar: false,
})