mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Show hover cards for inline message emoji (#885)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -40,6 +40,7 @@ import remarkCustomEmoji, {
|
||||
import remarkMentions from "@/shared/lib/remarkMentions";
|
||||
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
import {
|
||||
@@ -306,6 +307,97 @@ function getCodeBlockText(children: React.ReactNode) {
|
||||
return getReactNodeText(children).replace(/\n$/, "");
|
||||
}
|
||||
|
||||
function InlineEmojiPopover({
|
||||
alt,
|
||||
resolvedSrc,
|
||||
}: {
|
||||
alt: string | undefined;
|
||||
resolvedSrc: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const label = alt?.trim() || "Custom emoji";
|
||||
|
||||
const clearTimers = React.useCallback(() => {
|
||||
if (openTimeout.current) {
|
||||
clearTimeout(openTimeout.current);
|
||||
openTimeout.current = null;
|
||||
}
|
||||
if (closeTimeout.current) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
closeTimeout.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
clearTimers();
|
||||
openTimeout.current = setTimeout(() => setOpen(true), 200);
|
||||
}, [clearTimers]);
|
||||
|
||||
const scheduleClose = React.useCallback(() => {
|
||||
clearTimers();
|
||||
closeTimeout.current = setTimeout(() => setOpen(false), 150);
|
||||
}, [clearTimers]);
|
||||
|
||||
const handleFocus = React.useCallback(() => {
|
||||
clearTimers();
|
||||
setOpen(true);
|
||||
}, [clearTimers]);
|
||||
|
||||
React.useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex border-0 bg-transparent p-0 align-baseline text-inherit"
|
||||
aria-label={label}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={scheduleClose}
|
||||
onFocus={handleFocus}
|
||||
onBlur={scheduleClose}
|
||||
>
|
||||
<img
|
||||
alt={alt}
|
||||
title={label}
|
||||
src={resolvedSrc}
|
||||
data-custom-emoji=""
|
||||
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="center"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="w-auto min-w-32 max-w-56 rounded-xl p-3"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={scheduleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 flex h-14 w-14 items-center justify-center">
|
||||
<img
|
||||
alt={alt}
|
||||
src={resolvedSrc}
|
||||
className="inline-block h-12 w-12 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[12rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownCodeBlock({
|
||||
children,
|
||||
language,
|
||||
@@ -839,18 +931,10 @@ function createMarkdownComponents(
|
||||
if (!resolvedSrc) {
|
||||
return <span>{alt}</span>;
|
||||
}
|
||||
// Inline custom emoji: sized to the line, baseline-aligned with text.
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
title={alt}
|
||||
src={resolvedSrc}
|
||||
data-custom-emoji=""
|
||||
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
|
||||
draggable={false}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
if (!interactive) {
|
||||
return <span>{alt}</span>;
|
||||
}
|
||||
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
|
||||
},
|
||||
"channel-link": ({ children }: { children?: React.ReactNode }) => {
|
||||
const text = String(children ?? "");
|
||||
|
||||
@@ -90,6 +90,8 @@ test("custom emoji round-trips through select-all + send to the timeline", async
|
||||
.getByTestId("message-timeline")
|
||||
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
|
||||
await expect(sentEmoji.last()).toBeVisible();
|
||||
await sentEmoji.last().hover();
|
||||
await expect(page.getByText(`:${SHORTCODE}:`).last()).toBeVisible();
|
||||
// The composer clears after send.
|
||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user