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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-08 16:55:21 +01:00
committed by GitHub
parent ce6ec47b81
commit a1fe411161
7 changed files with 214 additions and 2 deletions

View File

@@ -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 (
<span
className={cn(
"inline-flex items-center justify-center bg-muted rounded text-muted-foreground text-xs",
sizeClasses[size],
className,
)}
title={`:${shortcode}:`}
>
?
</span>
);
}
return (
<img
src={url}
alt={`:${shortcode}:`}
title={`:${shortcode}:`}
className={cn(
"inline-block object-contain",
sizeClasses[size],
className,
)}
loading="lazy"
onError={() => setError(true)}
/>
);
}

View File

@@ -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,

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4">
{/* Header */}
<h1 className="text-2xl font-bold">{identifier}</h1>
{/* Empty state */}
{emojis.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
This emoji set is empty
</div>
) : (
<>
{/* Emoji grid */}
<div className="grid grid-cols-6 gap-2">
{emojis.map((emoji) => (
<div
key={emoji.shortcode}
className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/30"
title={`:${emoji.shortcode}:`}
>
<CustomEmoji
shortcode={emoji.shortcode}
url={emoji.url}
size="lg"
/>
<div className="text-xs text-muted-foreground font-mono truncate max-w-full px-1">
:{emoji.shortcode}:
</div>
</div>
))}
</div>
{/* Count */}
<p className="text-sm text-muted-foreground">
{emojis.length} emoji{emojis.length !== 1 ? "s" : ""} in this set
</p>
</>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="text-sm font-medium text-foreground"
>
{identifier}
</ClickableEventTitle>
{emojis.length === 0 ? (
<div className="text-xs text-muted-foreground italic">
Empty emoji set
</div>
) : (
<div className="flex flex-wrap gap-1.5 items-center">
{previewEmojis.map((emoji) => (
<CustomEmoji
key={emoji.shortcode}
shortcode={emoji.shortcode}
url={emoji.url}
size="md"
/>
))}
{remainingCount > 0 && (
<span className="text-xs text-muted-foreground">
+{remainingCount} more
</span>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
{emojis.length} emoji{emojis.length !== 1 ? "s" : ""}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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,

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)

32
src/lib/emoji-helpers.ts Normal file
View File

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