From 44ba32e074f4e5ade2b5cd8992ff8825ffb9db81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 09:57:30 +0000 Subject: [PATCH] feat: Add collection reference to custom emoji tags When using custom emoji from an emoji set (kind 30030), the emoji tag now includes an optional 4th element with the collection coordinate: ["emoji", "shortcode", "url", "30030:pubkey:identifier"] This enables: - Attribution: knowing which emoji set an emoji came from - Discovery: users can look up and subscribe to emoji sets Changes: - Add `collection` field to EmojiSearchResult and EmojiTag interfaces - Store full coordinate when indexing emoji from emoji sets - Propagate collection through editor serialization - Include collection in emoji tags for messages and reactions - Update all chat adapters (NIP-29, NIP-C7, NIP-53) --- src/components/chat/EmojiPickerDialog.tsx | 1 + src/components/editor/MentionEditor.tsx | 6 +++++- src/lib/chat/adapters/base-adapter.ts | 5 +++-- src/lib/chat/adapters/nip-29-adapter.ts | 19 ++++++++++++++++--- src/lib/chat/adapters/nip-53-adapter.ts | 19 ++++++++++++++++--- src/lib/chat/adapters/nip-c7-adapter.ts | 19 ++++++++++++++++--- src/lib/emoji-helpers.ts | 6 +++++- src/services/emoji-search.ts | 18 +++++++++++++++++- 8 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index d2675ae..70348bc 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -138,6 +138,7 @@ export function EmojiPickerDialog({ onEmojiSelect(`:${result.shortcode}:`, { shortcode: result.shortcode, url: result.url, + collection: result.collection, }); recordCustomUsage(result.shortcode, result.url); } diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index bf47333..ece434f 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -39,6 +39,8 @@ import { nip19 } from "nostr-tools"; export interface EmojiTag { shortcode: string; url: string; + /** Optional reference to the emoji set: "30030:pubkey:identifier" */ + collection?: string; } /** @@ -603,6 +605,7 @@ export const MentionEditor = forwardRef< const shortcode = child.attrs?.id; const url = child.attrs?.url; const source = child.attrs?.source; + const collection = child.attrs?.collection; if (source === "unicode" && url) { // Unicode emoji - output the actual character @@ -613,7 +616,7 @@ export const MentionEditor = forwardRef< if (url && !seenEmojis.has(shortcode)) { seenEmojis.add(shortcode); - emojiTags.push({ shortcode, url }); + emojiTags.push({ shortcode, url, collection }); } } } else if (child.type === "blobAttachment") { @@ -761,6 +764,7 @@ export const MentionEditor = forwardRef< label: props.shortcode, url: props.url, source: props.source, + collection: props.collection, // Reference to emoji set }, }, { type: "text", text: " " }, diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index d0aca1c..6f82673 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -10,6 +10,7 @@ import type { CreateConversationParams, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { ChatAction, ChatActionContext, @@ -40,7 +41,7 @@ export interface SendMessageOptions { /** Event ID being replied to */ replyTo?: string; /** NIP-30 custom emoji tags */ - emojiTags?: Array<{ shortcode: string; url: string }>; + emojiTags?: EmojiTag[]; /** Blob attachments for imeta tags (NIP-92) */ blobAttachments?: BlobAttachmentMeta[]; } @@ -137,7 +138,7 @@ export abstract class ChatProtocolAdapter { conversation: Conversation, messageId: string, emoji: string, - customEmoji?: { shortcode: string; url: string }, + customEmoji?: EmojiTag, ): Promise; /** diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 3f51c30..38f6ac4 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -3,6 +3,7 @@ import { map, first, toArray } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { Conversation, Message, @@ -465,7 +466,11 @@ export class Nip29Adapter extends ChatProtocolAdapter { // Add NIP-30 emoji tags if (options?.emojiTags) { for (const emoji of options.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); + const emojiTag: string[] = ["emoji", emoji.shortcode, emoji.url]; + if (emoji.collection) { + emojiTag.push(emoji.collection); + } + tags.push(emojiTag); } } @@ -495,7 +500,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { conversation: Conversation, messageId: string, emoji: string, - customEmoji?: { shortcode: string; url: string }, + customEmoji?: EmojiTag, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -523,7 +528,15 @@ export class Nip29Adapter extends ChatProtocolAdapter { // Add NIP-30 custom emoji tag if provided if (customEmoji) { - tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + const emojiTag: string[] = [ + "emoji", + customEmoji.shortcode, + customEmoji.url, + ]; + if (customEmoji.collection) { + emojiTag.push(customEmoji.collection); + } + tags.push(emojiTag); } // Use kind 7 for reactions diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index a3d6e8e..398e1cb 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -3,6 +3,7 @@ import { map, first, toArray } from "rxjs/operators"; import type { Filter } from "nostr-tools"; import { nip19 } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { Conversation, Message, @@ -446,7 +447,11 @@ export class Nip53Adapter extends ChatProtocolAdapter { // Add NIP-30 emoji tags if (options?.emojiTags) { for (const emoji of options.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); + const emojiTag: string[] = ["emoji", emoji.shortcode, emoji.url]; + if (emoji.collection) { + emojiTag.push(emoji.collection); + } + tags.push(emojiTag); } } @@ -476,7 +481,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { conversation: Conversation, messageId: string, emoji: string, - customEmoji?: { shortcode: string; url: string }, + customEmoji?: EmojiTag, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -523,7 +528,15 @@ export class Nip53Adapter extends ChatProtocolAdapter { // Add NIP-30 custom emoji tag if provided if (customEmoji) { - tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + const emojiTag: string[] = [ + "emoji", + customEmoji.shortcode, + customEmoji.url, + ]; + if (customEmoji.collection) { + emojiTag.push(customEmoji.collection); + } + tags.push(emojiTag); } // Use kind 7 for reactions diff --git a/src/lib/chat/adapters/nip-c7-adapter.ts b/src/lib/chat/adapters/nip-c7-adapter.ts index 17c43ef..6830215 100644 --- a/src/lib/chat/adapters/nip-c7-adapter.ts +++ b/src/lib/chat/adapters/nip-c7-adapter.ts @@ -3,6 +3,7 @@ import { map, first } from "rxjs/operators"; import { nip19 } from "nostr-tools"; import type { Filter } from "nostr-tools"; import { ChatProtocolAdapter, type SendMessageOptions } from "./base-adapter"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { Conversation, Message, @@ -238,7 +239,11 @@ export class NipC7Adapter extends ChatProtocolAdapter { // Add NIP-30 emoji tags if (options?.emojiTags) { for (const emoji of options.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); + const emojiTag: string[] = ["emoji", emoji.shortcode, emoji.url]; + if (emoji.collection) { + emojiTag.push(emoji.collection); + } + tags.push(emojiTag); } } @@ -254,7 +259,7 @@ export class NipC7Adapter extends ChatProtocolAdapter { conversation: Conversation, messageId: string, emoji: string, - customEmoji?: { shortcode: string; url: string }, + customEmoji?: EmojiTag, ): Promise { const activePubkey = accountManager.active$.value?.pubkey; const activeSigner = accountManager.active$.value?.signer; @@ -282,7 +287,15 @@ export class NipC7Adapter extends ChatProtocolAdapter { // Add NIP-30 custom emoji tag if provided if (customEmoji) { - tags.push(["emoji", customEmoji.shortcode, customEmoji.url]); + const emojiTag: string[] = [ + "emoji", + customEmoji.shortcode, + customEmoji.url, + ]; + if (customEmoji.collection) { + emojiTag.push(customEmoji.collection); + } + tags.push(emojiTag); } // Use kind 7 for reactions diff --git a/src/lib/emoji-helpers.ts b/src/lib/emoji-helpers.ts index 709dcd9..160f4e0 100644 --- a/src/lib/emoji-helpers.ts +++ b/src/lib/emoji-helpers.ts @@ -13,6 +13,8 @@ export const EMOJI_SHORTCODE_REGEX = /^:([a-zA-Z0-9_-]+):$/; export interface EmojiTag { shortcode: string; url: string; + /** Optional reference to the emoji set: "30030:pubkey:identifier" */ + collection?: string; } /** @@ -24,7 +26,8 @@ const EmojiTagsSymbol = Symbol("emojiTags"); * Extract and cache emoji tags from an event * Uses applesauce's symbol-based caching to avoid recomputation * - * Emoji tags format: ["emoji", "shortcode", "url"] + * Emoji tags format: ["emoji", "shortcode", "url", "collection?"] + * where collection is optional: "30030:pubkey:identifier" */ export function getEmojiTags(event: NostrEvent): EmojiTag[] { return getOrComputeCachedValue(event, EmojiTagsSymbol, () => @@ -33,6 +36,7 @@ export function getEmojiTags(event: NostrEvent): EmojiTag[] { .map((tag) => ({ shortcode: tag[1], url: tag[2], + collection: tag[3], // Optional 4th element })), ); } diff --git a/src/services/emoji-search.ts b/src/services/emoji-search.ts index 4b7dcd8..4adebd1 100644 --- a/src/services/emoji-search.ts +++ b/src/services/emoji-search.ts @@ -7,6 +7,8 @@ export interface EmojiSearchResult { url: string; /** Source of the emoji: "unicode", "user", "set:", or "context" */ source: string; + /** For emoji from sets: the full coordinate "30030:pubkey:identifier" */ + collection?: string; } export class EmojiSearchService { @@ -24,11 +26,16 @@ export class EmojiSearchService { /** * Add a single emoji to the search index + * @param shortcode - The emoji shortcode (without colons) + * @param url - The URL to the emoji image (or emoji char for unicode) + * @param source - Source type: "unicode", "user", "set:", or "context" + * @param collection - For emoji from sets: the full coordinate "30030:pubkey:identifier" */ async addEmoji( shortcode: string, url: string, source: string = "custom", + collection?: string, ): Promise { // Normalize shortcode (lowercase, no colons) const normalized = shortcode.toLowerCase().replace(/^:|:$/g, ""); @@ -43,6 +50,7 @@ export class EmojiSearchService { shortcode: normalized, url, source, + collection, }; this.emojis.set(normalized, emoji); @@ -59,8 +67,16 @@ export class EmojiSearchService { event.tags.find((t) => t[0] === "d")?.[1] || "unnamed-set"; const emojis = getEmojiTags(event); + // Build the full coordinate for collection reference + const collection = `30030:${event.pubkey}:${identifier}`; + for (const emoji of emojis) { - await this.addEmoji(emoji.shortcode, emoji.url, `set:${identifier}`); + await this.addEmoji( + emoji.shortcode, + emoji.url, + `set:${identifier}`, + collection, + ); } }