✨(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:
committed by
aleb_the_flash
parent
16929bcc83
commit
b962dddbf2
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NotificationType } from './NotificationType'
|
||||
|
||||
export interface NotificationPayload {
|
||||
type: NotificationType
|
||||
data?: {
|
||||
emoji?: string
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export enum NotificationType {
|
||||
ParticipantMuted = 'participantMuted',
|
||||
MessageReceived = 'messageReceived',
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
"lowerHand": {
|
||||
"auto": "",
|
||||
"dismiss": ""
|
||||
},
|
||||
"reaction": {
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@
|
||||
"closed": ""
|
||||
},
|
||||
"support": "",
|
||||
"moreOptions": ""
|
||||
"moreOptions": "",
|
||||
"reactions": {
|
||||
"button": "",
|
||||
"send": "",
|
||||
"you": ""
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"buttonLabel": "",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user