14 changed files with 931 additions and 135 deletions
@ -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