From cffb981ad14779f87c19ad361e87a866cb4fc3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Mon, 2 Mar 2026 21:06:58 +0100 Subject: [PATCH] feat: add emoji set address as 4th param in NIP-30 emoji tags NIP-30 allows an optional 4th tag parameter specifying the source emoji set address (e.g. "30030:pubkey:identifier"). This threads that address through the full emoji pipeline so it appears in posts, replies, reactions, and zap requests. - Add local blueprints.ts with patched NoteBlueprint, NoteReplyBlueprint, GroupMessageBlueprint, and ReactionBlueprint that emit the 4th param; marked TODO to revert once applesauce-common supports it upstream - Add address? to EmojiWithAddress, EmojiTag, EmojiSearchResult, and EmojiTag in create-zap-request - Store address in EmojiSearchService.addEmojiSet (30030:pubkey:identifier) - Thread address through both editor serializers (MentionEditor, RichEditor) and the emoji node TipTap attributes - Fix EmojiPickerDialog to pass address when calling onEmojiSelect and when re-indexing context emojis - Update SendMessageOptions.emojiTags and sendReaction customEmoji param to use EmojiTag throughout the adapter chain Co-Authored-By: Claude Sonnet 4.6 --- src/components/PostViewer.tsx | 3 +- src/components/chat/EmojiPickerDialog.tsx | 3 +- src/components/editor/MentionEditor.tsx | 18 +- src/components/editor/RichEditor.tsx | 13 +- .../nostr/kinds/BaseEventRenderer.tsx | 8 +- src/lib/blueprints.ts | 180 ++++++++++++++++++ src/lib/chat/adapters/base-adapter.ts | 5 +- src/lib/chat/adapters/nip-10-adapter.ts | 13 +- src/lib/chat/adapters/nip-29-adapter.ts | 13 +- src/lib/chat/adapters/nip-53-adapter.ts | 15 +- src/lib/create-zap-request.ts | 8 +- src/lib/emoji-helpers.ts | 2 + src/services/emoji-search.ts | 12 +- 13 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 src/lib/blueprints.ts diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index baf8988..2482fea 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -35,7 +35,7 @@ import { Kind1Renderer } from "./nostr/kinds"; import pool from "@/services/relay-pool"; import eventStore from "@/services/event-store"; import { EventFactory } from "applesauce-core/event-factory"; -import { NoteBlueprint } from "applesauce-common/blueprints"; +import { NoteBlueprint } from "@/lib/blueprints"; import { useGrimoire } from "@/core/state"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { normalizeRelayURL } from "@/lib/relay-url"; @@ -354,6 +354,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { emojis: emojiTags.map((e) => ({ shortcode: e.shortcode, url: e.url, + address: e.address, })), }); diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index 72a20fd..0ae36a8 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -66,7 +66,7 @@ export function EmojiPickerDialog({ useEffect(() => { if (contextEmojis.length > 0) { for (const emoji of contextEmojis) { - service.addEmoji(emoji.shortcode, emoji.url, "context"); + service.addEmoji(emoji.shortcode, emoji.url, "context", emoji.address); } } }, [contextEmojis, service]); @@ -143,6 +143,7 @@ export function EmojiPickerDialog({ onEmojiSelect(`:${result.shortcode}:`, { shortcode: result.shortcode, url: result.url, + address: result.address, }); updateReactionHistory(`:${result.shortcode}:`); } diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 976f5f7..304bc33 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -40,6 +40,8 @@ import { FilePasteHandler } from "./extensions/file-paste-handler"; export interface EmojiTag { shortcode: string; url: string; + /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ + address?: string; } /** @@ -127,6 +129,14 @@ const EmojiMention = Mention.extend({ return { "data-source": attributes.source }; }, }, + address: { + default: null, + parseHTML: (element) => element.getAttribute("data-address"), + renderHTML: (attributes) => { + if (!attributes.address) return {}; + return { "data-address": attributes.address }; + }, + }, }; }, @@ -699,6 +709,7 @@ export const MentionEditor = forwardRef< const shortcode = child.attrs?.id; const url = child.attrs?.url; const source = child.attrs?.source; + const address = child.attrs?.address; if (source === "unicode" && url) { // Unicode emoji - output the actual character @@ -709,7 +720,11 @@ export const MentionEditor = forwardRef< if (url && !seenEmojis.has(shortcode)) { seenEmojis.add(shortcode); - emojiTags.push({ shortcode, url }); + emojiTags.push({ + shortcode, + url, + address: address ?? undefined, + }); } } } else if (child.type === "blobAttachment") { @@ -893,6 +908,7 @@ export const MentionEditor = forwardRef< label: props.shortcode, url: props.url, source: props.source, + address: props.address ?? null, }, }, { type: "text", text: " " }, diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx index ff808bc..2ef9fbb 100644 --- a/src/components/editor/RichEditor.tsx +++ b/src/components/editor/RichEditor.tsx @@ -97,6 +97,14 @@ const EmojiMention = Mention.extend({ return { "data-source": attributes.source }; }, }, + address: { + default: null, + parseHTML: (element) => element.getAttribute("data-address"), + renderHTML: (attributes) => { + if (!attributes.address) return {}; + return { "data-address": attributes.address }; + }, + }, }; }, @@ -179,11 +187,11 @@ function serializeContent(editor: any): SerializedContent { // Walk the document to collect emoji, blob, and address reference data editor.state.doc.descendants((node: any) => { if (node.type.name === "emoji") { - const { id, url, source } = node.attrs; + const { id, url, source, address } = node.attrs; // Only add custom emojis (not unicode) and avoid duplicates if (source !== "unicode" && !seenEmojis.has(id)) { seenEmojis.add(id); - emojiTags.push({ shortcode: id, url }); + emojiTags.push({ shortcode: id, url, address: address ?? undefined }); } } else if (node.type.name === "blobAttachment") { const { url, sha256, mimeType, size, server } = node.attrs; @@ -500,6 +508,7 @@ export const RichEditor = forwardRef( label: props.shortcode, url: props.url, source: props.source, + address: props.address ?? null, }, }, { type: "text", text: " " }, diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 3fea7ce..880c367 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -41,7 +41,7 @@ import { cn } from "@/lib/utils"; import { isAddressableKind } from "@/lib/nostr-kinds"; import { getSemanticAuthor } from "@/lib/semantic-author"; import { EventFactory } from "applesauce-core/event-factory"; -import { ReactionBlueprint } from "applesauce-common/blueprints"; +import { ReactionBlueprint } from "@/lib/blueprints"; import { publishEventToRelays } from "@/services/hub"; import { selectRelaysForInteraction } from "@/services/relay-selection"; import type { EmojiTag } from "@/lib/emoji-helpers"; @@ -548,7 +548,11 @@ export function BaseEventContainer({ factory.setSigner(signer); const emojiArg = customEmoji - ? { shortcode: customEmoji.shortcode, url: customEmoji.url } + ? { + shortcode: customEmoji.shortcode, + url: customEmoji.url, + address: customEmoji.address, + } : emoji; const draft = await factory.create(ReactionBlueprint, event, emojiArg); diff --git a/src/lib/blueprints.ts b/src/lib/blueprints.ts new file mode 100644 index 0000000..c0db785 --- /dev/null +++ b/src/lib/blueprints.ts @@ -0,0 +1,180 @@ +/** + * Local copies of applesauce-common blueprints with NIP-30 emoji set address support. + * + * The upstream Emoji type only has { shortcode, url }. NIP-30 allows an optional 4th + * tag parameter for the emoji set address (e.g. "30030:pubkey:identifier"). These + * blueprints add that via a local `EmojiWithAddress` type and a custom `includeEmojis` + * operation. A patch upstream will be submitted once stable. + * + * TODO: Once applesauce-common supports the emoji set address natively, remove this + * file and revert all imports back to `applesauce-common/blueprints`. + */ + +import { blueprint } from "applesauce-core/event-factory"; +import { kinds } from "applesauce-core/helpers/event"; +import { + setShortTextContent, + type TextContentOptions, +} from "applesauce-core/operations/content"; +import { + setMetaTags, + type MetaTagOptions, +} from "applesauce-core/operations/event"; +import type { EventOperation } from "applesauce-core/event-factory"; +import { + setZapSplit, + type ZapOptions, +} from "applesauce-common/operations/zap-split"; +import { + includePubkeyNotificationTags, + setThreadParent, +} from "applesauce-common/operations/note"; +import { + addPreviousRefs, + setGroupPointer, +} from "applesauce-common/operations/group"; +import { + setReaction, + setReactionParent, +} from "applesauce-common/operations/reaction"; +import { + GROUP_MESSAGE_KIND, + type GroupPointer, +} from "applesauce-common/helpers/groups"; +import type { NostrEvent } from "nostr-tools"; + +// --------------------------------------------------------------------------- +// Extended emoji type +// --------------------------------------------------------------------------- + +export type EmojiWithAddress = { + shortcode: string; + url: string; + /** NIP-30 optional 4th tag: the "30030:pubkey:identifier" address of the set */ + address?: string; +}; + +// --------------------------------------------------------------------------- +// Custom includeEmojis operation that writes the 4th address param when present +// --------------------------------------------------------------------------- + +const Expressions = { + emoji: /:([a-zA-Z0-9_-]+):/g, +}; + +function includeEmojisWithAddress(emojis: EmojiWithAddress[]): EventOperation { + return (draft, ctx) => { + // Merge context emojis (upstream compat) with explicitly passed emojis + const all: EmojiWithAddress[] = [ + ...(ctx.emojis ?? []).map((e) => ({ + shortcode: e.shortcode, + url: e.url, + })), + ...emojis, + ]; + const emojiTags = Array.from( + draft.content.matchAll(Expressions.emoji), + ([, name]) => { + const emoji = all.find((e) => e.shortcode === name); + if (!emoji?.url) return null; + return emoji.address + ? ["emoji", emoji.shortcode, emoji.url, emoji.address] + : ["emoji", emoji.shortcode, emoji.url]; + }, + ).filter((tag): tag is string[] => tag !== null); + return { ...draft, tags: [...draft.tags, ...emojiTags] }; + }; +} + +// TextContentOptions extended with our emoji type +export type TextContentOptionsWithAddress = Omit< + TextContentOptions, + "emojis" +> & { + emojis?: EmojiWithAddress[]; +}; + +// MetaTagOptions extended (MetaTagOptions doesn't use emojis, but we carry the same pattern) +export type NoteBlueprintOptions = TextContentOptionsWithAddress & + MetaTagOptions & + ZapOptions; + +// --------------------------------------------------------------------------- +// NoteBlueprint +// --------------------------------------------------------------------------- + +export function NoteBlueprint(content: string, options?: NoteBlueprintOptions) { + return blueprint( + kinds.ShortTextNote, + // set text content (without emoji — we handle it ourselves) + setShortTextContent(content, { ...options, emojis: undefined }), + options?.emojis ? includeEmojisWithAddress(options.emojis) : undefined, + setZapSplit(options), + setMetaTags(options), + ); +} + +// --------------------------------------------------------------------------- +// NoteReplyBlueprint +// --------------------------------------------------------------------------- + +export function NoteReplyBlueprint( + parent: NostrEvent, + content: string, + options?: TextContentOptionsWithAddress, +) { + if (parent.kind !== kinds.ShortTextNote) + throw new Error( + "Kind 1 replies should only be used to reply to kind 1 notes", + ); + return blueprint( + kinds.ShortTextNote, + setThreadParent(parent), + includePubkeyNotificationTags(parent), + setShortTextContent(content, { ...options, emojis: undefined }), + options?.emojis ? includeEmojisWithAddress(options.emojis) : undefined, + ); +} + +// --------------------------------------------------------------------------- +// GroupMessageBlueprint +// --------------------------------------------------------------------------- + +export type GroupMessageOptions = TextContentOptionsWithAddress & { + previous?: NostrEvent[]; +}; + +export function GroupMessageBlueprint( + group: GroupPointer, + content: string, + options?: GroupMessageOptions, +) { + return blueprint( + GROUP_MESSAGE_KIND, + setGroupPointer(group), + options?.previous ? addPreviousRefs(options.previous) : undefined, + setShortTextContent(content, { ...options, emojis: undefined }), + options?.emojis ? includeEmojisWithAddress(options.emojis) : undefined, + setMetaTags(options), + ); +} + +// --------------------------------------------------------------------------- +// ReactionBlueprint +// --------------------------------------------------------------------------- + +export function ReactionBlueprint( + event: NostrEvent, + emoji: string | EmojiWithAddress = "+", +) { + return blueprint( + kinds.Reaction, + setReaction( + typeof emoji === "string" + ? emoji + : { shortcode: emoji.shortcode, url: emoji.url }, + ), + setReactionParent(event), + typeof emoji !== "string" ? includeEmojisWithAddress([emoji]) : undefined, + ); +} diff --git a/src/lib/chat/adapters/base-adapter.ts b/src/lib/chat/adapters/base-adapter.ts index 953e03d..12fcb78 100644 --- a/src/lib/chat/adapters/base-adapter.ts +++ b/src/lib/chat/adapters/base-adapter.ts @@ -11,6 +11,7 @@ import type { CreateConversationParams, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { ChatAction, ChatActionContext, @@ -71,7 +72,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[]; } @@ -168,7 +169,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-10-adapter.ts b/src/lib/chat/adapters/nip-10-adapter.ts index 8cbe5d5..481c7ca 100644 --- a/src/lib/chat/adapters/nip-10-adapter.ts +++ b/src/lib/chat/adapters/nip-10-adapter.ts @@ -17,6 +17,7 @@ import type { Participant, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import { publishEventToRelays } from "@/services/hub"; @@ -26,10 +27,7 @@ import { mergeRelaySets } from "applesauce-core/helpers"; import { getOutboxes } from "applesauce-core/helpers/mailboxes"; import { getEventPointerFromETag } from "applesauce-core/helpers/pointers"; import { EventFactory } from "applesauce-core/event-factory"; -import { - NoteReplyBlueprint, - ReactionBlueprint, -} from "applesauce-common/blueprints"; +import { NoteReplyBlueprint, ReactionBlueprint } from "@/lib/blueprints"; import { getNip10References } from "applesauce-common/helpers"; import { getZapAmount, @@ -396,6 +394,7 @@ export class Nip10Adapter extends ChatProtocolAdapter { emojis: options?.emojiTags?.map((e) => ({ shortcode: e.shortcode, url: e.url, + address: e.address, })), }, ); @@ -425,7 +424,7 @@ export class Nip10Adapter 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; @@ -450,9 +449,7 @@ export class Nip10Adapter extends ChatProtocolAdapter { factory.setSigner(activeSigner); // Use ReactionBlueprint - auto-handles e-tag, k-tag, p-tag, custom emoji - const emojiArg = customEmoji - ? { shortcode: customEmoji.shortcode, url: customEmoji.url } - : emoji; + const emojiArg = customEmoji ?? emoji; const draft = await factory.create( ReactionBlueprint, diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 80c294b..30dad44 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -19,6 +19,7 @@ import type { ParticipantRole, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import type { ChatAction, GetActionsOptions } from "@/types/chat-actions"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; @@ -29,10 +30,7 @@ import { getEventPointerFromETag } from "applesauce-core/helpers/pointers"; import { mergeRelaySets } from "applesauce-core/helpers"; import { normalizeRelayURL } from "@/lib/relay-url"; import { EventFactory } from "applesauce-core/event-factory"; -import { - GroupMessageBlueprint, - ReactionBlueprint, -} from "applesauce-common/blueprints"; +import { GroupMessageBlueprint, ReactionBlueprint } from "@/lib/blueprints"; import { resolveGroupMetadata } from "@/lib/chat/group-metadata-helpers"; /** @@ -465,6 +463,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { emojis: options?.emojiTags?.map((e) => ({ shortcode: e.shortcode, url: e.url, + address: e.address, })), }, ); @@ -508,7 +507,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; @@ -538,9 +537,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { factory.setSigner(activeSigner); // Use ReactionBlueprint - auto-handles e-tag, k-tag, p-tag, custom emoji - const emojiArg = customEmoji - ? { shortcode: customEmoji.shortcode, url: customEmoji.url } - : emoji; + const emojiArg = customEmoji ?? emoji; const draft = await factory.create( ReactionBlueprint, diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 4627481..431c15d 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -23,6 +23,7 @@ import type { ParticipantRole, } from "@/types/chat"; import type { NostrEvent } from "@/types/nostr"; +import type { EmojiTag } from "@/lib/emoji-helpers"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import { publishEventToRelays } from "@/services/hub"; @@ -42,7 +43,7 @@ import { isValidZap, } from "applesauce-common/helpers/zap"; import { EventFactory } from "applesauce-core/event-factory"; -import { ReactionBlueprint } from "applesauce-common/blueprints"; +import { ReactionBlueprint } from "@/lib/blueprints"; /** * NIP-53 Adapter - Live Activity Chat @@ -466,7 +467,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]); + tags.push( + emoji.address + ? ["emoji", emoji.shortcode, emoji.url, emoji.address] + : ["emoji", emoji.shortcode, emoji.url], + ); } } @@ -496,7 +501,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; @@ -545,9 +550,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { factory.setSigner(activeSigner); // Use ReactionBlueprint - auto-handles e-tag, k-tag, p-tag, custom emoji - const emojiArg = customEmoji - ? { shortcode: customEmoji.shortcode, url: customEmoji.url } - : emoji; + const emojiArg = customEmoji ?? emoji; const draft = await factory.create( ReactionBlueprint, diff --git a/src/lib/create-zap-request.ts b/src/lib/create-zap-request.ts index 2381564..267354c 100644 --- a/src/lib/create-zap-request.ts +++ b/src/lib/create-zap-request.ts @@ -12,6 +12,8 @@ import { selectZapRelays } from "./zap-relay-selection"; export interface EmojiTag { shortcode: string; url: string; + /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ + address?: string; } export interface ZapRequestParams { @@ -124,7 +126,11 @@ export async function createZapRequest( // Add NIP-30 emoji tags if (params.emojiTags) { for (const emoji of params.emojiTags) { - tags.push(["emoji", emoji.shortcode, emoji.url]); + tags.push( + emoji.address + ? ["emoji", emoji.shortcode, emoji.url, emoji.address] + : ["emoji", emoji.shortcode, emoji.url], + ); } } diff --git a/src/lib/emoji-helpers.ts b/src/lib/emoji-helpers.ts index 709dcd9..802c037 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; + /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ + address?: string; } /** diff --git a/src/services/emoji-search.ts b/src/services/emoji-search.ts index 4b7dcd8..5c78a90 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; + /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ + address?: string; } export class EmojiSearchService { @@ -29,6 +31,7 @@ export class EmojiSearchService { shortcode: string, url: string, source: string = "custom", + address?: string, ): Promise { // Normalize shortcode (lowercase, no colons) const normalized = shortcode.toLowerCase().replace(/^:|:$/g, ""); @@ -43,6 +46,7 @@ export class EmojiSearchService { shortcode: normalized, url, source, + address, }; this.emojis.set(normalized, emoji); @@ -57,10 +61,16 @@ export class EmojiSearchService { const identifier = event.tags.find((t) => t[0] === "d")?.[1] || "unnamed-set"; + const address = `30030:${event.pubkey}:${identifier}`; const emojis = getEmojiTags(event); for (const emoji of emojis) { - await this.addEmoji(emoji.shortcode, emoji.url, `set:${identifier}`); + await this.addEmoji( + emoji.shortcode, + emoji.url, + `set:${identifier}`, + address, + ); } }