From 7c81947681e6a6784de7f238364f30577571c17d Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 25 Mar 2026 16:27:53 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20refactor=20react?= =?UTF-8?q?ion=20system=20to=20unify=20state=20and=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../notifications/MainNotificationToast.tsx | 40 +++------- .../livekit/components/ReactionPortal.tsx | 59 +++------------ .../components/controls/ReactionsToggle.tsx | 73 ++----------------- .../livekit/hooks/useAnnounceReaction.ts | 25 +++++++ .../rooms/livekit/hooks/useReactions.ts | 55 ++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 + src/frontend/src/stores/reactions.ts | 27 +++++++ 7 files changed, 140 insertions(+), 141 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useAnnounceReaction.ts create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useReactions.ts create mode 100644 src/frontend/src/stores/reactions.ts diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index 758a1389..29f4a74f 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect } from 'react' import { useRoomContext } from '@livekit/components-react' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client' import { ChatMessage, isMobileBrowser } from '@livekit/components-core' @@ -10,17 +10,11 @@ import { decodeNotificationDataReceived } from './utils' import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification' import { ToastProvider, toastQueue } from './components/ToastProvider' 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 { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' +import { useReactions } from '@/features/rooms/livekit/hooks/useReactions' +import { Emoji } from '@/stores/reactions' export const MainNotificationToast = () => { const room = useRoomContext() @@ -28,8 +22,7 @@ export const MainNotificationToast = () => { const { t } = useTranslation('notifications') const announce = useScreenReaderAnnounce() - const [reactions, setReactions] = useState([]) - const instanceIdRef = useRef(0) + const { appendReaction } = useReactions() useEffect(() => { const handleChatMessage = ( @@ -62,21 +55,13 @@ export const MainNotificationToast = () => { } }, [room, triggerNotificationSound, announce, t]) - const handleEmoji = (emoji: string, participant: Participant) => { - if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return - const id = instanceIdRef.current++ - setReactions((prev) => [ - ...prev, - { - id, - emoji, - participant, - }, - ]) - setTimeout(() => { - setReactions((prev) => prev.filter((instance) => instance.id !== id)) - }, ANIMATION_DURATION) - } + const handleEmoji = useCallback( + (emoji: string, participant: Participant) => { + if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return + appendReaction(emoji as Emoji, participant) + }, + [appendReaction] + ) useEffect(() => { const handleDataReceived = ( @@ -149,7 +134,7 @@ export const MainNotificationToast = () => { return () => { room.off(RoomEvent.DataReceived, handleDataReceived) } - }, [room]) + }, [room, handleEmoji]) useEffect(() => { const showJoinNotification = (participant: Participant) => { @@ -252,7 +237,6 @@ export const MainNotificationToast = () => {
-
) } diff --git a/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx index 445b975c..3a2585aa 100644 --- a/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx @@ -2,13 +2,9 @@ import { createPortal } from 'react-dom' import { useState, useEffect, useMemo } from 'react' import { Text } from '@/primitives' 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 { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' +import { Reaction, reactionsStore } from '@/stores/reactions' +import { useAnnounceReaction } from '../hooks/useAnnounceReaction' export const ANIMATION_DURATION = 3000 export const ANIMATION_DISTANCE = 300 @@ -79,7 +75,7 @@ export function FloatingReaction({ > {''} Math.random() * 1.5 + 0.5, []) const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), []) return createPortal( @@ -138,51 +127,27 @@ export function ReactionPortal({ })} > , document.body ) } -export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => { - const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) - const { announceReactions } = useSnapshot(accessibilityStore) - const [lastAnnouncedId, setLastAnnouncedId] = useState(null) - const announce = useScreenReaderAnnounce() +export const ReactionPortals = () => { + const { reactions } = useSnapshot(reactionsStore) + const latestReaction = reactions.at(-1) - const 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]) + useAnnounceReaction(latestReaction) return ( <> {reactions.map((instance) => ( - + ))} ) diff --git a/src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx index 8b31f124..2d9bafce 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx @@ -1,15 +1,9 @@ import { useTranslation } from 'react-i18next' import { RiEmotionLine } from '@remixicon/react' -import { useState, useRef } from 'react' +import { useState } from 'react' import { css } from '@/styled-system/css' -import { useRoomContext } from '@livekit/components-react' 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 { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { @@ -18,72 +12,20 @@ import { DialogTrigger, } from 'react-aria-components' import { FocusScope } from '@react-aria/focus' -import { Participant } from 'livekit-client' -import useRateLimiter from '@/hooks/useRateLimiter' - -// 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 -} +import { useReactions } from '../../hooks/useReactions' +import { Emoji } from '@/stores/reactions.ts' export const ReactionsToggle = () => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) - const [reactions, setReactions] = useState([]) - const instanceIdRef = useRef(0) - const room = useRoomContext() - const [isOpen, setIsOpen] = useState(false) + const { sendReaction } = useReactions() + useRegisterKeyboardShortcut({ id: 'reaction', 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 ( <>
@@ -130,7 +72,7 @@ export const ReactionsToggle = () => { {Object.values(Emoji).map((emoji, index) => (
- ) } diff --git a/src/frontend/src/features/rooms/livekit/hooks/useAnnounceReaction.ts b/src/frontend/src/features/rooms/livekit/hooks/useAnnounceReaction.ts new file mode 100644 index 00000000..eadacd45 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useAnnounceReaction.ts @@ -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(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]) +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useReactions.ts b/src/frontend/src/features/rooms/livekit/hooks/useReactions.ts new file mode 100644 index 00000000..7223da51 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useReactions.ts @@ -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, + } +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index f69727dc..7c6c2eaa 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -44,6 +44,7 @@ import { GridLayout } from '../components/layout/GridLayout' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { getParticipantName } from '@/features/rooms/utils/getParticipantName' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' +import { ReactionPortals } from '@/features/rooms/livekit/components/ReactionPortal' const LayoutWrapper = styled( 'div', @@ -346,6 +347,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { + ) } diff --git a/src/frontend/src/stores/reactions.ts b/src/frontend/src/stores/reactions.ts new file mode 100644 index 00000000..67309eef --- /dev/null +++ b/src/frontend/src/stores/reactions.ts @@ -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({ + reactions: [], +})