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)
This commit is contained in:
Claude
2026-01-17 09:57:30 +00:00
parent b84914e0ab
commit 44ba32e074
8 changed files with 79 additions and 14 deletions

View File

@@ -138,6 +138,7 @@ export function EmojiPickerDialog({
onEmojiSelect(`:${result.shortcode}:`, {
shortcode: result.shortcode,
url: result.url,
collection: result.collection,
});
recordCustomUsage(result.shortcode, result.url);
}

View File

@@ -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: " " },

View File

@@ -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<void>;
/**

View File

@@ -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<void> {
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

View File

@@ -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<void> {
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

View File

@@ -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<void> {
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

View File

@@ -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
})),
);
}

View File

@@ -7,6 +7,8 @@ export interface EmojiSearchResult {
url: string;
/** Source of the emoji: "unicode", "user", "set:<identifier>", 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:<identifier>", 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<void> {
// 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,
);
}
}