Skip to content

Commit 1b7b697

Browse files
wesbillmanPinky
andauthored
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>
1 parent 5268fac commit 1b7b697

2 files changed

Lines changed: 98 additions & 12 deletions

File tree

desktop/src/shared/ui/markdown.tsx

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import remarkCustomEmoji, {
4040
import remarkMentions from "@/shared/lib/remarkMentions";
4141
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
4242
import { Button } from "@/shared/ui/button";
43+
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
4344
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
4445

4546
import {
@@ -306,6 +307,97 @@ function getCodeBlockText(children: React.ReactNode) {
306307
return getReactNodeText(children).replace(/\n$/, "");
307308
}
308309

310+
function InlineEmojiPopover({
311+
alt,
312+
resolvedSrc,
313+
}: {
314+
alt: string | undefined;
315+
resolvedSrc: string;
316+
}) {
317+
const [open, setOpen] = React.useState(false);
318+
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
319+
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
320+
const label = alt?.trim() || "Custom emoji";
321+
322+
const clearTimers = React.useCallback(() => {
323+
if (openTimeout.current) {
324+
clearTimeout(openTimeout.current);
325+
openTimeout.current = null;
326+
}
327+
if (closeTimeout.current) {
328+
clearTimeout(closeTimeout.current);
329+
closeTimeout.current = null;
330+
}
331+
}, []);
332+
333+
const handleMouseEnter = React.useCallback(() => {
334+
clearTimers();
335+
openTimeout.current = setTimeout(() => setOpen(true), 200);
336+
}, [clearTimers]);
337+
338+
const scheduleClose = React.useCallback(() => {
339+
clearTimers();
340+
closeTimeout.current = setTimeout(() => setOpen(false), 150);
341+
}, [clearTimers]);
342+
343+
const handleFocus = React.useCallback(() => {
344+
clearTimers();
345+
setOpen(true);
346+
}, [clearTimers]);
347+
348+
React.useEffect(() => clearTimers, [clearTimers]);
349+
350+
return (
351+
<Popover open={open} onOpenChange={setOpen}>
352+
<PopoverTrigger asChild>
353+
<button
354+
type="button"
355+
className="inline-flex border-0 bg-transparent p-0 align-baseline text-inherit"
356+
aria-label={label}
357+
onMouseEnter={handleMouseEnter}
358+
onMouseLeave={scheduleClose}
359+
onFocus={handleFocus}
360+
onBlur={scheduleClose}
361+
>
362+
<img
363+
alt={alt}
364+
title={label}
365+
src={resolvedSrc}
366+
data-custom-emoji=""
367+
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
368+
draggable={false}
369+
onContextMenu={(e) => e.preventDefault()}
370+
/>
371+
</button>
372+
</PopoverTrigger>
373+
<PopoverContent
374+
align="center"
375+
side="top"
376+
sideOffset={6}
377+
className="w-auto min-w-32 max-w-56 rounded-xl p-3"
378+
onMouseEnter={handleMouseEnter}
379+
onMouseLeave={scheduleClose}
380+
onOpenAutoFocus={(e) => e.preventDefault()}
381+
onCloseAutoFocus={(e) => e.preventDefault()}
382+
>
383+
<div className="flex flex-col items-center text-center">
384+
<div className="mb-2 flex h-14 w-14 items-center justify-center">
385+
<img
386+
alt={alt}
387+
src={resolvedSrc}
388+
className="inline-block h-12 w-12 object-contain"
389+
draggable={false}
390+
/>
391+
</div>
392+
<div className="max-w-[12rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
393+
{label}
394+
</div>
395+
</div>
396+
</PopoverContent>
397+
</Popover>
398+
);
399+
}
400+
309401
function MarkdownCodeBlock({
310402
children,
311403
language,
@@ -839,18 +931,10 @@ function createMarkdownComponents(
839931
if (!resolvedSrc) {
840932
return <span>{alt}</span>;
841933
}
842-
// Inline custom emoji: sized to the line, baseline-aligned with text.
843-
return (
844-
<img
845-
alt={alt}
846-
title={alt}
847-
src={resolvedSrc}
848-
data-custom-emoji=""
849-
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-text-bottom"
850-
draggable={false}
851-
onContextMenu={(e) => e.preventDefault()}
852-
/>
853-
);
934+
if (!interactive) {
935+
return <span>{alt}</span>;
936+
}
937+
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
854938
},
855939
"channel-link": ({ children }: { children?: React.ReactNode }) => {
856940
const text = String(children ?? "");

desktop/tests/e2e/custom-emoji.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ test("custom emoji round-trips through select-all + send to the timeline", async
9090
.getByTestId("message-timeline")
9191
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
9292
await expect(sentEmoji.last()).toBeVisible();
93+
await sentEmoji.last().hover();
94+
await expect(page.getByText(`:${SHORTCODE}:`).last()).toBeVisible();
9395
// The composer clears after send.
9496
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
9597
});

0 commit comments

Comments
 (0)