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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-15 11:33:27 +01:00
committed by GitHub
parent 64c181dd87
commit 72eca99c2e
4 changed files with 409 additions and 4 deletions

View File

@@ -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<number>(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<string, React.ComponentType<{ node: any }>> = {
text: Text,
hashtag: Hashtag,
@@ -94,11 +100,12 @@ const contentComponents: Record<string, React.ComponentType<{ node: any }>> = {
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({

View File

@@ -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 (
<button
onClick={openRelay}
className="text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair"
title={url}
>
{displayUrl}
</button>
);
}

View File

@@ -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<T>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<Text>(tree, "text");
const relays = getNodesOfType<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(tree, "relay");
expect(relays).toHaveLength(0);
const texts = getNodesOfType<Text>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(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<RelayNode>(tree, "relay");
expect(relays).toHaveLength(1);
expect(relays[0].url).toBe(url);
}
});
});
});

View File

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