fix: stickers and chat UI fix
This commit is contained in:
@@ -4,20 +4,33 @@
|
||||
* 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 modal on desktop, reusing ResponsiveDialog so it behaves like
|
||||
* every other chat surface.
|
||||
* 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 { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { EMOJI_GROUPS } from "@/components/ui/emoji-picker";
|
||||
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||
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 = "❤️";
|
||||
|
||||
@@ -33,7 +46,12 @@ export function ReactionRow({
|
||||
/** Picking the current emoji again clears it — the caller decides. */
|
||||
onPick: (emoji: string) => void;
|
||||
}) {
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
// 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 (
|
||||
<>
|
||||
@@ -55,7 +73,11 @@ export function ReactionRow({
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrowsing(true)}
|
||||
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"
|
||||
>
|
||||
@@ -63,39 +85,118 @@ export function ReactionRow({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{browsing && (
|
||||
<ResponsiveDialog
|
||||
title="Pick a reaction"
|
||||
description="Choose any emoji to react with."
|
||||
onClose={() => setBrowsing(false)}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
{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={() => {
|
||||
setBrowsing(false);
|
||||
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>
|
||||
</ResponsiveDialog>
|
||||
)}
|
||||
{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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user