import { ErrorInfo, RoomReactionEvent } from '@ably/chat'; import { useRoomReactions } from '@ably/chat/react'; import { clsx } from 'clsx'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useThrottle } from '../../hooks/use-throttle.tsx'; import { EmojiBurst } from './emoji-burst.tsx'; import { EmojiWheel } from './emoji-wheel.tsx'; /** * Props for the RoomReaction component */ export interface RoomReactionProps { /** * Duration for the emoji burst animation in milliseconds. * Controls how long the emoji animation is visible before automatically hiding. * Longer durations provide more noticeable feedback but may feel slower. * @default 500 */ emojiBurstDuration?: number; /** * Fixed position for the burst animation when receiving reactions from others. * When provided, all incoming reactions will animate at this position. * When not provided, incoming reactions animate at screen center. * Own reactions always animate from the button position * * @example * // Fixed position in top-right corner * emojiBurstPosition={{ x: window.innerWidth - 100, y: 100 }} * * @example * // Center of a specific element * const rect = element.getBoundingClientRect(); * emojiBurstPosition={{ * x: rect.left + rect.width / 2, * y: rect.top + rect.height / 2 * }} */ emojiBurstPosition?: { x: number; y: number }; /** * Additional CSS classes to apply to the reaction button container. * Allows customization of spacing, alignment, and styling. * Merged with default padding and container classes using clsx. * * @example * // Custom spacing and positioning * className="px-6 py-2 fixed bottom-4 right-4" * * @example * // Integration with flex layouts * className="flex-shrink-0 ml-auto" */ className?: string; /** * Custom error handling configuration for room reaction operations. * Provides hooks for handling specific error scenarios instead of default console logging. * All handlers are optional and will fall back to console.error if not provided. * * @example * ```tsx * const onError = { * onSendRoomReactionError: (error, emoji) => { * toast.error(`Failed to send ${emoji} reaction: ${error.message}`); * console.error('Room reaction error:', error); * } * }; * * * ``` */ onError?: { /** * Called when sending a room reaction fails. * Provides the error object and the emoji that failed to send. * * @param error - The error that occurred while sending the reaction * @param emoji - The emoji that failed to send */ onSendRoomReactionError?: (error: ErrorInfo, emoji: string) => void; }; } /** * RoomReaction component provides ephemeral room reaction functionality for chat rooms * * Core Features: * - Quick reaction button with customizable default emoji (starts with 👍) * - Long press (500ms) to open circular emoji selection wheel * - Selected emoji becomes new default for subsequent quick reactions * - Immediate visual feedback with emoji burst animations * - Throttled sending (max 1 reaction per 200ms) * - Handles both outgoing and incoming room reactions * - Mobile-friendly with touch event support and haptic feedback * - Accessible with proper ARIA labels and keyboard interaction * * Interaction: * • Short click/tap: Sends current default emoji immediately * • Long press/hold: Opens emoji wheel for selection * • Emoji wheel selection: Updates default and sends chosen emoji * • Click outside wheel: Closes wheel without sending * * * Room Reactions vs Message Reactions: * Room reactions are ephemeral like typing indicators - they provide momentary * feedback without being stored in chat history. They're useful for quick * acknowledgments, applause, or ambient reactions during conversations. * * @example * // With custom animation settings * * * @example * // Custom positioning and styling * * * @example * // With custom error handling * { * toast.error(`Failed to send ${emoji} reaction: ${error.message}`); * console.error('Room reaction error:', error); * } * }} * /> * */ export const RoomReaction = ({ emojiBurstDuration = 500, emojiBurstPosition: initialEmojiBurstPosition, className, onError, }: RoomReactionProps) => { const [showEmojiBurst, setShowEmojiBurst] = useState(false); const [emojiBurstPosition, setEmojiBurstPosition] = useState( initialEmojiBurstPosition || { x: 0, y: 0 } ); const [burstEmoji, setBurstEmoji] = useState('👍'); const [showEmojiWheel, setShowEmojiWheel] = useState(false); const [emojiWheelPosition, setEmojiWheelPosition] = useState({ x: 0, y: 0 }); const [defaultEmoji, setDefaultEmoji] = useState('👍'); // Track current default emoji const reactionButtonRef = useRef(null); const longPressTimerRef = useRef(undefined); const isLongPressRef = useRef(false); /** * Handles incoming room reactions from other users. * This function will be throttled to prevent excessive UI updates. * * @param reaction - The incoming room reaction event */ const handleIncomingReaction = useCallback( (reaction: RoomReactionEvent) => { if (reaction.reaction.isSelf) { // If the reaction is from ourselves, we don't need to show the burst animation // (we already showed it locally for immediate feedback) return; } // Set the emoji and show burst for incoming reactions setBurstEmoji(reaction.reaction.name); // Use provided position or default to screen center for incoming reactions if (initialEmojiBurstPosition) { setEmojiBurstPosition(initialEmojiBurstPosition); } else { // Show burst in the screen center for incoming reactions setEmojiBurstPosition({ x: window.innerWidth / 2, // horizontal center y: window.innerHeight / 2, // vertical center }); } setShowEmojiBurst(true); }, [initialEmojiBurstPosition] ); const throttledHandleIncomingReaction = useThrottle(handleIncomingReaction, 200); const { sendRoomReaction } = useRoomReactions({ listener: throttledHandleIncomingReaction, }); /** * Shows the local burst animation at the button position. * This provides immediate visual feedback regardless of network throttling. * Always animates from the button location for own reactions. * * @param emoji - The emoji character to animate in the burst */ const showLocalBurst = useCallback((emoji: string) => { const button = reactionButtonRef.current; if (button) { const rect = button.getBoundingClientRect(); setBurstEmoji(emoji); setEmojiBurstPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); setShowEmojiBurst(true); } }, []); /** * Sends a room reaction to all participants (without throttling). * This is the base function that communicates with Ably Chat. * Will be wrapped by useThrottle to prevent excessive API calls. * * @param emoji - The emoji reaction to send to the room */ const sendReactionToRoom = useCallback( (emoji: string): void => { sendRoomReaction({ name: emoji }).catch((error: unknown) => { if (onError?.onSendRoomReactionError) { onError.onSendRoomReactionError(error as ErrorInfo, emoji); } else { console.error('Failed to send room reaction:', error); } }); }, [sendRoomReaction, onError] ); // Create throttled version of the send function to avoid excessive network calls // Limits to maximum 1 reaction per 200ms while preserving immediate visual feedback const throttledSendReaction = useThrottle(sendReactionToRoom, 200); /** * Handles sending a room reaction with immediate visual feedback and throttled network call. * Shows local animation instantly, then sends throttled network request. * This pattern ensures responsive UX even with network throttling. * * @param emoji - The emoji reaction to send and animate */ const sendReaction = useCallback( (emoji: string) => { // Always show local burst for immediate feedback showLocalBurst(emoji); // Send throttled network request throttledSendReaction(emoji); }, [showLocalBurst, throttledSendReaction] ); /** * Handles clicking the reaction button (short press). * Sends the current default emoji reaction if this wasn't part of a long press. * Long press detection prevents accidental reactions when opening emoji wheel. */ const handleReactionClick = useCallback(() => { // Only send reaction if this wasn't a long press if (!isLongPressRef.current) { sendReaction(defaultEmoji); } // Reset long press flag for next interaction isLongPressRef.current = false; }, [sendReaction, defaultEmoji]); /** * Handles starting a potential long press interaction. * Sets up timer to detect long press (500ms threshold). * When timer completes, opens emoji wheel at button position. */ const handleMouseDown = useCallback(() => { isLongPressRef.current = false; longPressTimerRef.current = setTimeout(() => { isLongPressRef.current = true; // Show emoji wheel at button position const button = reactionButtonRef.current; if (button) { const rect = button.getBoundingClientRect(); setEmojiWheelPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); setShowEmojiWheel(true); // Add haptic feedback if available (mobile devices) navigator.vibrate(50); } }, 500); // 500ms threshold for long press detection }, []); /** * Handles ending a potential long press interaction. * Clears the long press timer to prevent wheel from opening. * Called on mouse up, mouse leave, touch end events. */ const handleMouseUp = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = undefined; } }, []); /** * Handles touch start for mobile long press detection. * Delegates to mouse down handler for unified behavior. */ const handleTouchStart = useCallback(() => { handleMouseDown(); }, [handleMouseDown]); /** * Handles touch end for mobile long press detection. * Delegates to mouse up handler for unified behavior. */ const handleTouchEnd = useCallback(() => { handleMouseUp(); }, [handleMouseUp]); /** * Handles emoji selection from the emoji wheel. * Updates the default emoji for future quick reactions and sends the selected emoji. * Closes the wheel after selection completes. * * @param emoji - The emoji selected from the wheel */ const handleEmojiSelect = useCallback( (emoji: string) => { setShowEmojiWheel(false); setDefaultEmoji(emoji); // Update default emoji for future quick reactions sendReaction(emoji); }, [sendReaction] ); /** * Handles closing the emoji wheel without selecting an emoji. * Called when clicking outside the wheel or pressing escape. */ const handleEmojiWheelClose = useCallback(() => { setShowEmojiWheel(false); }, []); /** * Updates the emoji wheel position when the window is resized * Ensures the wheel stays properly positioned relative to the button */ useEffect(() => { if (!showEmojiWheel) return; const handleResize = () => { const button = reactionButtonRef.current; if (button) { const rect = button.getBoundingClientRect(); setEmojiWheelPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); } }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [showEmojiWheel]); /** * Callback when the emoji burst animation completes. * Hides the animation component to clean up the UI. */ const handleEmojiBurstComplete = useCallback(() => { setShowEmojiBurst(false); }, []); return (
{/* Reaction Button */} {/* Emoji Selection Wheel */} {/* Emoji Burst Animation */}
); };