mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
62
src/components/nostr/CustomEmoji.tsx
Normal file
62
src/components/nostr/CustomEmoji.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
54
src/components/nostr/kinds/EmojiSetDetailRenderer.tsx
Normal file
54
src/components/nostr/kinds/EmojiSetDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/nostr/kinds/EmojiSetRenderer.tsx
Normal file
60
src/components/nostr/kinds/EmojiSetRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
32
src/lib/emoji-helpers.ts
Normal 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],
|
||||
})),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user