(frontend) add emoji reactions feature

Implement a new reactions system allowing users
to send quick emoji responses during video calls.
This feature is present in most visio softwares.

Particulary this commit:
- Add ReactionsButton to control bar with emoji selection
- Support floating emoji animations
This commit is contained in:
Arnaud Robin
2025-02-19 00:39:19 +01:00
committed by aleb_the_flash
parent 16929bcc83
commit b962dddbf2
14 changed files with 312 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
@@ -7,11 +7,19 @@ import { NotificationDuration } from './NotificationDuration'
import { Div } from '@/primitives'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
useEffect(() => {
const handleChatMessage = (
chatMessage: ChatMessage,
@@ -34,13 +42,31 @@ export const MainNotificationToast = () => {
}
}, [room, triggerNotificationSound])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji) return
const id = instanceIdRef.current++
setReactions((prev) => [
...prev,
{
id,
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const decoder = new TextDecoder()
const notificationType = decoder.decode(payload)
const notificationPayload = JSON.parse(decoder.decode(payload))
const notificationType = notificationPayload.type
const data = notificationPayload.data
if (!participant) return
@@ -54,6 +80,9 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.ReactionReceived:
handleEmoji(data?.emoji, participant)
break
default:
return
}
@@ -160,6 +189,7 @@ export const MainNotificationToast = () => {
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<ReactionPortals reactions={reactions} />
</Div>
)
}

View File

@@ -11,4 +11,5 @@ export const NotificationDuration = {
PARTICIPANT_JOINED: ToastDuration.LONG,
HAND_RAISED: ToastDuration.LONG,
LOWER_HAND: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
} as const

View File

@@ -0,0 +1,8 @@
import { NotificationType } from './NotificationType'
export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
}
}

View File

@@ -4,4 +4,5 @@ export enum NotificationType {
ParticipantMuted = 'participantMuted',
MessageReceived = 'messageReceived',
LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived',
}

View File

@@ -5,6 +5,7 @@ import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
export const useMuteParticipant = () => {
const data = useRoomData()
@@ -12,7 +13,10 @@ export const useMuteParticipant = () => {
const notifyParticipant = async (participant: Participant) => {
const encoder = new TextEncoder()
const data = encoder.encode(NotificationType.ParticipantMuted)
const payload: NotificationPayload = {
type: NotificationType.ParticipantMuted,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [participant.identity],

View File

@@ -0,0 +1,143 @@
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'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
interface FloatingReactionProps {
emoji: string
name?: string
speed?: number
scale?: number
}
export function FloatingReaction({
emoji,
name,
speed = 1,
scale = 1,
}: FloatingReactionProps) {
const [deltaY, setDeltaY] = useState(0)
const [opacity, setOpacity] = useState(1)
const left = useMemo(
() => Math.random() * window.innerWidth * REACTION_SPAWN_WIDTH_RATIO,
[]
)
useEffect(() => {
let start: number | null = null
function animate(timestamp: number) {
if (start === null) start = timestamp
const elapsed = timestamp - start
if (elapsed < 0) {
setOpacity(0)
} else {
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
const distance = ANIMATION_DISTANCE * speed
const newY = progress * distance
setDeltaY(newY)
if (progress > FADE_OUT_THRESHOLD) {
setOpacity(1 - (progress - FADE_OUT_THRESHOLD) / 0.3)
}
}
if (elapsed < ANIMATION_DURATION) {
requestAnimationFrame(animate)
}
}
const req = requestAnimationFrame(animate)
return () => cancelAnimationFrame(req)
}, [speed])
return (
<div
className={css({
fontSize: '3rem',
position: 'absolute',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
})}
style={{
left: left,
bottom: INITIAL_POSITION + deltaY,
transform: `scale(${scale})`,
opacity: opacity,
}}
>
<span
className={css({
lineHeight: '45px',
})}
>
{emoji}
</span>
{name && (
<Text
variant="sm"
className={css({
backgroundColor: 'primaryDark.100',
color: 'white',
textAlign: 'center',
borderRadius: '20px',
paddingX: '0.5rem',
paddingY: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '14px',
})}
>
{name}
</Text>
)}
</div>
)
}
export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal(
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
<FloatingReaction
emoji={emoji}
speed={speed}
scale={scale}
name={participant?.isLocal ? t('you') : participant.name}
/>
</div>,
document.body
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))

View File

@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { Menu, 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 { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
export interface Reaction {
id: number
emoji: string
participant: Participant
}
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const room = useRoomContext()
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)
}
return (
<>
<Menu variant="dark" placement="top">
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
>
<RiEmotionLine />
</ToggleButton>
<RACToolbar
className={css({
display: 'flex',
})}
>
{EMOJIS.map((emoji) => (
<Button
onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="quaternaryText"
size="sm"
>
<span
className={css({
fontSize: '20px',
})}
>
{emoji}
</span>
</Button>
))}
</RACToolbar>
</Menu>
<ReactionPortals reactions={reactions} />
</>
)
}

View File

@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
@@ -75,6 +76,7 @@ export function DesktopControlBar({
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>

View File

@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "",
"dismiss": ""
},
"reaction": {
"description": ""
}
}

View File

@@ -86,7 +86,12 @@
"closed": ""
},
"support": "",
"moreOptions": ""
"moreOptions": "",
"reactions": {
"button": "",
"send": "",
"you": ""
}
},
"options": {
"buttonLabel": "",

View File

@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
}
}

View File

@@ -85,7 +85,12 @@
"closed": "Show AI assistant"
},
"support": "Support",
"moreOptions": "More options"
"moreOptions": "More options",
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"you": "you"
}
},
"options": {
"buttonLabel": "More Options",

View File

@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
"dismiss": "Laisser la main levée"
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
}
}

View File

@@ -85,7 +85,12 @@
"closed": "Afficher l'assistant IA"
},
"support": "Support",
"moreOptions": "Plus d'options"
"moreOptions": "Plus d'options",
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"you": "vous"
}
},
"options": {
"buttonLabel": "Plus d'options",