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 (