mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
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:
@@ -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({
|
||||
|
||||
39
src/components/nostr/RichText/Relay.tsx
Normal file
39
src/components/nostr/RichText/Relay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
314
src/lib/relay-transformer.test.ts
Normal file
314
src/lib/relay-transformer.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
45
src/lib/relay-transformer.ts
Normal file
45
src/lib/relay-transformer.ts
Normal 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;
|
||||
},
|
||||
],
|
||||
]);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user