Files
spsays-training/components/chat/reaction-row.tsx
T

203 lines
6.6 KiB
TypeScript

"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 (
<>
<div className="flex items-center gap-1 border-b border-border px-2 py-2">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => onPick(emoji)}
aria-label={`React ${emoji}`}
aria-pressed={current === emoji}
className={cn(
"inline-flex size-9 items-center justify-center rounded-full text-lg transition-colors",
current === emoji ? "bg-primary/15" : "hover:bg-muted",
)}
>
{emoji}
</button>
))}
<button
type="button"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
// Top-centre of the "+": the popover opens upward from here.
setBrowsing({ x: r.left + r.width / 2, y: r.top });
}}
aria-label="More reactions"
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Plus className="size-4" />
</button>
</div>
{browsing && <FullSet anchor={browsing} onPick={onPick} onClose={close} />}
</>
);
}
/** 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 = (
<div className="space-y-4 p-3">
{EMOJI_GROUPS.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
<div className="grid grid-cols-8 gap-1">
{group.emojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onClose();
onPick(emoji);
}}
aria-label={`React ${emoji}`}
className="inline-flex size-9 items-center justify-center rounded-md text-lg hover:bg-muted"
>
{emoji}
</button>
))}
</div>
</section>
))}
</div>
);
if (isMobile) {
return (
<Drawer open onOpenChange={(o) => !o && onClose()}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left">
<DrawerTitle>Pick a reaction</DrawerTitle>
<DrawerDescription>
Choose any emoji to react with.
</DrawerDescription>
</DrawerHeader>
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
{grid}
</div>
</DrawerContent>
</Drawer>
);
}
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. */}
<div className="fixed inset-0 z-[60]" onClick={onClose} aria-hidden />
<div
role="dialog"
aria-label="Pick a reaction"
style={{ left, ...vertical, width: POPOVER_W, maxHeight: maxH }}
className="fixed z-[61] flex flex-col overflow-hidden rounded-xl border border-border bg-card shadow-lg"
>
<div className="flex-1 overflow-y-auto">{grid}</div>
</div>
</>,
document.body,
);
}