You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
158 lines
4.5 KiB
158 lines
4.5 KiB
/** |
|
* 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>; eventIds?: Map<string, 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>; eventIds?: Map<string, 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; |
|
}
|
|
|