mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 you’ll 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" }]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user