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

/**
* 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;
}