diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index d5487a3..72ce91e 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -12,6 +12,7 @@ import { Nip } from "./RichText/Nip"; import { Relay } from "./RichText/Relay"; import { nipReferences } from "@/lib/nip-transformer"; import { relayReferences } from "@/lib/relay-transformer"; +import { nostrMentionReferences } from "@/lib/nostr-mention-transformer"; import type { NostrEvent } from "@/types/nostr"; import type { Root } from "applesauce-content/nast"; @@ -23,8 +24,11 @@ const { useRenderedContent } = Hooks; // Custom cache key for our extended transformers const GrimoireContentSymbol = Symbol.for("grimoire-content"); -// Default transformers including our custom NIP and relay transformers +// Default transformers including our custom NIP, relay, and mention transformers +// IMPORTANT: nostrMentionReferences must come BEFORE textNoteTransformers +// to handle edge cases where mentions are immediately followed by text without whitespace export const defaultTransformers = [ + nostrMentionReferences, ...textNoteTransformers, nipReferences, relayReferences, diff --git a/src/lib/nostr-mention-transformer.test.ts b/src/lib/nostr-mention-transformer.test.ts new file mode 100644 index 0000000..925756f --- /dev/null +++ b/src/lib/nostr-mention-transformer.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from "vitest"; +import { nostrMentionReferences } from "./nostr-mention-transformer"; +import type { Root } from "applesauce-content/nast"; + +/** + * Helper to create a simple text tree for testing + */ +function createTextTree(content: string): Root { + return { + type: "root", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: content, + }, + ], + } as any, // Type assertion needed for test setup + ], + }; +} + +/** + * Helper to extract text and mention nodes from a transformed tree + */ +function extractNodes( + tree: Root, +): Array<{ type: string; value?: string; decoded?: any }> { + const results: Array<{ type: string; value?: string; decoded?: any }> = []; + + for (const child of tree.children as any[]) { + if (child.type === "paragraph" && "children" in child) { + for (const node of child.children) { + if (node.type === "text" && "value" in node) { + results.push({ type: "text", value: node.value as string }); + } else if (node.type === "mention" && "decoded" in node) { + results.push({ type: "mention", decoded: node.decoded }); + } + } + } + } + + return results; +} + +describe("nostrMentionReferences", () => { + describe("npub mentions", () => { + it("should parse npub mention with space after", () => { + const tree = createTextTree( + "Hello nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg world", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: "text", value: "Hello " }); + expect(nodes[1].type).toBe("mention"); + expect(nodes[1].decoded?.type).toBe("npub"); + expect(nodes[2]).toEqual({ type: "text", value: " world" }); + }); + + it("should parse npub mention immediately followed by text without space", () => { + // This is the bug case from the issue - npub followed by "how" without space + const tree = createTextTree( + "nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxghow does the COUNT work?", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(2); + expect(nodes[0].type).toBe("mention"); + expect(nodes[0].decoded?.type).toBe("npub"); + // The "how does the COUNT work?" should remain as text + expect(nodes[1]).toEqual({ + type: "text", + value: "how does the COUNT work?", + }); + }); + + it("should parse npub mention followed by punctuation", () => { + const tree = createTextTree( + "Check out nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg!", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: "text", value: "Check out " }); + expect(nodes[1].type).toBe("mention"); + expect(nodes[1].decoded?.type).toBe("npub"); + expect(nodes[2]).toEqual({ type: "text", value: "!" }); + }); + + it("should not match nostr: without valid prefix", () => { + const tree = createTextTree("nostr:invalid123"); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toEqual({ type: "text", value: "nostr:invalid123" }); + }); + }); + + describe("note mentions", () => { + it("should fallback to text for invalid note mention (wrong checksum)", () => { + // Note with invalid checksum will be matched by pattern but fail decode, falling back to text + const tree = createTextTree( + "See nostr:note1q9m4f9qx3p2l8zq3xz9h3whxuc2h69k2xy6x5agdchp3upynhsxqqdhcxs test", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + // Pattern matches, but decode fails, so matched text + surrounding text remain + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: "text", value: "See " }); + expect(nodes[1].type).toBe("text"); // Failed mention becomes text + expect(nodes[1].value).toContain("nostr:note1"); + expect(nodes[2]).toEqual({ type: "text", value: " test" }); + }); + }); + + describe("nevent mentions", () => { + it("should not parse nevent that is too short", () => { + // nevent that's less than 40 chars after prefix won't match our pattern + const tree = createTextTree("Check nostr:nevent1short"); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + // Should remain as text because nevent is too short + expect(nodes).toHaveLength(1); + expect(nodes[0]).toEqual({ + type: "text", + value: "Check nostr:nevent1short", + }); + }); + }); + + describe("multiple mentions", () => { + it("should parse multiple mentions in one string", () => { + const tree = createTextTree( + "nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg and nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg!", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + // Should have: mention, text(" and "), mention, text("!") + expect(nodes.length).toBe(4); + expect(nodes[0].type).toBe("mention"); + expect(nodes[1]).toEqual({ type: "text", value: " and " }); + expect(nodes[2].type).toBe("mention"); + expect(nodes[3]).toEqual({ type: "text", value: "!" }); + }); + }); + + describe("edge cases", () => { + it("should not match nostr: in middle of word", () => { + const tree = createTextTree( + "testnostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + // Should remain as text because no word boundary before nostr: + expect(nodes).toHaveLength(1); + expect(nodes[0]).toEqual({ + type: "text", + value: + "testnostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg", + }); + }); + + it("should handle mention at start of string", () => { + const tree = createTextTree( + "nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("mention"); + }); + + it("should handle mention at end of string", () => { + const tree = createTextTree( + "Check out nostr:npub107jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2quqncxg", + ); + const transformer = nostrMentionReferences(); + transformer(tree); + + const nodes = extractNodes(tree); + expect(nodes).toHaveLength(2); + expect(nodes[0]).toEqual({ type: "text", value: "Check out " }); + expect(nodes[1].type).toBe("mention"); + }); + }); +}); diff --git a/src/lib/nostr-mention-transformer.ts b/src/lib/nostr-mention-transformer.ts new file mode 100644 index 0000000..c4bc49a --- /dev/null +++ b/src/lib/nostr-mention-transformer.ts @@ -0,0 +1,81 @@ +import { findAndReplace } from "applesauce-content/nast"; +import type { Root, Content } from "applesauce-content/nast"; +import { nip19 } from "nostr-tools"; + +/** + * Custom node type for nostr mentions + */ +export interface NostrMentionNode { + type: "mention"; + /** The decoded nip19 entity */ + decoded?: { + type: string; + data: any; + }; + /** The original encoded string (without nostr: prefix) */ + encoded?: string; + /** The raw matched text (including nostr: prefix) */ + raw: string; +} + +/** + * Match nostr: URIs with proper word boundaries and length constraints + * + * Pattern explanation: + * - \bnostr: - word boundary + nostr: prefix + * - Two alternatives: + * 1. (npub1|note1)[bech32]{58} - fixed-length identifiers (32-byte pubkey/event ID) + * 2. (nprofile1|nevent1|naddr1)[bech32]{40,300} - variable-length TLV-encoded identifiers + * - [023456789acdefghjklmnpqrstuvwxyz] - bech32 character set (excludes 1, b, i, o) + * + * Length constraints prevent matching too many characters when text immediately follows: + * - "nostr:npub1...{58 chars}how" will match only the 63-char npub, not including "how" + * - This works because npub/note are always exactly 63 chars (including prefix) + * - Other types are TLV-encoded and vary, but typically 100-200 chars + */ +const NOSTR_MENTION_PATTERN = + /\bnostr:((?:(?:npub1|note1)[023456789acdefghjklmnpqrstuvwxyz]{58})|(?:(?:nprofile1|nevent1|naddr1)[023456789acdefghjklmnpqrstuvwxyz]{40,300}))/gi; + +/** + * Transformer that finds nostr: mentions and converts them to mention nodes. + * Compatible with applesauce-content's transformer pipeline. + * + * This transformer runs BEFORE the default textNoteTransformers to handle + * edge cases where mentions are immediately followed by text without whitespace. + */ +export function nostrMentionReferences() { + return (tree: Root) => { + findAndReplace(tree, [ + [ + NOSTR_MENTION_PATTERN, + (full, encoded) => { + try { + // Decode the nip19 identifier + const decoded = nip19.decode(encoded); + + // Return a mention node with decoded data + return { + type: "mention", + decoded: { + type: decoded.type, + data: decoded.data, + }, + encoded, + raw: full, + } as unknown as Content; + } catch (error) { + // If decode fails, return as plain text + console.warn( + `[nostr-mention-transformer] Failed to decode ${encoded}:`, + error, + ); + return { + type: "text", + value: full, + } as unknown as Content; + } + }, + ], + ]); + }; +}