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 (
+
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],
+ })),
+ );
+}