mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
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:
@@ -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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
136
src/components/nostr/Emoji.tsx
Normal file
136
src/components/nostr/Emoji.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user