Improve emoji naming and custom emoji UX (#878)

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 18:51:46 +00:00
committed by GitHub
co-authored by Pinky
parent 581c7e95a9
commit bc53008676
20 changed files with 348 additions and 109 deletions
+8 -16
View File
@@ -25,8 +25,9 @@ pub struct BlobDescriptor {
/// NIP-71 poster frame URL. `None` for non-video blobs or if extraction failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Original filename, for the generic file-card label. Captured client-side
/// (the relay is content-addressed and never learns it). `None` for media.
/// Original filename captured client-side (the relay is content-addressed
/// and never learns it). Generic files use it for file-card labels; custom
/// emoji upload uses it to suggest a shortcode.
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
@@ -324,15 +325,10 @@ async fn process_picked_path(
}
}
// Generic files (non-image, non-video) carry their original filename so the
// client can render a file card with a real label. Media is identified by
// its preview, so no filename is attached.
if !mime.starts_with("image/") && !mime.starts_with("video/") {
descriptor.filename = path
.file_name()
.and_then(|n| n.to_str())
.map(sanitize_filename);
}
descriptor.filename = path
.file_name()
.and_then(|n| n.to_str())
.map(sanitize_filename);
Ok(descriptor)
}
@@ -430,11 +426,7 @@ pub async fn upload_media_bytes(
}
}
// Attach the original filename for generic files (drag/paste supply it from
// the JS File object). Media identifies itself by its preview, so skip it.
if !mime.starts_with("image/") && !mime.starts_with("video/") {
descriptor.filename = filename.as_deref().map(sanitize_filename);
}
descriptor.filename = filename.as_deref().map(sanitize_filename);
Ok(descriptor)
}
@@ -15,7 +15,7 @@ export function buildCustomEmojiCategory(customEmoji: CustomEmoji[]) {
name: "Custom",
emojis: customEmoji.map((e) => ({
id: e.shortcode,
name: e.shortcode,
name: `:${e.shortcode}:`,
keywords: [e.shortcode],
skins: [{ src: rewriteRelayUrl(e.url) }],
})),
@@ -8,7 +8,10 @@ import {
useRemoveCustomEmojiMutation,
useSetCustomEmojiMutation,
} from "@/features/custom-emoji/hooks";
import { normalizeShortcode } from "@/shared/api/customEmoji";
import {
normalizeShortcode,
suggestShortcodeFromFilename,
} from "@/shared/api/customEmoji";
import { pickAndUploadMedia } from "@/shared/api/tauri";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Button } from "@/shared/ui/button";
@@ -31,6 +34,10 @@ export function CustomEmojiSettingsCard() {
const removeEmoji = useRemoveCustomEmojiMutation();
const [name, setName] = React.useState("");
const [pendingUpload, setPendingUpload] = React.useState<{
url: string;
filename: string | null;
} | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const normalized = normalizeShortcode(name);
@@ -38,29 +45,63 @@ export function CustomEmojiSettingsCard() {
// "Replace" only applies to MY set — that's the set the upload will rewrite.
const ownDuplicate =
normalized !== null && own.some((e) => e.shortcode === normalized);
const canSubmit = normalized !== null && !isUploading && !setEmoji.isPending;
const canSubmit =
pendingUpload !== null &&
normalized !== null &&
!isUploading &&
!setEmoji.isPending;
const handleAdd = React.useCallback(async () => {
if (normalized === null) return;
const handleUpload = React.useCallback(async () => {
setIsUploading(true);
try {
const blobs = await pickAndUploadMedia();
const url = blobs[0]?.url;
if (!url) {
// User cancelled the picker, or nothing uploaded.
const blob = blobs[0];
if (!blob?.url) {
return;
}
const stored = await setEmoji.mutateAsync({ shortcode: normalized, url });
if (!blob.type.startsWith("image/")) {
toast.error("Choose an image file for custom emoji.");
return;
}
setPendingUpload({ url: blob.url, filename: blob.filename ?? null });
const suggested = blob.filename
? suggestShortcodeFromFilename(blob.filename)
: null;
if (suggested && name.trim().length === 0) {
setName(suggested);
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to upload emoji image.",
);
} finally {
setIsUploading(false);
}
}, [name]);
const handleAdd = React.useCallback(async () => {
if (normalized === null || pendingUpload === null) return;
try {
const stored = await setEmoji.mutateAsync({
shortcode: normalized,
url: pendingUpload.url,
});
setName("");
setPendingUpload(null);
toast.success(`Added :${stored}:`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to add emoji.",
);
} finally {
setIsUploading(false);
}
}, [normalized, setEmoji]);
}, [normalized, pendingUpload, setEmoji]);
const handleReset = React.useCallback(() => {
setName("");
setPendingUpload(null);
}, []);
const handleRemove = React.useCallback(
async (shortcode: string) => {
@@ -91,49 +132,117 @@ export function CustomEmojiSettingsCard() {
</div>
<form
className="flex items-end gap-2"
className="max-w-2xl space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (canSubmit) void handleAdd();
}}
>
<div className="min-w-0 flex-1 space-y-1.5">
<label className="text-sm font-medium" htmlFor="custom-emoji-name">
Name
</label>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">:</span>
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold">1. Upload an image</h4>
<p className="text-sm text-muted-foreground">
Square images work best. GIF, PNG, JPEG, and WebP files are
supported.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-md border bg-background">
{pendingUpload ? (
<img
alt="Selected custom emoji preview"
src={rewriteRelayUrl(pendingUpload.url)}
className="h-14 w-14 object-contain"
draggable={false}
/>
) : (
<ImagePlus className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-muted-foreground">
{pendingUpload?.filename ?? "No image selected"}
</p>
<Button
type="button"
data-testid="custom-emoji-upload"
onClick={() => void handleUpload()}
disabled={isUploading || setEmoji.isPending}
variant="outline"
>
{isUploading
? "Uploading…"
: pendingUpload
? "Choose different image"
: "Upload image"}
</Button>
</div>
</div>
</div>
<div className="space-y-3 border-t pt-4">
<div>
<h4 className="text-sm font-semibold">2. Give it a name</h4>
<p className="text-sm text-muted-foreground">
This is what youll type to add this emoji to messages and
reactions.
</p>
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
:
</span>
<Input
id="custom-emoji-name"
data-testid="custom-emoji-name-input"
autoCapitalize="none"
autoCorrect="off"
className="px-6"
placeholder="party-parrot"
spellCheck={false}
value={name}
onChange={(event) => setName(event.target.value)}
/>
<span className="text-muted-foreground">:</span>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
:
</span>
</div>
{nameInvalid ? (
<p className="text-sm text-destructive">
Use only letters, numbers, hyphen, or underscore.
</p>
) : pendingUpload === null ? (
<p className="text-sm text-muted-foreground">
Choose an image first; Sprout will suggest a name from the
filename.
</p>
) : ownDuplicate ? (
<p className="text-sm text-muted-foreground">
You already have :{normalized}: saving will replace its image.
</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t pt-4">
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={
setEmoji.isPending || (name.length === 0 && !pendingUpload)
}
>
Clear
</Button>
<Button
type="submit"
data-testid="custom-emoji-add"
disabled={!canSubmit}
>
{setEmoji.isPending ? "Saving…" : "Save emoji"}
</Button>
</div>
<Button
type="submit"
data-testid="custom-emoji-add"
disabled={!canSubmit}
>
<ImagePlus className="mr-2 h-4 w-4" />
{isUploading ? "Uploading…" : "Upload image"}
</Button>
</form>
{nameInvalid ? (
<p className="text-sm text-destructive">
Use only letters, numbers, hyphen, or underscore.
</p>
) : ownDuplicate ? (
<p className="text-sm text-muted-foreground">
You already have :{normalized}: uploading will replace its image.
</p>
) : null}
<div className="space-y-3" data-testid="custom-emoji-mine">
<h3 className="text-sm font-medium">
@@ -58,7 +58,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
}
}}
perLine={8}
previewPosition="none"
previewPosition="bottom"
set="native"
skinTonePosition="search"
theme="auto"
@@ -129,7 +129,7 @@ export function registerCustomEmojiMarkdownIt(
// proxy at PM-render time, so here we emit the raw `src`; `parseHTML`
// re-derives the node from `data-shortcode` and the palette supplies the
// live url. We still set `src` so a fully-formed <img> round-trips cleanly.
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" />`;
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" title=":${esc(shortcode)}:" />`;
};
}
@@ -190,6 +190,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
mergeAttributes(HTMLAttributes, {
src,
alt: `:${shortcode}:`,
title: `:${shortcode}:`,
"data-custom-emoji": "",
"data-shortcode": shortcode,
draggable: "false",
@@ -262,9 +262,11 @@ export function formatTimelineMessages(
const profile = profiles?.[actorPubkey];
const displayName =
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
`${actorPubkey.slice(0, 8)}`;
currentPubkeyLower && actorPubkey === currentPubkeyLower
? "You"
: profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
`${actorPubkey.slice(0, 8)}`;
existing.users.push({
pubkey: actorPubkey,
displayName,
@@ -81,6 +81,22 @@ test("formatImetaMediaLine: image mime → ![image] line", () => {
);
});
test("buildImetaTags omits image filenames from imeta", () => {
assert.deepEqual(
buildImetaTags([
{
url: "https://b/a.png",
type: "image/png",
sha256: "abc",
size: 10,
uploaded: 1,
filename: "Party Parrot.png",
},
]),
[["imeta", "url https://b/a.png", "m image/png", "x abc", "size 10"]],
);
});
test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => {
assert.equal(
formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }),
@@ -97,7 +97,11 @@ export function buildImetaTags(
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
...(!d.type.startsWith("image/") &&
!d.type.startsWith("video/") &&
d.filename
? [`filename ${d.filename}`]
: []),
]);
}
@@ -2,11 +2,9 @@ import * as React from "react";
import type { TimelineReaction } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
const MAX_VISIBLE_REACTORS = 10;
/**
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
@@ -22,10 +20,12 @@ function EmojiGlyph({
reaction: TimelineReaction;
className?: string;
}) {
const displayName = emojiDisplayName(reaction.emoji);
if (reaction.emojiUrl) {
return (
<img
alt={reaction.emoji}
title={displayName}
src={rewriteRelayUrl(reaction.emojiUrl)}
className={cn(
"inline-block object-contain align-text-bottom",
@@ -35,41 +35,46 @@ function EmojiGlyph({
/>
);
}
return <span>{reaction.emoji}</span>;
return (
<span
className={cn("inline-block leading-none", className)}
title={displayName}
>
{reaction.emoji}
</span>
);
}
function formatReactionUsers(reaction: TimelineReaction): string {
const names = reaction.users.map((user) => user.displayName).filter(Boolean);
if (reaction.reactedByCurrentUser) {
const others = names.filter((name) => name !== "You");
names.splice(0, names.length, "You (click to remove)", ...others);
}
if (names.length === 0) return `${reaction.count} people`;
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
}
function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
const visible = reaction.users.slice(0, MAX_VISIBLE_REACTORS);
const overflow = reaction.users.length - MAX_VISIBLE_REACTORS;
const displayName = emojiDisplayName(reaction.emoji);
const userText = formatReactionUsers(reaction);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 pb-1 border-b border-border/50">
<EmojiGlyph reaction={reaction} className="h-6 w-6 text-2xl" />
<span className="text-xs text-muted-foreground">
{reaction.count} {reaction.count === 1 ? "reaction" : "reactions"}
</span>
<div className="flex flex-col items-center text-center">
<div className="mb-2 flex h-14 w-14 items-center justify-center">
<EmojiGlyph
reaction={reaction}
className={reaction.emojiUrl ? "h-12 w-12" : "text-4xl"}
/>
</div>
<div className="flex flex-col gap-1.5">
{visible.map((user) => (
<div key={user.pubkey} className="flex items-center gap-2 min-w-0">
<UserAvatar
avatarUrl={user.avatarUrl}
displayName={user.displayName}
size="xs"
/>
<span className="text-sm truncate">{user.displayName}</span>
</div>
))}
<div className="max-w-[14rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
{userText} <span className="text-muted-foreground">reacted with</span>
</div>
<div className="mt-0.5 text-sm font-semibold leading-snug text-muted-foreground">
{displayName}
</div>
{overflow > 0 && (
<span className="text-xs text-muted-foreground">+{overflow} more</span>
)}
{reaction.reactedByCurrentUser && (
<span className="text-xs text-muted-foreground border-t border-border/50 pt-1.5">
Click to remove your reaction
</span>
)}
</div>
);
}
@@ -175,11 +180,14 @@ function ReactionPill({
onSelect(reaction.emoji);
};
const displayName = emojiDisplayName(reaction.emoji);
if (reaction.users.length === 0) {
return (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
title={displayName}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
@@ -205,6 +213,7 @@ function ReactionPill({
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
title={displayName}
className={pillClasses}
disabled={!canToggle || pending}
onClick={handleClick}
@@ -219,7 +228,7 @@ function ReactionPill({
align="start"
side="top"
sideOffset={6}
className="w-auto min-w-48 max-w-64 p-3"
className="w-auto min-w-56 max-w-72 rounded-xl p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
@@ -1,5 +1,6 @@
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
/**
@@ -29,6 +30,7 @@ export function StatusEmoji({ value, className }: StatusEmojiProps) {
if (!value) return null;
const displayName = emojiDisplayName(value);
const match = value.match(SHORTCODE_RE);
if (match) {
const shortcode = match[1].toLowerCase();
@@ -39,6 +41,7 @@ export function StatusEmoji({ value, className }: StatusEmojiProps) {
return (
<img
alt={value}
title={displayName}
src={rewriteRelayUrl(found.url)}
className={cn(
"inline-block object-contain align-text-bottom",
@@ -53,5 +56,9 @@ export function StatusEmoji({ value, className }: StatusEmojiProps) {
// Native glyph, or an unknown shortcode we can't resolve — render as text.
// Thread the caller's className through so native statuses keep the spacing
// (e.g. `mr-1`) every display site applies to the image branch above.
return <span className={className}>{value}</span>;
return (
<span className={className} title={displayName}>
{value}
</span>
);
}
@@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
customEmojiFromEvent,
customEmojiFromTags,
normalizeShortcode,
suggestShortcodeFromFilename,
} from "./customEmoji.ts";
function ev(tags) {
@@ -83,6 +85,22 @@ test("normalizeShortcode rejects invalid chars and empties", () => {
assert.equal(normalizeShortcode(""), null);
});
test("suggestShortcodeFromFilename derives a valid name from common filenames", () => {
assert.equal(
suggestShortcodeFromFilename("Party Parrot.gif"),
"party_parrot",
);
assert.equal(
suggestShortcodeFromFilename("ship-it.final.PNG"),
"ship-it_final",
);
assert.equal(
suggestShortcodeFromFilename(path.join("tmp", "Narf! Zort.webp")),
"narf_zort",
);
assert.equal(suggestShortcodeFromFilename("---.png"), null);
});
test("customEmojiFromTags normalizes shortcodes (case-fold)", () => {
const out = customEmojiFromTags([["emoji", "ShipIt", "https://relay/s.png"]]);
assert.deepEqual(out, [{ shortcode: "shipit", url: "https://relay/s.png" }]);
+18
View File
@@ -54,6 +54,24 @@ export function normalizeShortcode(raw: string): string | null {
return SHORTCODE_RE.test(lower) ? lower : null;
}
/**
* Suggest a valid custom-emoji shortcode from an uploaded filename.
* Mirrors Slack's file-first flow: strip the extension, lowercase, and collapse
* runs of invalid characters into a single underscore.
*/
export function suggestShortcodeFromFilename(filename: string): string | null {
const basename = filename
.trim()
.replace(/^.*[/\\]/, "")
.replace(/\.[^.]*$/, "");
const suggested = basename
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[_-]+|[_-]+$/g, "");
return normalizeShortcode(suggested);
}
/**
* Parse NIP-30 `["emoji", shortcode, url]` tags from a single event into a
* custom-emoji list. Shortcodes are normalized; malformed/duplicate entries
+1 -1
View File
@@ -761,7 +761,7 @@ export type BlobDescriptor = {
thumb?: string;
duration?: number;
image?: string;
/** Original filename for generic (non-media) file attachments. */
/** Original filename captured client-side. */
filename?: string;
};
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { emojiDisplayName } from "./emojiName.ts";
test("emojiDisplayName resolves native emoji to emoji-mart shortcodes", () => {
assert.equal(emojiDisplayName("🔥"), ":fire:");
assert.equal(emojiDisplayName("😍"), ":heart_eyes:");
});
test("emojiDisplayName resolves skin-tone and ZWJ variants to shortcodes", () => {
assert.equal(emojiDisplayName("👍🏽"), ":+1:");
assert.equal(emojiDisplayName("👨‍👩‍👧‍👦"), ":man-woman-girl-boy:");
assert.equal(emojiDisplayName("❤️"), ":heart:");
});
test("emojiDisplayName preserves custom emoji shortcodes", () => {
assert.equal(emojiDisplayName(":party_parrot:"), ":party_parrot:");
});
+35
View File
@@ -0,0 +1,35 @@
import data from "@emoji-mart/data/sets/15/native.json" with { type: "json" };
type EmojiMartData = {
emojis?: Record<
string,
{
skins?: Array<{ native?: string }>;
}
>;
};
let shortcodeByNativeEmoji: Map<string, string> | null = null;
function buildShortcodeMap(): Map<string, string> {
const map = new Map<string, string>();
const emojis = (data as EmojiMartData).emojis ?? {};
for (const [id, emoji] of Object.entries(emojis)) {
const shortcode = `:${id}:`;
for (const skin of emoji.skins ?? []) {
if (skin.native && !map.has(skin.native)) {
map.set(skin.native, shortcode);
}
}
}
return map;
}
export function emojiDisplayName(emoji: string): string {
const trimmed = emoji.trim();
if (trimmed.startsWith(":") && trimmed.endsWith(":")) {
return trimmed;
}
shortcodeByNativeEmoji ??= buildShortcodeMap();
return shortcodeByNativeEmoji.get(trimmed) ?? trimmed;
}
+1
View File
@@ -843,6 +843,7 @@ function createMarkdownComponents(
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"
@@ -65,9 +65,9 @@ test.describe("channel muting screenshots", () => {
await muteItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
@@ -158,9 +158,9 @@ test.describe("channel muting screenshots", () => {
await unmuteItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
@@ -40,9 +40,9 @@ test.describe("channel starring screenshots", () => {
await starItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
@@ -89,9 +89,9 @@ test.describe("channel starring screenshots", () => {
await unstarItem.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")!
.getAnimations()
.map((a) => a.finished),
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
+8 -3
View File
@@ -141,8 +141,10 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
// to surface the custom emoji, then click it.
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
// Custom emoji buttons carry the shortcode as their `title` (no aria-label).
await picker.locator(`button[title='${REACTION_SHORTCODE}']`).first().click();
await picker
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
.first()
.click();
// The reaction pill renders the custom emoji as an <img alt=":react:">. Its
// src must be the localhost proxy URL — proving rewriteRelayUrl() ran. A raw
@@ -281,7 +283,10 @@ test("a system message accepts a custom-emoji reaction", async ({ page }) => {
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
await picker.locator(`button[title='${REACTION_SHORTCODE}']`).first().click();
await picker
.getByRole("button", { name: `:${REACTION_SHORTCODE}:` })
.first()
.click();
const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
@@ -33,7 +33,10 @@ test("profile popover renders a custom emoji status as an image", async ({
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(SHORTCODE);
await picker.locator(`button[title='${SHORTCODE}']`).first().click();
await picker
.getByRole("button", { name: `:${SHORTCODE}:` })
.first()
.click();
await page.getByTestId("set-status-input").fill(STATUS_TEXT);
await page.getByTestId("set-status-save").click();