diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index 1387729..8ef1980 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -1,16 +1,29 @@ import { cn } from "@/lib/utils"; import { Hooks } from "applesauce-react"; -import { createContext, useContext } from "react"; +import { textNoteTransformers } from "applesauce-content/text"; +import { createContext, useContext, useState, useMemo } from "react"; import { Text } from "./RichText/Text"; import { Hashtag } from "./RichText/Hashtag"; import { Mention } from "./RichText/Mention"; import { Link } from "./RichText/Link"; import { Emoji } from "./RichText/Emoji"; import { Gallery } from "./RichText/Gallery"; +import { Nip } from "./RichText/Nip"; +import { nipReferences } from "@/lib/nip-transformer"; import type { NostrEvent } from "@/types/nostr"; +import type { Root } from "applesauce-content/nast"; + +/** Transformer function type compatible with applesauce-content */ +type ContentTransformer = () => (tree: Root) => void; const { useRenderedContent } = Hooks; +// Custom cache key for our extended transformers +const GrimoireContentSymbol = Symbol.for("grimoire-content"); + +// Default transformers including our custom NIP transformer +const defaultTransformers = [...textNoteTransformers, nipReferences]; + // Context for passing depth through RichText rendering const DepthContext = createContext(1); @@ -50,28 +63,44 @@ export function useRichTextOptions() { return useContext(OptionsContext); } +/** + * Parser options for customizing content parsing + */ +export interface ParserOptions { + /** Custom transformers to use instead of defaults */ + transformers?: ContentTransformer[]; + /** Maximum content length before truncation (characters) */ + maxLength?: number; + /** Custom cache key (pass null to disable caching) */ + cacheKey?: symbol | null; +} + interface RichTextProps { event?: NostrEvent; content?: string; className?: string; depth?: number; options?: RichTextOptions; + /** Parser options for customizing content parsing */ + parserOptions?: ParserOptions; children?: React.ReactNode; } // Content node component types for rendering -const contentComponents = { +// Using 'any' for node type since we extend with custom node types (like 'nip') +const contentComponents: Record> = { text: Text, hashtag: Hashtag, mention: Mention, link: Link, emoji: Emoji, gallery: Gallery, + nip: Nip, }; /** * RichText component that renders Nostr event content with rich formatting - * Supports mentions, hashtags, links, emojis, and galleries + * Supports mentions, hashtags, links, emojis, galleries, and NIP references * Can also render plain text without requiring a full event */ export function RichText({ @@ -80,14 +109,47 @@ export function RichText({ className = "", depth = 1, options = {}, + parserOptions = {}, children, }: RichTextProps) { + const [isExpanded, setIsExpanded] = useState(false); + // Merge provided options with defaults const mergedOptions: Required = { ...defaultOptions, ...options, }; + // Get content string for length checking + const contentString = content ?? event?.content ?? ""; + + // Determine if content might need truncation + const maxLength = parserOptions.maxLength; + const mightBeTruncated = maxLength && contentString.length > maxLength; + + // Use effective maxLength based on expansion state + const effectiveMaxLength = + isExpanded || !mightBeTruncated ? undefined : maxLength; + + // Prepare transformers - use provided or defaults + const transformers = parserOptions.transformers ?? defaultTransformers; + + // Prepare cache key - use provided, or default, or null to disable + const cacheKey = + parserOptions.cacheKey === null + ? null + : (parserOptions.cacheKey ?? GrimoireContentSymbol); + + // Memoize hook options to prevent unnecessary re-renders + const hookOptions = useMemo( + () => ({ + transformers, + maxLength: effectiveMaxLength, + cacheKey, + }), + [transformers, effectiveMaxLength, cacheKey], + ); + // Call hook unconditionally - it will handle undefined/null const trimmedEvent = event ? { @@ -95,6 +157,7 @@ export function RichText({ content: event.content.trim(), } : undefined; + const renderedContent = useRenderedContent( content ? ({ @@ -102,8 +165,12 @@ export function RichText({ } as NostrEvent) : trimmedEvent, contentComponents, + hookOptions, ); + // Show expand button only when content is truncated and not expanded + const showExpandButton = mightBeTruncated && !isExpanded; + return ( @@ -113,6 +180,14 @@ export function RichText({ > {children} {renderedContent} + {showExpandButton && ( + + )} diff --git a/src/components/nostr/RichText/Nip.tsx b/src/components/nostr/RichText/Nip.tsx new file mode 100644 index 0000000..694137e --- /dev/null +++ b/src/components/nostr/RichText/Nip.tsx @@ -0,0 +1,34 @@ +import type { NipNode } from "@/lib/nip-transformer"; +import { useGrimoire } from "@/core/state"; +import { getNIPInfo } from "@/lib/nip-icons"; + +interface NipNodeProps { + node: NipNode; +} + +/** + * Renders a NIP reference as a clickable link that opens the NIP viewer + */ +export function Nip({ node }: NipNodeProps) { + const { addWindow } = useGrimoire(); + const { number, raw } = node; + const nipInfo = getNIPInfo(number); + + const openNIP = () => { + addWindow( + "nip", + { number }, + nipInfo ? `NIP ${number} - ${nipInfo.name}` : `NIP ${number}`, + ); + }; + + return ( + + ); +} diff --git a/src/lib/nip-transformer.test.ts b/src/lib/nip-transformer.test.ts new file mode 100644 index 0000000..e538f01 --- /dev/null +++ b/src/lib/nip-transformer.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; +import { nipReferences, NipNode } from "./nip-transformer"; +import type { Root, Text } from "applesauce-content/nast"; + +// Helper to create a basic tree with text content +function createTree(content: string): Root { + return { + type: "root", + children: [{ type: "text", value: content }], + }; +} + +// Helper to get all nodes of a specific type from the tree +function getNodesOfType(tree: Root, type: string): T[] { + return tree.children.filter((node) => node.type === type) as T[]; +} + +describe("nipReferences transformer", () => { + describe("basic NIP patterns", () => { + it("should parse NIP-01 format", () => { + const tree = createTree("Check out NIP-01 for the basics"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("01"); + expect(nips[0].raw).toBe("NIP-01"); + }); + + it("should parse lowercase nip-01 format", () => { + const tree = createTree("See nip-01 for details"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("01"); + expect(nips[0].raw).toBe("nip-01"); + }); + + it("should parse single digit NIP-1", () => { + const tree = createTree("NIP-1 is important"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("01"); // Normalized to 2 digits + expect(nips[0].raw).toBe("NIP-1"); + }); + + it("should parse three digit NIP-100", () => { + const tree = createTree("Future NIP-100 might exist"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("100"); + expect(nips[0].raw).toBe("NIP-100"); + }); + }); + + describe("multiple NIPs in content", () => { + it("should parse multiple NIP references", () => { + const tree = createTree("NIP-01, NIP-19, and NIP-30 are related"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(3); + expect(nips[0].number).toBe("01"); + expect(nips[1].number).toBe("19"); + expect(nips[2].number).toBe("30"); + }); + + it("should preserve text between NIP references", () => { + const tree = createTree("See NIP-01 and NIP-02"); + const transformer = nipReferences(); + transformer(tree); + + const texts = getNodesOfType(tree, "text"); + const nips = getNodesOfType(tree, "nip"); + + expect(nips).toHaveLength(2); + expect(texts.some((t) => t.value.includes("See "))).toBe(true); + expect(texts.some((t) => t.value.includes(" and "))).toBe(true); + }); + }); + + describe("edge cases", () => { + it("should not match NIP without number", () => { + const tree = createTree("NIP- is not valid"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(0); + }); + + it("should not match partial word like SNIP-01", () => { + const tree = createTree("SNIP-01 is not a NIP"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(0); + }); + + it("should handle NIP at start of content", () => { + const tree = createTree("NIP-01 defines the protocol"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("01"); + }); + + it("should handle NIP at end of content", () => { + const tree = createTree("Check NIP-01"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(1); + expect(nips[0].number).toBe("01"); + }); + + it("should handle content with no NIPs", () => { + const tree = createTree("Just some regular text"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips).toHaveLength(0); + + const texts = getNodesOfType(tree, "text"); + expect(texts).toHaveLength(1); + expect(texts[0].value).toBe("Just some regular text"); + }); + }); + + describe("number normalization", () => { + it("should normalize single digit to 2 digits", () => { + const tree = createTree("NIP-1 NIP-2 NIP-9"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips[0].number).toBe("01"); + expect(nips[1].number).toBe("02"); + expect(nips[2].number).toBe("09"); + }); + + it("should preserve two digit numbers", () => { + const tree = createTree("NIP-19 NIP-50"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips[0].number).toBe("19"); + expect(nips[1].number).toBe("50"); + }); + + it("should preserve three digit numbers", () => { + const tree = createTree("NIP-100 NIP-999"); + const transformer = nipReferences(); + transformer(tree); + + const nips = getNodesOfType(tree, "nip"); + expect(nips[0].number).toBe("100"); + expect(nips[1].number).toBe("999"); + }); + }); +}); diff --git a/src/lib/nip-transformer.ts b/src/lib/nip-transformer.ts new file mode 100644 index 0000000..7a4c40c --- /dev/null +++ b/src/lib/nip-transformer.ts @@ -0,0 +1,41 @@ +import { findAndReplace } from "applesauce-content/nast"; +import type { Root, Content } from "applesauce-content/nast"; + +/** + * Custom node type for NIP references + */ +export interface NipNode { + type: "nip"; + /** The NIP number (e.g., "01", "19", "30") */ + number: string; + /** The raw matched text (e.g., "NIP-01", "nip-19") */ + raw: string; +} + +// Match NIP-xx patterns (case insensitive, 1-3 digits) +// Supports: NIP-01, NIP-1, nip-19, NIP-100, etc. +const NIP_PATTERN = /\bNIP-(\d{1,3})\b/gi; + +/** + * Transformer that finds NIP-xx references and converts them to nip nodes. + * Compatible with applesauce-content's transformer pipeline. + */ +export function nipReferences() { + return (tree: Root) => { + findAndReplace(tree, [ + [ + NIP_PATTERN, + (full, number) => { + // Normalize to 2 digits with leading zero for consistency + const normalized = number.padStart(2, "0"); + // Cast to Content since we're extending with a custom node type + return { + type: "nip", + number: normalized, + raw: full, + } as unknown as Content; + }, + ], + ]); + }; +}