✨(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 { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
@@ -7,11 +7,19 @@ import { NotificationDuration } from './NotificationDuration'
|
|||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
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 = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { triggerNotificationSound } = useNotificationSound()
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
|
|
||||||
|
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||||
|
const instanceIdRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChatMessage = (
|
const handleChatMessage = (
|
||||||
chatMessage: ChatMessage,
|
chatMessage: ChatMessage,
|
||||||
@@ -34,13 +42,31 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleDataReceived = (
|
const handleDataReceived = (
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
participant?: RemoteParticipant
|
participant?: RemoteParticipant
|
||||||
) => {
|
) => {
|
||||||
const decoder = new TextDecoder()
|
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
|
if (!participant) return
|
||||||
|
|
||||||
@@ -54,6 +80,9 @@ export const MainNotificationToast = () => {
|
|||||||
{ timeout: NotificationDuration.ALERT }
|
{ timeout: NotificationDuration.ALERT }
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
case NotificationType.ReactionReceived:
|
||||||
|
handleEmoji(data?.emoji, participant)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,6 +189,7 @@ export const MainNotificationToast = () => {
|
|||||||
return (
|
return (
|
||||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
|
<ReactionPortals reactions={reactions} />
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export const NotificationDuration = {
|
|||||||
PARTICIPANT_JOINED: ToastDuration.LONG,
|
PARTICIPANT_JOINED: ToastDuration.LONG,
|
||||||
HAND_RAISED: ToastDuration.LONG,
|
HAND_RAISED: ToastDuration.LONG,
|
||||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||||
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
} as const
|
} 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',
|
ParticipantMuted = 'participantMuted',
|
||||||
MessageReceived = 'messageReceived',
|
MessageReceived = 'messageReceived',
|
||||||
LowerHand = 'lowerHand',
|
LowerHand = 'lowerHand',
|
||||||
|
ReactionReceived = 'reactionReceived',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { buildServerApiUrl } from './buildServerApiUrl'
|
|||||||
import { useRoomData } from '../hooks/useRoomData'
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -12,7 +13,10 @@ export const useMuteParticipant = () => {
|
|||||||
|
|
||||||
const notifyParticipant = async (participant: Participant) => {
|
const notifyParticipant = async (participant: Participant) => {
|
||||||
const encoder = new TextEncoder()
|
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, {
|
await room.localParticipant.publishData(data, {
|
||||||
reliable: true,
|
reliable: true,
|
||||||
destinationIdentities: [participant.identity],
|
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 { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||||
import { HandToggle } from '../../components/controls/HandToggle'
|
import { HandToggle } from '../../components/controls/HandToggle'
|
||||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||||
@@ -75,6 +76,7 @@ export function DesktopControlBar({
|
|||||||
}
|
}
|
||||||
menuVariant="dark"
|
menuVariant="dark"
|
||||||
/>
|
/>
|
||||||
|
<ReactionsToggle />
|
||||||
{browserSupportsScreenSharing && (
|
{browserSupportsScreenSharing && (
|
||||||
<ScreenShareToggle
|
<ScreenShareToggle
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
"lowerHand": {
|
"lowerHand": {
|
||||||
"auto": "",
|
"auto": "",
|
||||||
"dismiss": ""
|
"dismiss": ""
|
||||||
|
},
|
||||||
|
"reaction": {
|
||||||
|
"description": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,12 @@
|
|||||||
"closed": ""
|
"closed": ""
|
||||||
},
|
},
|
||||||
"support": "",
|
"support": "",
|
||||||
"moreOptions": ""
|
"moreOptions": "",
|
||||||
|
"reactions": {
|
||||||
|
"button": "",
|
||||||
|
"send": "",
|
||||||
|
"you": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"buttonLabel": "",
|
"buttonLabel": "",
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
"lowerHand": {
|
"lowerHand": {
|
||||||
"auto": "It seems you have started speaking, so your hand will be lowered.",
|
"auto": "It seems you have started speaking, so your hand will be lowered.",
|
||||||
"dismiss": "Keep hand raised"
|
"dismiss": "Keep hand raised"
|
||||||
|
},
|
||||||
|
"reaction": {
|
||||||
|
"description": "{{name}} reacted with {{emoji}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,12 @@
|
|||||||
"closed": "Show AI assistant"
|
"closed": "Show AI assistant"
|
||||||
},
|
},
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
"moreOptions": "More options"
|
"moreOptions": "More options",
|
||||||
|
"reactions": {
|
||||||
|
"button": "Send reaction",
|
||||||
|
"send": "Send reaction {{emoji}}",
|
||||||
|
"you": "you"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"buttonLabel": "More Options",
|
"buttonLabel": "More Options",
|
||||||
|
|||||||
@@ -12,5 +12,8 @@
|
|||||||
"lowerHand": {
|
"lowerHand": {
|
||||||
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
|
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
|
||||||
"dismiss": "Laisser la main levée"
|
"dismiss": "Laisser la main levée"
|
||||||
|
},
|
||||||
|
"reaction": {
|
||||||
|
"description": "{{name}} a reagi avec {{emoji}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,12 @@
|
|||||||
"closed": "Afficher l'assistant IA"
|
"closed": "Afficher l'assistant IA"
|
||||||
},
|
},
|
||||||
"support": "Support",
|
"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": {
|
"options": {
|
||||||
"buttonLabel": "Plus d'options",
|
"buttonLabel": "Plus d'options",
|
||||||
|
|||||||
Reference in New Issue
Block a user