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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2026-03-02 21:06:58 +01:00
parent 5c1d7c0c63
commit cffb981ad1
13 changed files with 260 additions and 33 deletions

180
src/lib/blueprints.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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