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:
Wes
2026-06-05 14:01:06 -07:00
committed by GitHub
co-authored by Pinky
parent 5268fac2d8
commit 1b7b6978fc
2 changed files with 98 additions and 12 deletions
+96 -12
View File
@@ -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 ?? "");
+2
View File
@@ -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);
});