♻️(frontend) refactor reaction system to unify state and rendering

Use a single store, hook, and portal system to handle both local
and remote emoji reactions.

Improve code quality and reduce duplication through better
factorization of shared logic.
This commit is contained in:
lebaudantoine
2026-03-25 16:27:53 +01:00
parent 2424817523
commit 7c81947681
7 changed files with 140 additions and 141 deletions
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core' import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -10,17 +10,11 @@ import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification' import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { ToastProvider, toastQueue } from './components/ToastProvider' import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification' import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import {
Emoji,
Reaction,
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel' import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useReactions } from '@/features/rooms/livekit/hooks/useReactions'
import { Emoji } from '@/stores/reactions'
export const MainNotificationToast = () => { export const MainNotificationToast = () => {
const room = useRoomContext() const room = useRoomContext()
@@ -28,8 +22,7 @@ export const MainNotificationToast = () => {
const { t } = useTranslation('notifications') const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const [reactions, setReactions] = useState<Reaction[]>([]) const { appendReaction } = useReactions()
const instanceIdRef = useRef(0)
useEffect(() => { useEffect(() => {
const handleChatMessage = ( const handleChatMessage = (
@@ -62,21 +55,13 @@ export const MainNotificationToast = () => {
} }
}, [room, triggerNotificationSound, announce, t]) }, [room, triggerNotificationSound, announce, t])
const handleEmoji = (emoji: string, participant: Participant) => { const handleEmoji = useCallback(
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return (emoji: string, participant: Participant) => {
const id = instanceIdRef.current++ if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
setReactions((prev) => [ appendReaction(emoji as Emoji, participant)
...prev, },
{ [appendReaction]
id, )
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
useEffect(() => { useEffect(() => {
const handleDataReceived = ( const handleDataReceived = (
@@ -149,7 +134,7 @@ export const MainNotificationToast = () => {
return () => { return () => {
room.off(RoomEvent.DataReceived, handleDataReceived) room.off(RoomEvent.DataReceived, handleDataReceived)
} }
}, [room]) }, [room, handleEmoji])
useEffect(() => { useEffect(() => {
const showJoinNotification = (participant: Participant) => { const showJoinNotification = (participant: Participant) => {
@@ -252,7 +237,6 @@ export const MainNotificationToast = () => {
<Div position="absolute" bottom={0} right={5} zIndex={1000}> <Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider /> <ToastProvider />
<WaitingParticipantNotification /> <WaitingParticipantNotification />
<ReactionPortals reactions={reactions} />
</Div> </Div>
) )
} }
@@ -2,13 +2,9 @@ import { createPortal } from 'react-dom'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Text } from '@/primitives' import { Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { Reaction, reactionsStore } from '@/stores/reactions'
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
export const ANIMATION_DURATION = 3000 export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300 export const ANIMATION_DISTANCE = 300
@@ -79,7 +75,7 @@ export function FloatingReaction({
> >
<img <img
src={`/assets/reactions/${emoji}.png`} src={`/assets/reactions/${emoji}.png`}
alt={''} alt=""
className={css({ className={css({
height: '50px', height: '50px',
})} })}
@@ -116,14 +112,7 @@ export function FloatingReaction({
) )
} }
export function ReactionPortal({ export function ReactionPortal({ reaction }: { reaction: Reaction }) {
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, []) const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), []) const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal( return createPortal(
@@ -138,51 +127,27 @@ export function ReactionPortal({
})} })}
> >
<FloatingReaction <FloatingReaction
emoji={emoji} emoji={reaction.emoji}
speed={speed} speed={speed}
scale={scale} scale={scale}
name={participant?.isLocal ? t('you') : participant.name} name={reaction.participantName}
isLocal={participant?.isLocal} isLocal={reaction.isLocal}
/> />
</div>, </div>,
document.body document.body
) )
} }
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { export const ReactionPortals = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { reactions } = useSnapshot(reactionsStore)
const { announceReactions } = useSnapshot(accessibilityStore) const latestReaction = reactions.at(-1)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
const latestReaction = useAnnounceReaction(latestReaction)
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return ( return (
<> <>
{reactions.map((instance) => ( {reactions.map((instance) => (
<ReactionPortal <ReactionPortal key={instance.id} reaction={instance} />
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))} ))}
</> </>
) )
@@ -1,15 +1,9 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react' import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react' import { useState } from 'react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { ToggleButton, Button } from '@/primitives' import { ToggleButton, Button } from '@/primitives'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils' import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { import {
@@ -18,72 +12,20 @@ import {
DialogTrigger, DialogTrigger,
} from 'react-aria-components' } from 'react-aria-components'
import { FocusScope } from '@react-aria/focus' import { FocusScope } from '@react-aria/focus'
import { Participant } from 'livekit-client' import { useReactions } from '../../hooks/useReactions'
import useRateLimiter from '@/hooks/useRateLimiter' import { Emoji } from '@/stores/reactions.ts'
// eslint-disable-next-line react-refresh/only-export-components
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: number
emoji: string
participant: Participant
}
export const ReactionsToggle = () => { export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const room = useRoomContext()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const { sendReaction } = useReactions()
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'reaction', id: 'reaction',
handler: () => setIsOpen((prev) => !prev), handler: () => setIsOpen((prev) => !prev),
}) })
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ReactionReceived,
data: {
emoji: emoji,
},
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, { reliable: true })
const newReaction = {
id: instanceIdRef.current++,
emoji,
participant: room.localParticipant,
}
setReactions((prev) => [...prev, newReaction])
// Remove this reaction after animation
setTimeout(() => {
setReactions((prev) =>
prev.filter((instance) => instance.id !== newReaction.id)
)
}, ANIMATION_DURATION)
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
return ( return (
<> <>
<div className={css({ position: 'relative' })}> <div className={css({ position: 'relative' })}>
@@ -130,7 +72,7 @@ export const ReactionsToggle = () => {
{Object.values(Emoji).map((emoji, index) => ( {Object.values(Emoji).map((emoji, index) => (
<Button <Button
key={index} key={index}
onPress={() => debouncedSendReaction(emoji)} onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })} aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
variant="primaryTextDark" variant="primaryTextDark"
size="sm" size="sm"
@@ -155,7 +97,6 @@ export const ReactionsToggle = () => {
</RACPopover> </RACPopover>
</DialogTrigger> </DialogTrigger>
</div> </div>
<ReactionPortals reactions={reactions} />
</> </>
) )
} }
@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Reaction } from '@/stores/reactions'
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
const announce = useScreenReaderAnnounce()
useEffect(() => {
if (!announceReactions || !latestReaction) return
if (latestReaction.id === lastAnnouncedId) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participantName
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
}
@@ -0,0 +1,55 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Emoji, reactionsStore } from '@/stores/reactions'
import { NotificationType } from '@/features/notifications/NotificationType'
import { ANIMATION_DURATION } from '@/features/rooms/livekit/components/ReactionPortal'
import useRateLimiter from '@/hooks/useRateLimiter'
import { useNotifyParticipants } from '@/features/notifications'
import { Participant } from 'livekit-client'
export const useReactions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { notifyParticipants } = useNotifyParticipants()
const appendReaction = useCallback(
(emoji: Emoji, participant?: Participant) => {
const newReaction = {
id: `${emoji}-${Date.now()}-${Math.random()}`,
emoji,
participantName: participant
? participant.name || participant.identity
: t('you'),
isLocal: !participant,
}
reactionsStore.reactions.push(newReaction)
setTimeout(() => {
const index = reactionsStore.reactions.findIndex(
(r) => r.id === newReaction.id
)
if (index !== -1) reactionsStore.reactions.splice(index, 1)
}, ANIMATION_DURATION)
},
[t]
)
const sendReaction = async (emoji: Emoji) => {
appendReaction(emoji)
await notifyParticipants({
type: NotificationType.ReactionReceived,
additionalData: { data: { emoji } },
})
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
return {
sendReaction: debouncedSendReaction,
appendReaction,
}
}
@@ -44,6 +44,7 @@ import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName' import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { ReactionPortals } from '@/features/rooms/livekit/components/ReactionPortal'
const LayoutWrapper = styled( const LayoutWrapper = styled(
'div', 'div',
@@ -346,6 +347,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<ReactionPortals />
</div> </div>
) )
} }
+27
View File
@@ -0,0 +1,27 @@
import { proxy } from 'valtio'
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: string
emoji: Emoji
participantName: string
isLocal: boolean
}
type State = {
reactions: Reaction[]
}
export const reactionsStore = proxy<State>({
reactions: [],
})