refactor: Unify emoji rendering into single Emoji component

Consolidate CustomEmoji and UnicodeEmoji into a single unified Emoji
component with a cleaner API:

- source: "unicode" | "custom" - determines rendering mode
- value: string - emoji char (unicode) or URL (custom)
- shortcode: string - for tooltips
- size: "xs" | "sm" | "md" | "lg" - consistent sizing
- showTooltip: boolean - optional tooltip on hover

Benefits:
- Single import instead of two components
- Maps directly to EmojiSearchResult shape
- Consistent API regardless of emoji type
- One place for sizing, tooltips, and error handling

Updated components:
- EmojiSuggestionList, EmojiPickerDialog
- MessageReactions, ReactionRenderer, ReactionCompactPreview
- EmojiListRenderer, EmojiSetRenderer, EmojiSetDetailRenderer
- RichText/Emoji

Removed:
- CustomEmoji.tsx (merged into Emoji.tsx)
- UnicodeEmoji.tsx (merged into Emoji.tsx)
This commit is contained in:
Claude
2026-01-17 11:12:22 +00:00
parent 1798b14370
commit df41155d1f
12 changed files with 210 additions and 218 deletions

View File

@@ -6,8 +6,7 @@ import type { EmojiSearchResult } from "@/services/emoji-search";
import type { EmojiTag } from "@/lib/emoji-helpers";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useEmojiFrequency } from "@/hooks/useEmojiFrequency";
import { CustomEmoji } from "../nostr/CustomEmoji";
import { UnicodeEmoji } from "../nostr/UnicodeEmoji";
import { Emoji } from "../nostr/Emoji";
interface EmojiPickerDialogProps {
open: boolean;
@@ -173,20 +172,13 @@ export function EmojiPickerDialog({
className="hover:bg-muted rounded p-2 transition-colors flex items-center justify-center aspect-square"
title={`:${result.shortcode}:`}
>
{result.source === "unicode" ? (
<UnicodeEmoji
emoji={result.url}
shortcode={result.shortcode}
size="md"
showTooltip={false}
/>
) : (
<CustomEmoji
size="md"
shortcode={result.shortcode}
url={result.url}
/>
)}
<Emoji
source={result.source === "unicode" ? "unicode" : "custom"}
value={result.url}
shortcode={result.shortcode}
size="md"
showTooltip={false}
/>
</button>
))
) : (

View File

@@ -5,8 +5,7 @@ import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import accountManager from "@/services/accounts";
import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers";
import { CustomEmoji } from "../nostr/CustomEmoji";
import { UnicodeEmoji } from "../nostr/UnicodeEmoji";
import { Emoji } from "../nostr/Emoji";
interface MessageReactionsProps {
messageId: string;
@@ -178,22 +177,14 @@ function ReactionBadge({ reaction }: { reaction: ReactionSummary }) {
className="inline-flex items-center gap-1.5 text-[10px] leading-tight"
title={tooltip}
>
{reaction.customEmoji ? (
<CustomEmoji
shortcode={reaction.customEmoji.shortcode}
url={reaction.customEmoji.url}
size="xs"
showTooltip={false}
className="flex-shrink-0"
/>
) : (
<UnicodeEmoji
emoji={reaction.emoji}
size="xs"
showTooltip={false}
className="flex-shrink-0"
/>
)}
<Emoji
source={reaction.customEmoji ? "custom" : "unicode"}
value={reaction.customEmoji ? reaction.customEmoji.url : reaction.emoji}
shortcode={reaction.customEmoji?.shortcode || reaction.emoji}
size="xs"
showTooltip={false}
className="flex-shrink-0"
/>
<span
className={cn(
hasUserReacted ? "text-highlight" : "text-muted-foreground",

View File

@@ -7,8 +7,7 @@ import {
} from "react";
import type { EmojiSearchResult } from "@/services/emoji-search";
import { cn } from "@/lib/utils";
import { CustomEmoji } from "@/components/nostr/CustomEmoji";
import { UnicodeEmoji } from "@/components/nostr/UnicodeEmoji";
import { Emoji } from "@/components/nostr/Emoji";
export interface EmojiSuggestionListProps {
items: EmojiSearchResult[];
@@ -122,23 +121,13 @@ export const EmojiSuggestionList = forwardRef<
)}
title={`:${item.shortcode}:`}
>
{item.source === "unicode" ? (
// Unicode emoji - render with UnicodeEmoji component
<UnicodeEmoji
emoji={item.url}
shortcode={item.shortcode}
size="md"
showTooltip={false}
/>
) : (
// Custom emoji - render using CustomEmoji component
<CustomEmoji
shortcode={item.shortcode}
url={item.url}
size="md"
showTooltip={false}
/>
)}
<Emoji
source={item.source === "unicode" ? "unicode" : "custom"}
value={item.url}
shortcode={item.shortcode}
size="md"
showTooltip={false}
/>
</button>
))}
</div>

View File

@@ -1,78 +0,0 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export interface CustomEmojiProps {
/** The shortcode (without colons) */
shortcode: string;
/** The image URL */
url: string;
/** Size variant */
size?: "xs" | "sm" | "md" | "lg";
/** Additional class names */
className?: string;
/** Whether to show tooltip on hover (default: true) */
showTooltip?: boolean;
}
const sizeClasses = {
xs: "size-3.5",
sm: "size-4",
md: "size-6",
lg: "size-12",
};
/**
* Renders a custom emoji image from NIP-30
* Handles loading states and errors gracefully
*/
export function CustomEmoji({
shortcode,
url,
size = "md",
className,
showTooltip = true,
}: CustomEmojiProps) {
const [error, setError] = useState(false);
if (error) {
return (
<span
className={cn(
"inline-flex items-center justify-center bg-muted rounded text-muted-foreground text-xs",
sizeClasses[size],
className,
)}
title={`:${shortcode}:`}
>
?
</span>
);
}
const img = (
<img
src={url}
alt={`:${shortcode}:`}
title={`:${shortcode}:`}
className={cn(
"inline-block object-contain",
sizeClasses[size],
className,
)}
loading="lazy"
onError={() => setError(true)}
/>
);
if (!showTooltip) {
return img;
}
return (
<Tooltip>
<TooltipTrigger asChild>{img}</TooltipTrigger>
<TooltipContent>:{shortcode}:</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export interface EmojiProps {
/** Source type - determines how to render */
source: "unicode" | "custom";
/** The value - unicode character for unicode, URL for custom */
value: string;
/** Shortcode for tooltip (without colons) */
shortcode: string;
/** Size variant */
size?: "xs" | "sm" | "md" | "lg";
/** Additional class names */
className?: string;
/** Whether to show tooltip on hover (default: true) */
showTooltip?: boolean;
}
/**
* Size classes for custom emoji images
*/
const imageSizeClasses = {
xs: "size-3.5",
sm: "size-4",
md: "size-6",
lg: "size-12",
};
/**
* Text size classes for unicode emoji that visually match image sizes
* - xs: size-3.5 (14px) → text-sm (14px)
* - sm: size-4 (16px) → text-base (16px)
* - md: size-6 (24px) → text-2xl (24px)
* - lg: size-12 (48px) → text-5xl (48px)
*/
const textSizeClasses = {
xs: "text-sm",
sm: "text-base",
md: "text-2xl",
lg: "text-5xl",
};
/**
* Unified emoji component that renders both unicode and custom emoji
* with consistent sizing, tooltips, and error handling.
*
* @example
* // Unicode emoji
* <Emoji source="unicode" value="👍" shortcode="thumbsup" size="md" />
*
* // Custom emoji
* <Emoji source="custom" value="https://example.com/emoji.png" shortcode="pepe" size="md" />
*
* // From EmojiSearchResult
* <Emoji source={item.source} value={item.url} shortcode={item.shortcode} size="md" />
*/
export function Emoji({
source,
value,
shortcode,
size = "md",
className,
showTooltip = true,
}: EmojiProps) {
const [error, setError] = useState(false);
// Render custom emoji with error handling
if (source === "custom") {
if (error) {
return (
<span
className={cn(
"inline-flex items-center justify-center bg-muted rounded text-muted-foreground text-xs",
imageSizeClasses[size],
className,
)}
title={`:${shortcode}:`}
>
?
</span>
);
}
const img = (
<img
src={value}
alt={`:${shortcode}:`}
className={cn(
"inline-block object-contain",
imageSizeClasses[size],
className,
)}
loading="lazy"
onError={() => setError(true)}
/>
);
if (!showTooltip) {
return img;
}
return (
<Tooltip>
<TooltipTrigger asChild>{img}</TooltipTrigger>
<TooltipContent>:{shortcode}:</TooltipContent>
</Tooltip>
);
}
// Render unicode emoji
const emojiSpan = (
<span
className={cn(
"inline-block leading-none",
textSizeClasses[size],
className,
)}
role="img"
aria-label={`:${shortcode}:`}
>
{value}
</span>
);
if (!showTooltip) {
return emojiSpan;
}
return (
<Tooltip>
<TooltipTrigger asChild>{emojiSpan}</TooltipTrigger>
<TooltipContent>:{shortcode}:</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,4 +1,4 @@
import { CustomEmoji } from "../CustomEmoji";
import { Emoji as EmojiComponent } from "../Emoji";
interface EmojiNodeProps {
node: {
@@ -7,11 +7,16 @@ interface EmojiNodeProps {
};
}
/**
* RichText emoji node renderer - renders custom emoji in parsed content
* Note: Named export "Emoji" for RichText compatibility, uses EmojiComponent internally
*/
export function Emoji({ node }: EmojiNodeProps) {
return (
<CustomEmoji
<EmojiComponent
source="custom"
value={node.url}
shortcode={node.code}
url={node.url}
size="sm"
showTooltip={false}
/>

View File

@@ -1,63 +0,0 @@
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export interface UnicodeEmojiProps {
/** The emoji character */
emoji: string;
/** The shortcode for tooltip (without colons) */
shortcode?: string;
/** Size variant - matches CustomEmoji sizes */
size?: "xs" | "sm" | "md" | "lg";
/** Additional class names */
className?: string;
/** Whether to show tooltip on hover (default: true, requires shortcode) */
showTooltip?: boolean;
}
/**
* Text size classes that visually match CustomEmoji image sizes
* - xs: size-3.5 (14px) → text-sm (14px)
* - sm: size-4 (16px) → text-base (16px)
* - md: size-6 (24px) → text-2xl (24px)
* - lg: size-12 (48px) → text-5xl (48px)
*/
const sizeClasses = {
xs: "text-sm",
sm: "text-base",
md: "text-2xl",
lg: "text-5xl",
};
/**
* Renders a unicode emoji with consistent sizing
* Size variants match CustomEmoji for visual consistency
*/
export function UnicodeEmoji({
emoji,
shortcode,
size = "md",
className,
showTooltip = true,
}: UnicodeEmojiProps) {
const emojiSpan = (
<span
className={cn("inline-block leading-none", sizeClasses[size], className)}
role="img"
aria-label={shortcode ? `:${shortcode}:` : emoji}
>
{emoji}
</span>
);
// Only show tooltip if shortcode is provided and showTooltip is true
if (!showTooltip || !shortcode) {
return emojiSpan;
}
return (
<Tooltip>
<TooltipTrigger asChild>{emojiSpan}</TooltipTrigger>
<TooltipContent>:{shortcode}:</TooltipContent>
</Tooltip>
);
}

View File

@@ -5,8 +5,7 @@ import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { RichText } from "../RichText";
import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers";
import { CustomEmoji } from "../CustomEmoji";
import { UnicodeEmoji } from "../UnicodeEmoji";
import { Emoji } from "../Emoji";
/**
* Compact preview for Kind 7 (Reaction)
@@ -102,16 +101,25 @@ export function ReactionCompactPreview({ event }: { event: NostrEvent }) {
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <UnicodeEmoji emoji={content} size="xs" showTooltip={false} />;
return (
<Emoji
source="unicode"
value={content}
shortcode={content}
size="xs"
showTooltip={false}
/>
);
}
};
return (
<span className="flex items-center gap-1 text-sm truncate">
{parsedReaction.type === "custom" ? (
<CustomEmoji
<Emoji
source="custom"
value={parsedReaction.url}
shortcode={parsedReaction.shortcode}
url={parsedReaction.url}
size="xs"
className="shrink-0"
/>

View File

@@ -1,7 +1,7 @@
import { Smile } from "lucide-react";
import { getAddressPointerFromATag } from "applesauce-core/helpers";
import { getEmojiTags } from "@/lib/emoji-helpers";
import { CustomEmoji } from "@/components/nostr/CustomEmoji";
import { Emoji } from "@/components/nostr/Emoji";
import {
BaseEventProps,
BaseEventContainer,
@@ -64,10 +64,11 @@ export function EmojiListRenderer({ event }: BaseEventProps) {
{emojis.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
{previewEmojis.map((emoji) => (
<CustomEmoji
<Emoji
key={emoji.shortcode}
source="custom"
value={emoji.url}
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
))}
@@ -116,9 +117,10 @@ export function EmojiListDetailRenderer({ event }: { event: NostrEvent }) {
key={emoji.shortcode}
className="flex items-center gap-1.5 px-2 py-1 bg-muted rounded"
>
<CustomEmoji
<Emoji
source="custom"
value={emoji.url}
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
<span className="text-xs text-muted-foreground">

View File

@@ -1,6 +1,6 @@
import { getTagValue } from "applesauce-core/helpers";
import { getEmojiTags } from "@/lib/emoji-helpers";
import { CustomEmoji } from "@/components/nostr/CustomEmoji";
import { Emoji } from "@/components/nostr/Emoji";
import { NostrEvent } from "@/types/nostr";
/**
@@ -31,9 +31,10 @@ export function EmojiSetDetailRenderer({ event }: { event: NostrEvent }) {
className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/30"
title={`:${emoji.shortcode}:`}
>
<CustomEmoji
<Emoji
source="custom"
value={emoji.url}
shortcode={emoji.shortcode}
url={emoji.url}
size="lg"
/>
<div className="text-xs text-muted-foreground font-mono truncate max-w-full px-1">

View File

@@ -1,6 +1,6 @@
import { getTagValue } from "applesauce-core/helpers";
import { getEmojiTags } from "@/lib/emoji-helpers";
import { CustomEmoji } from "@/components/nostr/CustomEmoji";
import { Emoji } from "@/components/nostr/Emoji";
import {
BaseEventProps,
BaseEventContainer,
@@ -36,10 +36,11 @@ export function EmojiSetRenderer({ event }: BaseEventProps) {
) : (
<div className="flex flex-wrap gap-1.5 items-center">
{previewEmojis.map((emoji) => (
<CustomEmoji
<Emoji
key={emoji.shortcode}
source="custom"
value={emoji.url}
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
))}

View File

@@ -7,8 +7,7 @@ import { KindRenderer } from "./index";
import { EventCardSkeleton } from "@/components/ui/skeleton";
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
import { EMOJI_SHORTCODE_REGEX } from "@/lib/emoji-helpers";
import { CustomEmoji } from "../CustomEmoji";
import { UnicodeEmoji } from "../UnicodeEmoji";
import { Emoji } from "../Emoji";
/**
* Renderer for Kind 7 - Reactions
@@ -102,7 +101,15 @@ export function Kind7Renderer({ event }: BaseEventProps) {
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <UnicodeEmoji emoji={content} size="md" showTooltip={false} />;
return (
<Emoji
source="unicode"
value={content}
shortcode={content}
size="md"
showTooltip={false}
/>
);
}
};
@@ -112,9 +119,10 @@ export function Kind7Renderer({ event }: BaseEventProps) {
{/* Reaction indicator */}
<div className="flex items-center gap-2">
{parsedReaction.type === "custom" ? (
<CustomEmoji
<Emoji
source="custom"
value={parsedReaction.url}
shortcode={parsedReaction.shortcode}
url={parsedReaction.url}
size="md"
/>
) : (