From a1fe411161bd3a8245d3e7c45945ce18ca940a70 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 8 Jan 2026 16:55:21 +0100 Subject: [PATCH] NIP-30 custom emoji kinds (#47) * feat(emoji): implement NIP-30 custom emoji support - Fix shortcode regex to be NIP-30 spec compliant (alphanumeric + underscore only) - Add feed and detail renderers for kind 30030 Emoji Sets - Create shared CustomEmoji component for consistent emoji rendering - Add getEmojiTags helper with applesauce-style symbol caching * fix(emoji): remove colons from emoji set title display * style(emoji): enhance detail renderer title with icon * style(emoji): simplify detail renderer layout --------- Co-authored-by: Claude --- src/components/nostr/CustomEmoji.tsx | 62 +++++++++++++++++++ .../nostr/compact/ReactionCompactPreview.tsx | 2 +- .../nostr/kinds/EmojiSetDetailRenderer.tsx | 54 ++++++++++++++++ .../nostr/kinds/EmojiSetRenderer.tsx | 60 ++++++++++++++++++ .../nostr/kinds/ReactionRenderer.tsx | 2 +- src/components/nostr/kinds/index.tsx | 4 ++ src/lib/emoji-helpers.ts | 32 ++++++++++ 7 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/components/nostr/CustomEmoji.tsx create mode 100644 src/components/nostr/kinds/EmojiSetDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/EmojiSetRenderer.tsx create mode 100644 src/lib/emoji-helpers.ts diff --git a/src/components/nostr/CustomEmoji.tsx b/src/components/nostr/CustomEmoji.tsx new file mode 100644 index 0000000..2f57523 --- /dev/null +++ b/src/components/nostr/CustomEmoji.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +export interface CustomEmojiProps { + /** The shortcode (without colons) */ + shortcode: string; + /** The image URL */ + url: string; + /** Size variant */ + size?: "sm" | "md" | "lg"; + /** Additional class names */ + className?: string; +} + +const sizeClasses = { + 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, +}: CustomEmojiProps) { + const [error, setError] = useState(false); + + if (error) { + return ( + + ? + + ); + } + + return ( + {`:${shortcode}:`} setError(true)} + /> + ); +} diff --git a/src/components/nostr/compact/ReactionCompactPreview.tsx b/src/components/nostr/compact/ReactionCompactPreview.tsx index c5307b1..c043a31 100644 --- a/src/components/nostr/compact/ReactionCompactPreview.tsx +++ b/src/components/nostr/compact/ReactionCompactPreview.tsx @@ -26,7 +26,7 @@ export function ReactionCompactPreview({ event }: { event: NostrEvent }) { // Parse reaction content for custom emoji const parsedReaction = useMemo(() => { - const match = reaction.match(/^:([a-zA-Z0-9_#-]+):$/); + const match = reaction.match(/^:([a-zA-Z0-9_]+):$/); if (match && customEmojis[match[1]]) { return { type: "custom" as const, diff --git a/src/components/nostr/kinds/EmojiSetDetailRenderer.tsx b/src/components/nostr/kinds/EmojiSetDetailRenderer.tsx new file mode 100644 index 0000000..4bb80f0 --- /dev/null +++ b/src/components/nostr/kinds/EmojiSetDetailRenderer.tsx @@ -0,0 +1,54 @@ +import { getTagValue } from "applesauce-core/helpers"; +import { getEmojiTags } from "@/lib/emoji-helpers"; +import { CustomEmoji } from "@/components/nostr/CustomEmoji"; +import { NostrEvent } from "@/types/nostr"; + +/** + * Kind 30030 Detail Renderer - Emoji Set (Detail View) + * Shows the full emoji set in a grid + */ +export function EmojiSetDetailRenderer({ event }: { event: NostrEvent }) { + const identifier = getTagValue(event, "d") || "unnamed"; + const emojis = getEmojiTags(event); + + return ( +
+ {/* Header */} +

{identifier}

+ + {/* Empty state */} + {emojis.length === 0 ? ( +
+ This emoji set is empty +
+ ) : ( + <> + {/* Emoji grid */} +
+ {emojis.map((emoji) => ( +
+ +
+ :{emoji.shortcode}: +
+
+ ))} +
+ + {/* Count */} +

+ {emojis.length} emoji{emojis.length !== 1 ? "s" : ""} in this set +

+ + )} +
+ ); +} diff --git a/src/components/nostr/kinds/EmojiSetRenderer.tsx b/src/components/nostr/kinds/EmojiSetRenderer.tsx new file mode 100644 index 0000000..e77bece --- /dev/null +++ b/src/components/nostr/kinds/EmojiSetRenderer.tsx @@ -0,0 +1,60 @@ +import { getTagValue } from "applesauce-core/helpers"; +import { getEmojiTags } from "@/lib/emoji-helpers"; +import { CustomEmoji } from "@/components/nostr/CustomEmoji"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; + +/** + * Kind 30030 Renderer - Emoji Set (Feed View) + * Shows a preview of the emoji set with a few emojis + */ +export function EmojiSetRenderer({ event }: BaseEventProps) { + const identifier = getTagValue(event, "d") || "unnamed"; + const emojis = getEmojiTags(event); + + // Show first 8 emojis in feed view + const previewEmojis = emojis.slice(0, 8); + const remainingCount = emojis.length - previewEmojis.length; + + return ( + +
+ + {identifier} + + + {emojis.length === 0 ? ( +
+ Empty emoji set +
+ ) : ( +
+ {previewEmojis.map((emoji) => ( + + ))} + {remainingCount > 0 && ( + + +{remainingCount} more + + )} +
+ )} + +
+ {emojis.length} emoji{emojis.length !== 1 ? "s" : ""} +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/ReactionRenderer.tsx b/src/components/nostr/kinds/ReactionRenderer.tsx index 2f751e6..a04d699 100644 --- a/src/components/nostr/kinds/ReactionRenderer.tsx +++ b/src/components/nostr/kinds/ReactionRenderer.tsx @@ -32,7 +32,7 @@ export function Kind7Renderer({ event }: BaseEventProps) { // Parse reaction content to detect custom emoji shortcodes // Format: :shortcode: in the content const parsedReaction = useMemo(() => { - const match = reaction.match(/^:([a-zA-Z0-9_#-]+):$/); + const match = reaction.match(/^:([a-zA-Z0-9_]+):$/); if (match && customEmojis[match[1]]) { return { type: "custom" as const, diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 671f92a..da8f34c 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -52,6 +52,8 @@ import { CalendarDateEventRenderer } from "./CalendarDateEventRenderer"; import { CalendarDateEventDetailRenderer } from "./CalendarDateEventDetailRenderer"; import { CalendarTimeEventRenderer } from "./CalendarTimeEventRenderer"; import { CalendarTimeEventDetailRenderer } from "./CalendarTimeEventDetailRenderer"; +import { EmojiSetRenderer } from "./EmojiSetRenderer"; +import { EmojiSetDetailRenderer } from "./EmojiSetDetailRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -91,6 +93,7 @@ const kindRenderers: Record> = { 10050: GenericRelayListRenderer, // DM Relay List (NIP-51) 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article + 30030: EmojiSetRenderer, // Emoji Sets (NIP-30) 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) 34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy) 34236: Kind22Renderer, // Vertical Video (NIP-71 legacy) @@ -155,6 +158,7 @@ const detailRenderers: Record< 10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34) 777: SpellDetailRenderer, // Spell Detail 30023: Kind30023DetailRenderer, // Long-form Article Detail + 30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30) 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) diff --git a/src/lib/emoji-helpers.ts b/src/lib/emoji-helpers.ts new file mode 100644 index 0000000..c5a299a --- /dev/null +++ b/src/lib/emoji-helpers.ts @@ -0,0 +1,32 @@ +import { getOrComputeCachedValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Represents a parsed emoji tag from NIP-30 + */ +export interface EmojiTag { + shortcode: string; + url: string; +} + +/** + * Symbol for caching parsed emoji tags on events + */ +const EmojiTagsSymbol = Symbol("emojiTags"); + +/** + * Extract and cache emoji tags from an event + * Uses applesauce's symbol-based caching to avoid recomputation + * + * Emoji tags format: ["emoji", "shortcode", "url"] + */ +export function getEmojiTags(event: NostrEvent): EmojiTag[] { + return getOrComputeCachedValue(event, EmojiTagsSymbol, () => + event.tags + .filter((tag) => tag[0] === "emoji" && tag[1] && tag[2]) + .map((tag) => ({ + shortcode: tag[1], + url: tag[2], + })), + ); +}