mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-04 14:45:40 +00:00
♻️(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:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user