From 72eca99c2e004d08fc9359137b997085be84758e Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 15 Jan 2026 11:33:27 +0100 Subject: [PATCH] Add custom parsing for relay links (#102) * feat: Add relay link parsing and rendering to rich text Implements custom parsing for relay URLs (wss:// and ws://) in text content, automatically converting them to clickable links that open the relay viewer. Changes: - Add relay-transformer.ts with pattern matching for relay URLs - Create Relay.tsx component for rendering relay links inline - Register relay transformer in RichText component pipeline - Add comprehensive test suite (26 tests) covering all URL formats Supported formats: - wss:// and ws:// protocols - Domains, subdomains, and IP addresses - Custom ports, paths, query parameters - Multiple relay URLs in single message All tests passing (864/864). No breaking changes. * refactor: Update relay link styling to match other inline links - Use muted/underline styling consistent with NIP references - Remove icons and show only relay name (formatted) - Display full URL in tooltip - Match text size with surrounding content - Simplify component by not using RelayLink wrapper --------- Co-authored-by: Claude --- src/components/nostr/RichText.tsx | 15 +- src/components/nostr/RichText/Relay.tsx | 39 +++ src/lib/relay-transformer.test.ts | 314 ++++++++++++++++++++++++ src/lib/relay-transformer.ts | 45 ++++ 4 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 src/components/nostr/RichText/Relay.tsx create mode 100644 src/lib/relay-transformer.test.ts create mode 100644 src/lib/relay-transformer.ts diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index ca24d6a..d5487a3 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -9,7 +9,9 @@ import { Link } from "./RichText/Link"; import { Emoji } from "./RichText/Emoji"; import { Gallery } from "./RichText/Gallery"; import { Nip } from "./RichText/Nip"; +import { Relay } from "./RichText/Relay"; import { nipReferences } from "@/lib/nip-transformer"; +import { relayReferences } from "@/lib/relay-transformer"; import type { NostrEvent } from "@/types/nostr"; import type { Root } from "applesauce-content/nast"; @@ -21,8 +23,12 @@ const { useRenderedContent } = Hooks; // Custom cache key for our extended transformers const GrimoireContentSymbol = Symbol.for("grimoire-content"); -// Default transformers including our custom NIP transformer -export const defaultTransformers = [...textNoteTransformers, nipReferences]; +// Default transformers including our custom NIP and relay transformers +export const defaultTransformers = [ + ...textNoteTransformers, + nipReferences, + relayReferences, +]; // Context for passing depth through RichText rendering const DepthContext = createContext(1); @@ -85,7 +91,7 @@ interface RichTextProps { } // Content node component types for rendering -// Using 'any' for node type since we extend with custom node types (like 'nip') +// Using 'any' for node type since we extend with custom node types (like 'nip', 'relay') const contentComponents: Record> = { text: Text, hashtag: Hashtag, @@ -94,11 +100,12 @@ const contentComponents: Record> = { emoji: Emoji, gallery: Gallery, nip: Nip, + relay: Relay, }; /** * RichText component that renders Nostr event content with rich formatting - * Supports mentions, hashtags, links, emojis, galleries, and NIP references + * Supports mentions, hashtags, links, emojis, galleries, NIP references, and relay links * Can also render plain text without requiring a full event */ export function RichText({ diff --git a/src/components/nostr/RichText/Relay.tsx b/src/components/nostr/RichText/Relay.tsx new file mode 100644 index 0000000..6e2d31c --- /dev/null +++ b/src/components/nostr/RichText/Relay.tsx @@ -0,0 +1,39 @@ +import type { RelayNode } from "@/lib/relay-transformer"; +import { useGrimoire } from "@/core/state"; + +interface RelayNodeProps { + node: RelayNode; +} + +/** + * Format relay URL for display by removing protocol and trailing slashes + */ +function formatRelayUrlForDisplay(url: string): string { + return url + .replace(/^wss?:\/\//, "") // Remove ws:// or wss:// + .replace(/\/$/, ""); // Remove trailing slash +} + +/** + * Renders a relay URL as a clickable link that opens the relay viewer + */ +export function Relay({ node }: RelayNodeProps) { + const { addWindow } = useGrimoire(); + const { url } = node; + + const displayUrl = formatRelayUrlForDisplay(url); + + const openRelay = () => { + addWindow("relay", { url }); + }; + + return ( + + ); +} diff --git a/src/lib/relay-transformer.test.ts b/src/lib/relay-transformer.test.ts new file mode 100644 index 0000000..c99feea --- /dev/null +++ b/src/lib/relay-transformer.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect } from "vitest"; +import { relayReferences, RelayNode } from "./relay-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("relayReferences transformer", () => { + describe("basic relay patterns", () => { + it("should parse wss:// relay URL", () => { + const tree = createTree("Check out wss://relay.example.com for events"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com"); + expect(relays[0].raw).toBe("wss://relay.example.com"); + }); + + it("should parse ws:// relay URL", () => { + const tree = createTree("Local relay at ws://localhost:7777"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("ws://localhost:7777"); + }); + + it("should parse relay URL with trailing slash", () => { + const tree = createTree("Connect to wss://relay.damus.io/"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.damus.io/"); + }); + + it("should parse relay URL with path", () => { + const tree = createTree("Try wss://relay.example.com/some/path"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com/some/path"); + }); + + it("should parse relay URL with query params", () => { + const tree = createTree("wss://relay.example.com?param=value"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com?param=value"); + }); + }); + + describe("relay URL formats", () => { + it("should parse relay with subdomain", () => { + const tree = createTree("wss://nostr.relay.example.com"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://nostr.relay.example.com"); + }); + + it("should parse relay with port", () => { + const tree = createTree("wss://relay.example.com:443"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com:443"); + }); + + it("should parse relay with non-standard port", () => { + const tree = createTree("ws://localhost:8080"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("ws://localhost:8080"); + }); + + it("should parse relay with IP address", () => { + const tree = createTree("wss://192.168.1.100"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://192.168.1.100"); + }); + + it("should parse relay with IP and port", () => { + const tree = createTree("ws://127.0.0.1:7777"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("ws://127.0.0.1:7777"); + }); + }); + + describe("multiple relays in content", () => { + it("should parse multiple relay URLs", () => { + const tree = createTree( + "wss://relay.damus.io and wss://nos.lol are good relays", + ); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(2); + expect(relays[0].url).toBe("wss://relay.damus.io"); + expect(relays[1].url).toBe("wss://nos.lol"); + }); + + it("should preserve text between relay URLs", () => { + const tree = createTree("Try wss://relay.damus.io or wss://nos.lol"); + const transformer = relayReferences(); + transformer(tree); + + const texts = getNodesOfType(tree, "text"); + const relays = getNodesOfType(tree, "relay"); + + expect(relays).toHaveLength(2); + expect(texts.some((t) => t.value.includes("Try "))).toBe(true); + expect(texts.some((t) => t.value.includes(" or "))).toBe(true); + }); + + it("should parse relay list with commas", () => { + const tree = createTree( + "wss://relay.damus.io, wss://nos.lol, wss://relay.snort.social", + ); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(3); + expect(relays[0].url).toBe("wss://relay.damus.io"); + expect(relays[1].url).toBe("wss://nos.lol"); + expect(relays[2].url).toBe("wss://relay.snort.social"); + }); + }); + + describe("edge cases", () => { + it("should handle relay at start of content", () => { + const tree = createTree("wss://relay.damus.io is a great relay"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.damus.io"); + }); + + it("should handle relay at end of content", () => { + const tree = createTree("Connect to wss://relay.damus.io"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.damus.io"); + }); + + it("should handle content with no relays", () => { + const tree = createTree("Just some regular text"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(0); + + const texts = getNodesOfType(tree, "text"); + expect(texts).toHaveLength(1); + expect(texts[0].value).toBe("Just some regular text"); + }); + + it("should handle relay in parentheses", () => { + const tree = createTree("Try this relay (wss://relay.damus.io)"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.damus.io"); + }); + + it("should handle relay in quotes", () => { + const tree = createTree('Use "wss://relay.damus.io" for events'); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.damus.io"); + }); + + it("should not match incomplete protocol", () => { + const tree = createTree("wss:// is not complete"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(0); + }); + + it("should not match http:// URLs", () => { + const tree = createTree("http://example.com is not a relay"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(0); + }); + + it("should not match https:// URLs", () => { + const tree = createTree("https://example.com is not a relay"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(0); + }); + }); + + describe("special relay formats", () => { + it("should parse relay with dashes in domain", () => { + const tree = createTree("wss://nostr-relay.example.com"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://nostr-relay.example.com"); + }); + + it("should parse relay with complex path", () => { + const tree = createTree("wss://relay.example.com/nostr/v1/ws"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com/nostr/v1/ws"); + }); + + it("should parse relay with hash fragment", () => { + const tree = createTree("wss://relay.example.com#main"); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe("wss://relay.example.com"); + }); + + it("should handle mixed ws:// and wss:// in same content", () => { + const tree = createTree( + "Use wss://relay.damus.io for production and ws://localhost:7777 for testing", + ); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(2); + expect(relays[0].url).toBe("wss://relay.damus.io"); + expect(relays[1].url).toBe("ws://localhost:7777"); + }); + }); + + describe("real-world relay URLs", () => { + it("should parse popular relay URLs", () => { + const relayUrls = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://relay.nostr.band", + "wss://nostr.wine", + ]; + + for (const url of relayUrls) { + const tree = createTree(`Connect to ${url}`); + const transformer = relayReferences(); + transformer(tree); + + const relays = getNodesOfType(tree, "relay"); + expect(relays).toHaveLength(1); + expect(relays[0].url).toBe(url); + } + }); + }); +}); diff --git a/src/lib/relay-transformer.ts b/src/lib/relay-transformer.ts new file mode 100644 index 0000000..2f68a6d --- /dev/null +++ b/src/lib/relay-transformer.ts @@ -0,0 +1,45 @@ +import { findAndReplace } from "applesauce-content/nast"; +import type { Root, Content } from "applesauce-content/nast"; + +/** + * Custom node type for relay references + */ +export interface RelayNode { + type: "relay"; + /** The relay URL (e.g., "wss://relay.example.com", "ws://localhost:7777") */ + url: string; + /** The raw matched text */ + raw: string; +} + +// Match relay URLs (wss:// or ws://) +// Pattern matches: +// - wss:// or ws:// protocol +// - hostname (domain or IP) +// - optional port +// - optional path/query/fragment +// Word boundary at start ensures we don't match mid-word +const RELAY_PATTERN = + /\b(wss?:\/\/[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9](:[0-9]+)?(?:[/?][^\s]*)?)/gi; + +/** + * Transformer that finds relay URLs and converts them to relay nodes. + * Compatible with applesauce-content's transformer pipeline. + */ +export function relayReferences() { + return (tree: Root) => { + findAndReplace(tree, [ + [ + RELAY_PATTERN, + (full) => { + // Cast to Content since we're extending with a custom node type + return { + type: "relay", + url: full, + raw: full, + } as unknown as Content; + }, + ], + ]); + }; +}