"use client"; /** * The emoji row at the top of a message's long-press menu. * * Five quick reactions plus a "+" that opens the full set — a drawer on * mobile, a popover anchored to the "+" on desktop. A modal would take over * the screen and hide the message being reacted to, which is the one thing * that needs to stay visible while choosing. * * It sits inside the context menu rather than being its own popover: long * press is one gesture, and two surfaces competing for it would mean neither * felt reliable. */ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { EMOJI_GROUPS } from "@/components/ui/emoji-picker"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; const POPOVER_W = 320; const POPOVER_H = 320; const EDGE_PAD = 8; /** What a double-tap sends. Matches the server's own default. */ export const HEART_EMOJI = "❤️"; /** The five offered without opening the full set. */ export const QUICK_REACTIONS = [HEART_EMOJI, "😂", "🙏", "😮", "😢"]; export function ReactionRow({ current, onPick, }: { /** The viewer's existing reaction, so it can be shown as selected. */ current?: string; /** Picking the current emoji again clears it — the caller decides. */ onPick: (emoji: string) => void; }) { // Non-null both opens the full set and positions it: the popover hangs off // the "+" button's rect, which only exists at click time. const [browsing, setBrowsing] = useState<{ x: number; y: number } | null>( null, ); const close = () => setBrowsing(null); return ( <>
{QUICK_REACTIONS.map((emoji) => ( ))}
{browsing && } ); } /** The whole emoji set: popover on desktop, drawer on touch. */ function FullSet({ anchor, onPick, onClose, }: { anchor: { x: number; y: number }; onPick: (emoji: string) => void; onClose: () => void; }) { const isMobile = useMediaQuery("(max-width: 767px)"); // Desktop only: Escape closes. The drawer handles its own dismissal, and a // popover deliberately doesn't lock scrolling behind it. useEffect(() => { if (isMobile) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [isMobile, onClose]); const grid = (
{EMOJI_GROUPS.map((group) => (

{group.label}

{group.emojis.map((emoji) => ( ))}
))}
); if (isMobile) { return ( !o && onClose()}> Pick a reaction Choose any emoji to react with.
{grid}
); } if (typeof document === "undefined") return null; // Opens upward: the "+" sits in a menu raised over a message, so there is // usually more room above it than below. Flips down only if that would clip. let left = anchor.x - POPOVER_W / 2; if (left + POPOVER_W > window.innerWidth - EDGE_PAD) { left = window.innerWidth - POPOVER_W - EDGE_PAD; } if (left < EDGE_PAD) left = EDGE_PAD; // Room above the button, less a small gap and the viewport edge. const roomAbove = anchor.y - 8 - EDGE_PAD; // Open upward when there is any reasonable room, which by the composer // there almost always is. const openUp = roomAbove > 160; // Anchored by whichever edge faces the button, so the panel hugs it // regardless of how tall its contents actually are. Positioning by top // minus a MAX height floats it away whenever the content is shorter. const vertical = openUp ? { bottom: window.innerHeight - anchor.y + 8 } : { top: anchor.y + 8 }; // Never taller than the space it has. const maxH = Math.min( POPOVER_H, openUp ? roomAbove : window.innerHeight - anchor.y - 8 - EDGE_PAD, ); return createPortal( <> {/* Catches the outside click. Transparent — a popover dims nothing. */}
{grid}
, document.body, ); }