14 changed files with 931 additions and 135 deletions
@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
|
||||
/** |
||||
* 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<string, EmojiDefinition>; // shortcode -> definition
|
||||
} |
||||
|
||||
// Cache of emoji sets by pubkey
|
||||
const emojiSetCache = new Map<string, EmojiSet>(); |
||||
|
||||
/** |
||||
* 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<string, EmojiDefinition>(); |
||||
|
||||
// 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<EmojiSet | null> { |
||||
// 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<string | null> { |
||||
// 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<string, { content: string; pubkeys: Set<string> }>): string[] { |
||||
const pubkeys = new Set<string>(); |
||||
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<string, { content: string; pubkeys: Set<string> }> |
||||
): Promise<Map<string, string>> { |
||||
// 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<string, string>(); |
||||
|
||||
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; |
||||
} |
||||
Loading…
Reference in new issue