From b962dddbf2b6dc0a7cb5d19bdb7c7946a85ebc77 Mon Sep 17 00:00:00 2001 From: Arnaud Robin Date: Wed, 19 Feb 2025 00:39:19 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20emoji=20reactions?= =?UTF-8?q?=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../notifications/MainNotificationToast.tsx | 34 ++++- .../notifications/NotificationDuration.ts | 1 + .../notifications/NotificationPayload.ts | 8 + .../notifications/NotificationType.ts | 1 + .../rooms/livekit/api/muteParticipant.ts | 6 +- .../livekit/components/ReactionPortal.tsx | 143 ++++++++++++++++++ .../components/controls/ReactionsToggle.tsx | 93 ++++++++++++ .../prefabs/ControlBar/DesktopControlBar.tsx | 2 + .../src/locales/de/notifications.json | 3 + src/frontend/src/locales/de/rooms.json | 7 +- .../src/locales/en/notifications.json | 3 + src/frontend/src/locales/en/rooms.json | 7 +- .../src/locales/fr/notifications.json | 3 + src/frontend/src/locales/fr/rooms.json | 7 +- 14 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/features/notifications/NotificationPayload.ts create mode 100644 src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx diff --git a/src/frontend/src/features/notifications/MainNotificationToast.tsx b/src/frontend/src/features/notifications/MainNotificationToast.tsx index 96ad0306..e1d87f37 100644 --- a/src/frontend/src/features/notifications/MainNotificationToast.tsx +++ b/src/frontend/src/features/notifications/MainNotificationToast.tsx @@ -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([]) + 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 (
+
) } diff --git a/src/frontend/src/features/notifications/NotificationDuration.ts b/src/frontend/src/features/notifications/NotificationDuration.ts index 7547f56f..b5433dfd 100644 --- a/src/frontend/src/features/notifications/NotificationDuration.ts +++ b/src/frontend/src/features/notifications/NotificationDuration.ts @@ -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 diff --git a/src/frontend/src/features/notifications/NotificationPayload.ts b/src/frontend/src/features/notifications/NotificationPayload.ts new file mode 100644 index 00000000..3ca55989 --- /dev/null +++ b/src/frontend/src/features/notifications/NotificationPayload.ts @@ -0,0 +1,8 @@ +import { NotificationType } from './NotificationType' + +export interface NotificationPayload { + type: NotificationType + data?: { + emoji?: string + } +} diff --git a/src/frontend/src/features/notifications/NotificationType.ts b/src/frontend/src/features/notifications/NotificationType.ts index cf3953e2..2f0ecf3d 100644 --- a/src/frontend/src/features/notifications/NotificationType.ts +++ b/src/frontend/src/features/notifications/NotificationType.ts @@ -4,4 +4,5 @@ export enum NotificationType { ParticipantMuted = 'participantMuted', MessageReceived = 'messageReceived', LowerHand = 'lowerHand', + ReactionReceived = 'reactionReceived', } diff --git a/src/frontend/src/features/rooms/livekit/api/muteParticipant.ts b/src/frontend/src/features/rooms/livekit/api/muteParticipant.ts index 769bc5c8..a25f6718 100644 --- a/src/frontend/src/features/rooms/livekit/api/muteParticipant.ts +++ b/src/frontend/src/features/rooms/livekit/api/muteParticipant.ts @@ -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], diff --git a/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx new file mode 100644 index 00000000..a9efbe47 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/ReactionPortal.tsx @@ -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 ( +
+ + {emoji} + + {name && ( + + {name} + + )} +
+ ) +} + +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( +
+ +
, + document.body + ) +} + +export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => + 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 new file mode 100644 index 00000000..79ca46a4 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx @@ -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([]) + 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 ( + <> + + + + + + {EMOJIS.map((emoji) => ( + + ))} + + + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx index 83465194..e121c681 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx @@ -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" /> + {browserSupportsScreenSharing && ( diff --git a/src/frontend/src/locales/de/notifications.json b/src/frontend/src/locales/de/notifications.json index 343c0d3c..587e7364 100644 --- a/src/frontend/src/locales/de/notifications.json +++ b/src/frontend/src/locales/de/notifications.json @@ -12,5 +12,8 @@ "lowerHand": { "auto": "", "dismiss": "" + }, + "reaction": { + "description": "" } } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 43cfdf77..299bf8eb 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -86,7 +86,12 @@ "closed": "" }, "support": "", - "moreOptions": "" + "moreOptions": "", + "reactions": { + "button": "", + "send": "", + "you": "" + } }, "options": { "buttonLabel": "", diff --git a/src/frontend/src/locales/en/notifications.json b/src/frontend/src/locales/en/notifications.json index 2a34a463..d81d6a2e 100644 --- a/src/frontend/src/locales/en/notifications.json +++ b/src/frontend/src/locales/en/notifications.json @@ -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}}" } } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 0a7663f2..2b343793 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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", diff --git a/src/frontend/src/locales/fr/notifications.json b/src/frontend/src/locales/fr/notifications.json index 1b03c732..47ae5a83 100644 --- a/src/frontend/src/locales/fr/notifications.json +++ b/src/frontend/src/locales/fr/notifications.json @@ -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}}" } } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index eeb316cc..ccffd018 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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",