/** * NIP-30 Custom Emoji Support * * NIP-30 defines custom emojis using: * - Kind 10030: Emoji sets (replaceable, one per user) * - Kind 30030: Emoji packs (replaceable, parameterized) * * Each emoji set/pack maps shortcodes (e.g., "turtlehappy_sm") to image URLs. * * Format: :shortcode: in reactions/content */ import { nostrClient } from './nostr-client.js'; import { relayManager } from './relay-manager.js'; import type { NostrEvent } from '../../types/nostr.js'; import { matchAll } from 'nostr-tools/nip30'; export interface EmojiDefinition { shortcode: string; // Without colons, e.g., "turtlehappy_sm" url: string; // Image URL } export interface EmojiSet { pubkey: string; emojis: Map; // shortcode -> definition } // Cache of emoji sets by pubkey const emojiSetCache = new Map(); /** * Parse a kind 10030 emoji set event or kind 30030 emoji pack */ export function parseEmojiSet(event: NostrEvent): EmojiSet | null { if (event.kind !== 10030 && event.kind !== 30030) return null; const emojis = new Map(); // Parse emoji tags: ["emoji", "shortcode", "url"] for (const tag of event.tags) { if (tag[0] === 'emoji' && tag[1] && tag[2]) { const shortcode = tag[1]; const url = tag[2]; emojis.set(shortcode, { shortcode, url }); } } if (emojis.size === 0) return null; return { pubkey: event.pubkey, emojis }; } /** * Fetch emoji set for a pubkey */ export async function fetchEmojiSet(pubkey: string): Promise { // Check cache first if (emojiSetCache.has(pubkey)) { return emojiSetCache.get(pubkey)!; } try { const relays = relayManager.getFeedReadRelays(); // Fetch both emoji sets (10030) and emoji packs (30030) const events = await nostrClient.fetchEvents( [{ kinds: [10030, 30030], authors: [pubkey], limit: 10 }], // Get multiple in case of packs relays, { useCache: true, cacheResults: true, timeout: 5000 } ); if (events.length === 0) return null; // Get the most recent event (replaceable events) const event = events.sort((a, b) => b.created_at - a.created_at)[0]; const emojiSet = parseEmojiSet(event); if (emojiSet) { emojiSetCache.set(pubkey, emojiSet); } return emojiSet; } catch (error) { console.error('Error fetching emoji set:', error); return null; } } /** * Resolve a shortcode to an image URL * Tries to find the emoji in emoji sets from the given pubkeys */ export async function resolveEmojiShortcode( shortcode: string, pubkeys: string[] = [] ): Promise { // Remove colons if present const cleanShortcode = shortcode.replace(/^:|:$/g, ''); // Try each pubkey's emoji set for (const pubkey of pubkeys) { const emojiSet = await fetchEmojiSet(pubkey); if (emojiSet?.emojis.has(cleanShortcode)) { return emojiSet.emojis.get(cleanShortcode)!.url; } } return null; } /** * Get all unique pubkeys from reactions to fetch their emoji sets */ export function extractPubkeysFromReactions(reactions: Map; eventIds?: Map }>): string[] { const pubkeys = new Set(); for (const { pubkeys: reactionPubkeys } of reactions.values()) { for (const pubkey of reactionPubkeys) { pubkeys.add(pubkey); } } return Array.from(pubkeys); } /** * Resolve all custom emoji shortcodes in reactions to their URLs */ export async function resolveCustomEmojis( reactions: Map; eventIds?: Map }> ): Promise> { // Extract all pubkeys that have reactions const pubkeys = extractPubkeysFromReactions(reactions); // Fetch all emoji sets in parallel const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey)); await Promise.all(emojiSetPromises); // Build map of shortcode -> URL const emojiMap = new Map(); for (const [content] of reactions.entries()) { if (content.startsWith(':') && content.endsWith(':')) { const shortcode = content.slice(1, -1); // Remove colons // Try to find in any emoji set for (const pubkey of pubkeys) { const emojiSet = emojiSetCache.get(pubkey); if (emojiSet?.emojis.has(shortcode)) { emojiMap.set(content, emojiSet.emojis.get(shortcode)!.url); break; } } } } return emojiMap; }