mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00: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 remarkMentions from "@/shared/lib/remarkMentions";
|
||||||
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -306,6 +307,97 @@ function getCodeBlockText(children: React.ReactNode) {
|
|||||||
return getReactNodeText(children).replace(/\n$/, "");
|
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({
|
function MarkdownCodeBlock({
|
||||||
children,
|
children,
|
||||||
language,
|
language,
|
||||||
@@ -839,18 +931,10 @@ function createMarkdownComponents(
|
|||||||
if (!resolvedSrc) {
|
if (!resolvedSrc) {
|
||||||
return <span>{alt}</span>;
|
return <span>{alt}</span>;
|
||||||
}
|
}
|
||||||
// Inline custom emoji: sized to the line, baseline-aligned with text.
|
if (!interactive) {
|
||||||
return (
|
return <span>{alt}</span>;
|
||||||
<img
|
}
|
||||||
alt={alt}
|
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
|
||||||
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()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
"channel-link": ({ children }: { children?: React.ReactNode }) => {
|
"channel-link": ({ children }: { children?: React.ReactNode }) => {
|
||||||
const text = String(children ?? "");
|
const text = String(children ?? "");
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ test("custom emoji round-trips through select-all + send to the timeline", async
|
|||||||
.getByTestId("message-timeline")
|
.getByTestId("message-timeline")
|
||||||
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
|
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`);
|
||||||
await expect(sentEmoji.last()).toBeVisible();
|
await expect(sentEmoji.last()).toBeVisible();
|
||||||
|
await sentEmoji.last().hover();
|
||||||
|
await expect(page.getByText(`:${SHORTCODE}:`).last()).toBeVisible();
|
||||||
// The composer clears after send.
|
// The composer clears after send.
|
||||||
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
|
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user