feat: Unify emoji rendering with CustomEmoji and UnicodeEmoji components

- Add xs size variant (size-3.5) to CustomEmoji for small contexts like badges
- Add showTooltip prop to CustomEmoji to optionally disable tooltip
- Create UnicodeEmoji component with matching size variants (xs, sm, md, lg)
- Update RichText/Emoji.tsx to use CustomEmoji for inline custom emoji
- Update MessageReactions to use CustomEmoji and UnicodeEmoji
- Update ReactionRenderer to use CustomEmoji and UnicodeEmoji
- Update ReactionCompactPreview to use CustomEmoji and UnicodeEmoji
- Update EmojiSuggestionList to use CustomEmoji and UnicodeEmoji
- Update EmojiPickerDialog to use UnicodeEmoji

This provides consistent emoji rendering across all components with:
- Unified size variants: xs (14px), sm (16px), md (24px), lg (48px)
- Proper error handling for custom emoji with fallback display
- Optional tooltips showing shortcodes
- Accessible aria-labels for screen readers
This commit is contained in:
Claude
2026-01-17 10:51:02 +00:00
parent 44ba32e074
commit 1798b14370
8 changed files with 146 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ 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";
interface EmojiPickerDialogProps {
open: boolean;
@@ -173,7 +174,12 @@ export function EmojiPickerDialog({
title={`:${result.shortcode}:`}
>
{result.source === "unicode" ? (
<span className="text-xl leading-none">{result.url}</span>
<UnicodeEmoji
emoji={result.url}
shortcode={result.shortcode}
size="md"
showTooltip={false}
/>
) : (
<CustomEmoji
size="md"

View File

@@ -5,6 +5,8 @@ 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";
interface MessageReactionsProps {
messageId: string;
@@ -177,15 +179,20 @@ function ReactionBadge({ reaction }: { reaction: ReactionSummary }) {
title={tooltip}
>
{reaction.customEmoji ? (
<img
src={reaction.customEmoji.url}
alt={`:${reaction.customEmoji.shortcode}:`}
className="size-3.5 flex-shrink-0 object-contain"
<CustomEmoji
shortcode={reaction.customEmoji.shortcode}
url={reaction.customEmoji.url}
size="xs"
showTooltip={false}
className="flex-shrink-0"
/>
) : (
<span className="text-xs leading-none flex-shrink-0">
{reaction.emoji}
</span>
<UnicodeEmoji
emoji={reaction.emoji}
size="xs"
showTooltip={false}
className="flex-shrink-0"
/>
)}
<span
className={cn(

View File

@@ -7,6 +7,8 @@ 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";
export interface EmojiSuggestionListProps {
items: EmojiSearchResult[];
@@ -121,19 +123,20 @@ export const EmojiSuggestionList = forwardRef<
title={`:${item.shortcode}:`}
>
{item.source === "unicode" ? (
// Unicode emoji - render as text
<span className="text-lg leading-none">{item.url}</span>
// Unicode emoji - render with UnicodeEmoji component
<UnicodeEmoji
emoji={item.url}
shortcode={item.shortcode}
size="md"
showTooltip={false}
/>
) : (
// Custom emoji - render as image
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-6 object-contain"
loading="lazy"
onError={(e) => {
// Replace with fallback on error
e.currentTarget.style.display = "none";
}}
// Custom emoji - render using CustomEmoji component
<CustomEmoji
shortcode={item.shortcode}
url={item.url}
size="md"
showTooltip={false}
/>
)}
</button>

View File

@@ -8,12 +8,15 @@ export interface CustomEmojiProps {
/** The image URL */
url: string;
/** Size variant */
size?: "sm" | "md" | "lg";
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",
@@ -28,6 +31,7 @@ export function CustomEmoji({
url,
size = "md",
className,
showTooltip = true,
}: CustomEmojiProps) {
const [error, setError] = useState(false);
@@ -46,22 +50,28 @@ export function CustomEmoji({
);
}
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>
<img
src={url}
alt={`:${shortcode}:`}
title={`:${shortcode}:`}
className={cn(
"inline-block object-contain",
sizeClasses[size],
className,
)}
loading="lazy"
onError={() => setError(true)}
/>
</TooltipTrigger>
<TooltipTrigger asChild>{img}</TooltipTrigger>
<TooltipContent>:{shortcode}:</TooltipContent>
</Tooltip>
);

View File

@@ -1,3 +1,5 @@
import { CustomEmoji } from "../CustomEmoji";
interface EmojiNodeProps {
node: {
url: string;
@@ -7,11 +9,11 @@ interface EmojiNodeProps {
export function Emoji({ node }: EmojiNodeProps) {
return (
<img
src={node.url}
alt={`:${node.code}:`}
title={`:${node.code}:`}
className="inline-block size-5"
<CustomEmoji
shortcode={node.code}
url={node.url}
size="sm"
showTooltip={false}
/>
);
}

View File

@@ -0,0 +1,63 @@
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,6 +5,8 @@ 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";
/**
* Compact preview for Kind 7 (Reaction)
@@ -100,18 +102,18 @@ export function ReactionCompactPreview({ event }: { event: NostrEvent }) {
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <span className="text-base">{content}</span>;
return <UnicodeEmoji emoji={content} size="xs" showTooltip={false} />;
}
};
return (
<span className="flex items-center gap-1 text-sm truncate">
{parsedReaction.type === "custom" ? (
<img
src={parsedReaction.url}
alt={`:${parsedReaction.shortcode}:`}
title={`:${parsedReaction.shortcode}:`}
className="size-3.5 inline-block shrink-0"
<CustomEmoji
shortcode={parsedReaction.shortcode}
url={parsedReaction.url}
size="xs"
className="shrink-0"
/>
) : (
<span className="shrink-0">

View File

@@ -7,6 +7,8 @@ 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";
/**
* Renderer for Kind 7 - Reactions
@@ -100,7 +102,7 @@ export function Kind7Renderer({ event }: BaseEventProps) {
case "😊":
return <Smile className="size-4 fill-yellow-500 text-yellow-500" />;
default:
return <span className="text-xl">{content}</span>;
return <UnicodeEmoji emoji={content} size="md" showTooltip={false} />;
}
};
@@ -110,11 +112,10 @@ export function Kind7Renderer({ event }: BaseEventProps) {
{/* Reaction indicator */}
<div className="flex items-center gap-2">
{parsedReaction.type === "custom" ? (
<img
src={parsedReaction.url}
alt={`:${parsedReaction.shortcode}:`}
title={`:${parsedReaction.shortcode}:`}
className="size-6 inline-block"
<CustomEmoji
shortcode={parsedReaction.shortcode}
url={parsedReaction.url}
size="md"
/>
) : (
getReactionIcon(parsedReaction.emoji)