203 lines
6.6 KiB
TypeScript
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,
|
|
);
|
|
}
|