diff --git a/.gitignore b/.gitignore
index a547bf3..ce060ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Logs
logs
*.log
+*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..d7a8624
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,25 @@
+# TODO
+
+## Known Issues
+
+### RTL Support in Rich Text
+**Priority**: Medium
+**File**: `src/components/nostr/RichText/Text.tsx`
+
+Current RTL implementation is partial and has limitations:
+- RTL text direction works (`dir` attribute on spans)
+- RTL text alignment (right-align) doesn't work properly with inline elements
+- Mixed LTR/RTL content with inline elements (hashtags, mentions) creates layout conflicts
+
+**The core problem**:
+- Inline elements (hashtags, mentions) need inline flow to stay on same line
+- RTL alignment requires block-level containers
+- These two requirements conflict
+
+**Potential solutions to explore**:
+1. Line-aware rendering at RichText component level (parse and group by lines)
+2. CSS-based approach with unicode-bidi and direction properties
+3. Separate rendering paths for pure RTL content vs mixed content
+4. Accept partial RTL support and document limitations
+
+**Test case**: Arabic text with hashtags on same line should display properly with right-alignment.
diff --git a/src/components/Home.tsx b/src/components/Home.tsx
index 3acaf07..8e342b8 100644
--- a/src/components/Home.tsx
+++ b/src/components/Home.tsx
@@ -103,6 +103,9 @@ export default function Home() {
case "decode":
content = ;
break;
+ case "relay":
+ content = ;
+ break;
default:
content = (
diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx
index 1b05622..2697087 100644
--- a/src/components/JsonViewer.tsx
+++ b/src/components/JsonViewer.tsx
@@ -1,5 +1,4 @@
-import { useState } from "react";
-import { Copy, Check } from "lucide-react";
+import { Check, Copy } from "lucide-react";
import {
Dialog,
DialogContent,
diff --git a/src/components/NIPBadge.tsx b/src/components/NIPBadge.tsx
new file mode 100644
index 0000000..a32cc39
--- /dev/null
+++ b/src/components/NIPBadge.tsx
@@ -0,0 +1,46 @@
+import { getNIPInfo } from "../lib/nip-icons";
+import { useGrimoire } from "@/core/state";
+
+export interface NIPBadgeProps {
+ nipNumber: number;
+ className?: string;
+ showName?: boolean;
+}
+
+/**
+ * NIPBadge - Reusable component for displaying NIP badges
+ * Shows icon, number, optional name, and links to NIP page
+ */
+export function NIPBadge({
+ nipNumber,
+ className = "",
+ showName = true,
+}: NIPBadgeProps) {
+ const { addWindow } = useGrimoire();
+ const nipInfo = getNIPInfo(nipNumber);
+ const name = nipInfo?.name || `NIP-${nipNumber}`;
+ const description =
+ nipInfo?.description || `Nostr Implementation Possibility ${nipNumber}`;
+
+ const openNIP = () => {
+ const paddedNum = nipNumber.toString().padStart(2, "0");
+ addWindow(
+ "nip",
+ { number: paddedNum },
+ nipInfo ? `NIP ${paddedNum} - ${nipInfo?.name}` : `NIP ${paddedNum}`,
+ );
+ };
+
+ return (
+
+ {nipNumber}
+ {showName && nipInfo && (
+ ยท {name}
+ )}
+
+ );
+}
diff --git a/src/components/RelayViewer.tsx b/src/components/RelayViewer.tsx
new file mode 100644
index 0000000..d56fee8
--- /dev/null
+++ b/src/components/RelayViewer.tsx
@@ -0,0 +1,88 @@
+import { Copy, Check, Server } from "lucide-react";
+import { useRelayInfo } from "../hooks/useRelayInfo";
+import { useCopy } from "../hooks/useCopy";
+import { Button } from "./ui/button";
+import { UserName } from "./nostr/UserName";
+import { NIPBadge } from "./NIPBadge";
+
+export interface RelayViewerProps {
+ url: string;
+}
+
+export function RelayViewer({ url }: RelayViewerProps) {
+ const info = useRelayInfo(url);
+ const { copy, copied } = useCopy();
+
+ return (
+
+ {/* Header */}
+
+ {info?.icon ? (
+
+ ) : (
+
+ )}
+
+
+ {info?.name || "Unknown Relay"}
+
+
+ {url}
+ copy(url)}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ {info?.description && (
+
{info.description}
+ )}
+
+
+
+ {/* Operator */}
+ {(info?.contact || info?.pubkey) && (
+
+
Operator
+
+ {info.contact && info.contact.length == 64 && (
+
+ )}
+ {info.pubkey && info.pubkey.length === 64 && (
+
+ )}
+
+
+ )}
+
+ {/* Software */}
+ {(info?.software || info?.version) && (
+
+
Software
+
+ {info.software || info.version}
+
+
+ )}
+
+ {/* Supported NIPs */}
+ {info?.supported_nips && info.supported_nips.length > 0 && (
+
+
Supported NIPs
+
+ {info.supported_nips.map((num: number) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useRelayInfo.ts b/src/hooks/useRelayInfo.ts
new file mode 100644
index 0000000..f760a4b
--- /dev/null
+++ b/src/hooks/useRelayInfo.ts
@@ -0,0 +1,51 @@
+import { useLiveQuery } from "dexie-react-hooks";
+import { useEffect } from "react";
+import { RelayInformation } from "../types/nip11";
+import { fetchRelayInfo } from "../lib/nip11";
+import db from "../services/db";
+
+/**
+ * React hook to fetch and cache relay information (NIP-11)
+ * @param wsUrl - WebSocket URL of the relay (ws:// or wss://)
+ * @returns Relay information or undefined if not yet loaded
+ */
+export function useRelayInfo(wsUrl: string | undefined): RelayInformation | undefined {
+ const cached = useLiveQuery(
+ () => (wsUrl ? db.relayInfo.get(wsUrl) : undefined),
+ [wsUrl],
+ );
+
+ useEffect(() => {
+ if (!wsUrl) return;
+ if (cached) return;
+
+ // Fetch relay info if not in cache
+ fetchRelayInfo(wsUrl).then((info) => {
+ if (info) {
+ db.relayInfo.put({
+ url: wsUrl,
+ info,
+ fetchedAt: Date.now(),
+ });
+ }
+ });
+ }, [cached, wsUrl]);
+
+ return cached?.info;
+}
+
+/**
+ * React hook to check if a relay supports a specific NIP
+ * @param wsUrl - WebSocket URL of the relay
+ * @param nipNumber - NIP number to check (e.g., 42 for NIP-42)
+ * @returns true if supported, false if not, undefined if not yet loaded
+ */
+export function useRelaySupportsNip(
+ wsUrl: string | undefined,
+ nipNumber: number,
+): boolean | undefined {
+ const info = useRelayInfo(wsUrl);
+
+ if (!info) return undefined;
+ return info.supported_nips?.includes(nipNumber) ?? false;
+}
diff --git a/src/lib/nip-icons.ts b/src/lib/nip-icons.ts
new file mode 100644
index 0000000..eec6567
--- /dev/null
+++ b/src/lib/nip-icons.ts
@@ -0,0 +1,577 @@
+/**
+ * NIP Icon Mapping
+ * Maps NIP numbers to Lucide icons for visual representation
+ */
+
+import {
+ FileText,
+ Lock,
+ Hash,
+ MessageSquare,
+ Tag,
+ Image,
+ Link,
+ Zap,
+ Key,
+ Shield,
+ Search,
+ Calendar,
+ Users,
+ Mail,
+ Gift,
+ Flag,
+ AlertCircle,
+ Globe,
+ Server,
+ Database,
+ Eye,
+ Heart,
+ Star,
+ Bookmark,
+ Share2,
+ Filter,
+ Coins,
+ Video,
+ Music,
+ Code,
+ ShoppingCart,
+ GitBranch,
+ Package,
+ Wallet,
+ Radio,
+ Compass,
+ Gamepad2,
+ type LucideIcon,
+} from "lucide-react";
+
+export interface NIPInfo {
+ number: number;
+ name: string;
+ description: string;
+ icon: LucideIcon;
+ deprecated?: boolean;
+}
+
+export const NIP_METADATA: Record
= {
+ // Core Protocol
+ 1: {
+ number: 1,
+ name: "Basic Protocol",
+ description: "Basic protocol flow description",
+ icon: FileText,
+ },
+ 2: {
+ number: 2,
+ name: "Follow List",
+ description: "Contact list and petnames",
+ icon: Users,
+ },
+ 4: {
+ number: 4,
+ name: "Encrypted DMs",
+ description: "Encrypted direct messages",
+ icon: Mail,
+ deprecated: true,
+ },
+ 5: {
+ number: 5,
+ name: "Mapping Nostr keys to DNS",
+ description: "Mapping Nostr keys to DNS-based internet identifiers",
+ icon: Globe,
+ },
+ 6: {
+ number: 6,
+ name: "Key Derivation",
+ description: "Basic key derivation from mnemonic seed phrase",
+ icon: Key,
+ },
+ 7: {
+ number: 7,
+ name: "window.nostr",
+ description: "window.nostr capability for web browsers",
+ icon: Globe,
+ },
+ 8: {
+ number: 8,
+ name: "Mentions",
+ description: "Handling mentions",
+ icon: Tag,
+ deprecated: true,
+ },
+ 9: {
+ number: 9,
+ name: "Event Deletion",
+ description: "Event deletion",
+ icon: AlertCircle,
+ },
+ 10: {
+ number: 10,
+ name: "Conventions",
+ description: "Conventions for clients' use of e and p tags",
+ icon: Tag,
+ },
+ 11: {
+ number: 11,
+ name: "Relay Info",
+ description: "Relay information document",
+ icon: Server,
+ },
+ 13: {
+ number: 13,
+ name: "Proof of Work",
+ description: "Proof of work",
+ icon: Zap,
+ },
+ 14: {
+ number: 14,
+ name: "Subject Tag",
+ description: "Subject tag in text events",
+ icon: Tag,
+ },
+ 15: {
+ number: 15,
+ name: "Marketplace",
+ description: "Marketplace (for resilient marketplaces)",
+ icon: ShoppingCart,
+ },
+ 17: {
+ number: 17,
+ name: "Private DMs",
+ description: "Private Direct Messages",
+ icon: Lock,
+ },
+ 18: { number: 18, name: "Reposts", description: "Reposts", icon: Share2 },
+ 19: {
+ number: 19,
+ name: "bech32 Entities",
+ description: "bech32-encoded entities",
+ icon: Hash,
+ },
+ 21: {
+ number: 21,
+ name: "nostr: URI",
+ description: "nostr: URI scheme",
+ icon: Link,
+ },
+ 22: {
+ number: 22,
+ name: "Comment",
+ description: "Comment",
+ icon: MessageSquare,
+ },
+ 23: {
+ number: 23,
+ name: "Long-form",
+ description: "Long-form content",
+ icon: FileText,
+ },
+ 24: {
+ number: 24,
+ name: "Extra Metadata",
+ description: "Extra metadata fields and tags",
+ icon: Tag,
+ },
+ 25: { number: 25, name: "Reactions", description: "Reactions", icon: Heart },
+ 26: {
+ number: 26,
+ name: "Delegated Signing",
+ description: "Delegated event signing",
+ icon: Key,
+ deprecated: true,
+ },
+ 27: {
+ number: 27,
+ name: "Text Note References",
+ description: "Text note references",
+ icon: Link,
+ },
+ 28: {
+ number: 28,
+ name: "Public Chat",
+ description: "Public chat",
+ icon: MessageSquare,
+ },
+ 29: {
+ number: 29,
+ name: "Relay Groups",
+ description: "Relay-based groups",
+ icon: Users,
+ },
+ 30: {
+ number: 30,
+ name: "Custom Emoji",
+ description: "Custom emoji",
+ icon: Gift,
+ },
+ 31: {
+ number: 31,
+ name: "Unknown Events",
+ description: "Dealing with unknown event kinds",
+ icon: AlertCircle,
+ },
+ 32: { number: 32, name: "Labeling", description: "Labeling", icon: Tag },
+ 34: { number: 34, name: "Git", description: "git stuff", icon: GitBranch },
+ 35: { number: 35, name: "Torrents", description: "Torrents", icon: Share2 },
+ 36: {
+ number: 36,
+ name: "Sensitive Content",
+ description: "Sensitive content warnings",
+ icon: Eye,
+ },
+ 37: {
+ number: 37,
+ name: "Draft Events",
+ description: "Draft Events",
+ icon: FileText,
+ },
+ 38: {
+ number: 38,
+ name: "User Status",
+ description: "User statuses",
+ icon: Flag,
+ },
+ 39: {
+ number: 39,
+ name: "External Identity",
+ description: "External identities in profiles",
+ icon: Globe,
+ },
+ 40: {
+ number: 40,
+ name: "Expiration",
+ description: "Expiration timestamp",
+ icon: Calendar,
+ },
+ 42: {
+ number: 42,
+ name: "Authentication",
+ description: "Authentication of clients to relays",
+ icon: Shield,
+ },
+ 43: {
+ number: 43,
+ name: "Relay Access",
+ description: "Fast Authentication and Relay Access",
+ icon: Server,
+ },
+ 44: {
+ number: 44,
+ name: "Encrypted Payloads",
+ description: "Encrypted Payloads (Versioned)",
+ icon: Lock,
+ },
+ 45: {
+ number: 45,
+ name: "Event Counts",
+ description: "Counting results",
+ icon: Hash,
+ },
+ 46: {
+ number: 46,
+ name: "Remote Signing",
+ description: "Nostr connect protocol",
+ icon: Key,
+ },
+ 47: {
+ number: 47,
+ name: "Wallet Connect",
+ description: "Wallet connect",
+ icon: Wallet,
+ },
+ 48: { number: 48, name: "Proxy Tags", description: "Proxy tags", icon: Tag },
+ 49: {
+ number: 49,
+ name: "Private Key Encryption",
+ description: "Private key encryption",
+ icon: Lock,
+ },
+ 50: {
+ number: 50,
+ name: "Search",
+ description: "Search capability",
+ icon: Search,
+ },
+ 51: { number: 51, name: "Lists", description: "Lists", icon: Filter },
+ 52: {
+ number: 52,
+ name: "Calendar Events",
+ description: "Calendar Events",
+ icon: Calendar,
+ },
+ 53: {
+ number: 53,
+ name: "Live Activities",
+ description: "Live Activities",
+ icon: Radio,
+ },
+ 54: { number: 54, name: "Wiki", description: "Wiki", icon: FileText },
+ 55: {
+ number: 55,
+ name: "Android Signer",
+ description: "Android Signer Application",
+ icon: Key,
+ },
+ 56: { number: 56, name: "Reporting", description: "Reporting", icon: Flag },
+ 57: {
+ number: 57,
+ name: "Lightning Zaps",
+ description: "Lightning zaps",
+ icon: Zap,
+ },
+ 58: { number: 58, name: "Badges", description: "Badges", icon: Star },
+ 59: { number: 59, name: "Gift Wrap", description: "Gift Wrap", icon: Gift },
+ 60: {
+ number: 60,
+ name: "Cashu Wallet",
+ description: "Cashu Wallet",
+ icon: Wallet,
+ },
+ 61: { number: 61, name: "Nutzaps", description: "Nutzaps", icon: Zap },
+ 62: {
+ number: 62,
+ name: "Request to Vanish",
+ description: "Request to Vanish",
+ icon: Eye,
+ },
+ 64: { number: 64, name: "Chess", description: "Chess (PGN)", icon: Gamepad2 },
+ 65: {
+ number: 65,
+ name: "Relay List",
+ description: "Relay list metadata",
+ icon: Server,
+ },
+ 66: {
+ number: 66,
+ name: "Relay Discovery",
+ description: "Relay Discovery",
+ icon: Compass,
+ },
+ 68: {
+ number: 68,
+ name: "Picture-first",
+ description: "Picture-first feeds",
+ icon: Image,
+ },
+ 69: {
+ number: 69,
+ name: "P2P Order",
+ description: "Peer-to-peer Order events",
+ icon: ShoppingCart,
+ },
+ 70: {
+ number: 70,
+ name: "Protected Events",
+ description: "Protected Events",
+ icon: Shield,
+ },
+ 71: {
+ number: 71,
+ name: "Video Events",
+ description: "Video Events",
+ icon: Video,
+ },
+ 72: {
+ number: 72,
+ name: "Moderation",
+ description: "Moderated communities",
+ icon: Shield,
+ },
+ 73: {
+ number: 73,
+ name: "External Content IDs",
+ description: "External Content IDs",
+ icon: Link,
+ },
+ 75: { number: 75, name: "Zap Goals", description: "Zap Goals", icon: Zap },
+ 77: {
+ number: 77,
+ name: "Negentropy",
+ description: "Negentropy Protocol Sync",
+ icon: Server,
+ },
+ 78: {
+ number: 78,
+ name: "App Data",
+ description: "Application-specific data",
+ icon: Database,
+ },
+ 84: {
+ number: 84,
+ name: "Highlights",
+ description: "Highlights",
+ icon: Bookmark,
+ },
+ 86: {
+ number: 86,
+ name: "Relay Management",
+ description: "Relay Management API",
+ icon: Server,
+ },
+ 87: {
+ number: 87,
+ name: "Ecash Mints",
+ description: "Ecash Mint Discoverability",
+ icon: Coins,
+ },
+ 88: { number: 88, name: "Polls", description: "Polls", icon: Filter },
+ 89: {
+ number: 89,
+ name: "App Handlers",
+ description: "Recommended application handlers",
+ icon: Package,
+ },
+ 90: {
+ number: 90,
+ name: "Data Vending",
+ description: "Data Vending Machines",
+ icon: Database,
+ },
+ 92: {
+ number: 92,
+ name: "Media Attachments",
+ description: "Media Attachments",
+ icon: Image,
+ },
+ 94: {
+ number: 94,
+ name: "File Metadata",
+ description: "File metadata",
+ icon: Image,
+ },
+ 96: {
+ number: 96,
+ name: "HTTP File Storage",
+ description: "HTTP File Storage Integration",
+ icon: Server,
+ deprecated: true,
+ },
+ 98: {
+ number: 98,
+ name: "HTTP Auth",
+ description: "HTTP authentication",
+ icon: Lock,
+ },
+ 99: {
+ number: 99,
+ name: "Classified Listings",
+ description: "Classified listings",
+ icon: Tag,
+ },
+
+ // Hex NIPs (A0-EE)
+ A0: {
+ number: 0xa0,
+ name: "Voice Messages",
+ description: "Voice Messages",
+ icon: Music,
+ },
+ B0: {
+ number: 0xb0,
+ name: "Web Bookmarks",
+ description: "Web Bookmarks",
+ icon: Bookmark,
+ },
+ B7: { number: 0xb7, name: "Blossom", description: "Blossom", icon: Package },
+ BE: {
+ number: 0xbe,
+ name: "BLE",
+ description: "BLE Communications",
+ icon: Radio,
+ },
+ C0: {
+ number: 0xc0,
+ name: "Code Snippets",
+ description: "Code Snippets",
+ icon: Code,
+ },
+ C7: {
+ number: 0xc7,
+ name: "Chats",
+ description: "Chats",
+ icon: MessageSquare,
+ },
+ EE: {
+ number: 0xee,
+ name: "E2EE MLS",
+ description: "E2EE Messaging (MLS)",
+ icon: Lock,
+ },
+ "7D": {
+ number: 0x7d,
+ name: "Threads",
+ description: "Threads",
+ icon: MessageSquare,
+ },
+};
+
+/**
+ * Get NIP metadata by number (handles both decimal and hex)
+ */
+export function getNIPInfo(nipNumber: number | string): NIPInfo | undefined {
+ // Try direct lookup
+ if (NIP_METADATA[nipNumber]) {
+ return NIP_METADATA[nipNumber];
+ }
+
+ // Try hex conversion for numbers > 99
+ if (typeof nipNumber === "number" && nipNumber > 99) {
+ const hexKey = nipNumber.toString(16).toUpperCase();
+ return NIP_METADATA[hexKey];
+ }
+
+ return undefined;
+}
+
+/**
+ * Get all supported NIPs with their metadata, excluding deprecated ones
+ */
+export function getSupportedNIPsInfo(
+ nipNumbers: number[],
+ includeDeprecated: boolean = false,
+): (NIPInfo | { number: number; name: string; icon: LucideIcon })[] {
+ return nipNumbers
+ .map((num) => {
+ const info = getNIPInfo(num);
+ if (info) {
+ // Skip deprecated NIPs unless explicitly included
+ if (!includeDeprecated && info.deprecated) {
+ return null;
+ }
+ return info;
+ }
+ // Fallback for unknown NIPs
+ return {
+ number: num,
+ name: `NIP-${num}`,
+ icon: FileText,
+ };
+ })
+ .filter(
+ (
+ nip,
+ ): nip is NIPInfo | { number: number; name: string; icon: LucideIcon } =>
+ nip !== null,
+ );
+}
+
+/**
+ * Group NIPs by a single consolidated category
+ * For relay viewer, we show all NIPs in one list
+ */
+export function groupNIPsByCategory(
+ nipNumbers: number[],
+): Record {
+ const grouped: Record = {
+ "Supported NIPs": [],
+ };
+
+ nipNumbers.forEach((num) => {
+ const info = getNIPInfo(num);
+ // Skip deprecated NIPs
+ if (info && !info.deprecated) {
+ grouped["Supported NIPs"].push(info);
+ }
+ });
+
+ return grouped;
+}
diff --git a/src/lib/nip11.ts b/src/lib/nip11.ts
new file mode 100644
index 0000000..43d75fd
--- /dev/null
+++ b/src/lib/nip11.ts
@@ -0,0 +1,100 @@
+import { RelayInformation } from "../types/nip11";
+import db from "../services/db";
+
+/**
+ * NIP-11: Relay Information Document
+ * https://github.com/nostr-protocol/nips/blob/master/11.md
+ */
+
+const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
+
+/**
+ * Fetch relay information document
+ * NIP-11 specifies: GET request with Accept: application/nostr+json header
+ */
+export async function fetchRelayInfo(
+ wsUrl: string,
+): Promise {
+ try {
+ // Convert ws:// or wss:// to https://
+ const httpUrl = wsUrl.replace(/^ws(s)?:/, "https:");
+
+ const response = await fetch(httpUrl, {
+ headers: { Accept: "application/nostr+json" },
+ });
+
+ if (!response.ok) return null;
+
+ return (await response.json()) as RelayInformation;
+ } catch (error) {
+ console.warn(`NIP-11: Failed to fetch ${wsUrl}:`, error);
+ return null;
+ }
+}
+
+/**
+ * Get relay information with caching (fetches if needed)
+ */
+export async function getRelayInfo(
+ wsUrl: string,
+): Promise {
+ const cached = await db.relayInfo.get(wsUrl);
+ const isExpired = !cached || Date.now() - cached.fetchedAt > CACHE_DURATION;
+
+ if (!isExpired) return cached.info;
+
+ const info = await fetchRelayInfo(wsUrl);
+ if (info) {
+ await db.relayInfo.put({ url: wsUrl, info, fetchedAt: Date.now() });
+ }
+
+ return info;
+}
+
+/**
+ * Get cached relay info only (no network request)
+ */
+export async function getCachedRelayInfo(
+ wsUrl: string,
+): Promise {
+ const cached = await db.relayInfo.get(wsUrl);
+ return cached?.info ?? null;
+}
+
+/**
+ * Fetch multiple relays in parallel
+ */
+export async function getRelayInfoBatch(
+ wsUrls: string[],
+): Promise> {
+ const results = new Map();
+ const infos = await Promise.all(wsUrls.map((url) => getRelayInfo(url)));
+
+ infos.forEach((info, i) => {
+ if (info) results.set(wsUrls[i], info);
+ });
+
+ return results;
+}
+
+/**
+ * Clear relay info cache
+ */
+export async function clearRelayInfoCache(wsUrl?: string): Promise {
+ if (wsUrl) {
+ await db.relayInfo.delete(wsUrl);
+ } else {
+ await db.relayInfo.clear();
+ }
+}
+
+/**
+ * Check if relay supports a specific NIP
+ */
+export async function relaySupportsNip(
+ wsUrl: string,
+ nipNumber: number,
+): Promise {
+ const info = await getRelayInfo(wsUrl);
+ return info?.supported_nips?.includes(nipNumber) ?? false;
+}
diff --git a/src/lib/nostr-validation.ts b/src/lib/nostr-validation.ts
new file mode 100644
index 0000000..7972ec0
--- /dev/null
+++ b/src/lib/nostr-validation.ts
@@ -0,0 +1,24 @@
+/**
+ * Nostr validation utilities for hex strings and identifiers
+ */
+
+/**
+ * Check if a string is a valid 64-character hex pubkey
+ */
+export function isValidHexPubkey(value: string): boolean {
+ return /^[0-9a-f]{64}$/i.test(value);
+}
+
+/**
+ * Check if a string is a valid 64-character hex event ID
+ */
+export function isValidHexEventId(value: string): boolean {
+ return /^[0-9a-f]{64}$/i.test(value);
+}
+
+/**
+ * Normalize hex string to lowercase
+ */
+export function normalizeHex(value: string): string {
+ return value.toLowerCase();
+}
diff --git a/src/lib/relay-parser.ts b/src/lib/relay-parser.ts
new file mode 100644
index 0000000..4a90a6c
--- /dev/null
+++ b/src/lib/relay-parser.ts
@@ -0,0 +1,36 @@
+export interface ParsedRelayCommand {
+ url: string;
+}
+
+/**
+ * Parse RELAY command arguments
+ *
+ * Examples:
+ * relay wss://relay.damus.io
+ * relay relay.primal.net
+ * relay nos.lol
+ */
+export function parseRelayCommand(args: string[]): ParsedRelayCommand {
+ if (args.length < 1) {
+ throw new Error("Usage: RELAY ");
+ }
+
+ let url = args[0];
+
+ // Auto-add wss:// protocol if not present
+ if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
+ url = `wss://${url}`;
+ }
+
+ // Validate URL format
+ try {
+ const parsedUrl = new URL(url);
+ if (!parsedUrl.protocol.startsWith("ws")) {
+ throw new Error("Relay must be a WebSocket URL (ws:// or wss://)");
+ }
+ } catch {
+ throw new Error(`Invalid relay URL: ${url}`);
+ }
+
+ return { url };
+}
diff --git a/src/services/db.ts b/src/services/db.ts
index 498e3b5..96d529a 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -1,5 +1,6 @@
import { ProfileContent } from "applesauce-core/helpers";
import { Dexie, Table } from "dexie";
+import { RelayInformation } from "../types/nip11";
export interface Profile extends ProfileContent {
pubkey: string;
@@ -17,17 +18,25 @@ export interface Nip {
fetchedAt: number;
}
+export interface RelayInfo {
+ url: string;
+ info: RelayInformation;
+ fetchedAt: number;
+}
+
class GrimoireDb extends Dexie {
profiles!: Table;
nip05!: Table;
nips!: Table;
+ relayInfo!: Table;
constructor(name: string) {
super(name);
- this.version(3).stores({
+ this.version(4).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
+ relayInfo: "&url",
});
}
}
diff --git a/src/types/app.ts b/src/types/app.ts
index 8b8d24d..c572d0f 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -10,7 +10,8 @@ export type AppId =
| "open"
| "profile"
| "encode"
- | "decode";
+ | "decode"
+ | "relay";
export interface WindowInstance {
id: string;
diff --git a/src/types/man.ts b/src/types/man.ts
index d9f4541..ab3f1ac 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -3,6 +3,7 @@ import type { AppId } from "./app";
import { parseOpenCommand } from "@/lib/open-parser";
import { parseProfileCommand } from "@/lib/profile-parser";
+import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
export interface ManPageEntry {
diff --git a/src/types/nip11.ts b/src/types/nip11.ts
new file mode 100644
index 0000000..2a84a21
--- /dev/null
+++ b/src/types/nip11.ts
@@ -0,0 +1,99 @@
+/**
+ * NIP-11: Relay Information Document
+ * https://github.com/nostr-protocol/nips/blob/master/11.md
+ */
+
+export interface RelayInformation {
+ /** DNS name of the relay */
+ name?: string;
+
+ /** Description of the relay in plain text */
+ description?: string;
+
+ /** Public key of the relay administrator */
+ pubkey?: string;
+
+ /** Administrative contact for the relay */
+ contact?: string;
+
+ /** List of NIPs supported by this relay */
+ supported_nips?: number[];
+
+ /** Software version running the relay */
+ software?: string;
+
+ /** Software version identifier */
+ version?: string;
+
+ /** Relay limitations and policies */
+ limitation?: RelayLimitation;
+
+ /** Payment information for paid relays */
+ payments_url?: string;
+
+ /** Relay usage fees */
+ fees?: RelayFees;
+
+ /** URL to the relay's icon */
+ icon?: string;
+}
+
+export interface RelayLimitation {
+ /** Maximum length of the content field */
+ max_message_length?: number;
+
+ /** Maximum number of subscriptions per WebSocket connection */
+ max_subscriptions?: number;
+
+ /** Maximum number of filters per subscription */
+ max_filters?: number;
+
+ /** Maximum length of subscription ID */
+ max_subid_length?: number;
+
+ /** Minimum prefix length for search filters */
+ min_prefix?: number;
+
+ /** Maximum number of elements in various arrays */
+ max_limit?: number;
+
+ /** Minimum POW difficulty for events */
+ min_pow_difficulty?: number;
+
+ /** Whether authentication is required */
+ auth_required?: boolean;
+
+ /** Whether payment is required */
+ payment_required?: boolean;
+
+ /** Restricted write access */
+ restricted_writes?: boolean;
+
+ /** Created at lower limit (oldest events accepted) */
+ created_at_lower_limit?: number;
+
+ /** Created at upper limit (newest events accepted) */
+ created_at_upper_limit?: number;
+}
+
+export interface RelayFees {
+ /** Admission fee structure */
+ admission?: Array<{ amount: number; unit: string }>;
+
+ /** Subscription fee structure */
+ subscription?: Array<{ amount: number; unit: string; period?: number }>;
+
+ /** Publication fee structure */
+ publication?: Array<{ kinds?: number[]; amount: number; unit: string }>;
+}
+
+export interface CachedRelayInfo {
+ /** Relay URL (websocket) */
+ url: string;
+
+ /** Relay information document */
+ info: RelayInformation;
+
+ /** Timestamp when the info was fetched */
+ fetchedAt: number;
+}
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
deleted file mode 100644
index c0e145c..0000000
--- a/tsconfig.app.tsbuildinfo
+++ /dev/null
@@ -1 +0,0 @@
-{"root":["./src/main.tsx","./src/root.tsx","./src/vite-env.d.ts","./src/components/command.tsx","./src/components/commandlauncher.tsx","./src/components/decodeviewer.tsx","./src/components/encodeviewer.tsx","./src/components/eventdetailviewer.tsx","./src/components/grimoirewelcome.tsx","./src/components/home.tsx","./src/components/jsonviewer.tsx","./src/components/kindbadge.tsx","./src/components/kindrenderer.tsx","./src/components/manpage.tsx","./src/components/markdown.tsx","./src/components/nipbadge.tsx","./src/components/niprenderer.tsx","./src/components/profileviewer.tsx","./src/components/relayviewer.tsx","./src/components/reqviewer.tsx","./src/components/tabbar.tsx","./src/components/timestamp.tsx","./src/components/winviewer.tsx","./src/components/windowtoolbar.tsx","./src/components/nostr/embeddedevent.tsx","./src/components/nostr/feed.tsx","./src/components/nostr/mediadialog.tsx","./src/components/nostr/mediaembed.tsx","./src/components/nostr/quotedevent.tsx","./src/components/nostr/richtext.tsx","./src/components/nostr/username.tsx","./src/components/nostr/index.ts","./src/components/nostr/nip05.tsx","./src/components/nostr/npub.tsx","./src/components/nostr/relay-pool.tsx","./src/components/nostr/user-menu.tsx","./src/components/nostr/linkpreview/audiolink.tsx","./src/components/nostr/linkpreview/imagelink.tsx","./src/components/nostr/linkpreview/plainlink.tsx","./src/components/nostr/linkpreview/videolink.tsx","./src/components/nostr/linkpreview/index.ts","./src/components/nostr/richtext/emoji.tsx","./src/components/nostr/richtext/eventembed.tsx","./src/components/nostr/richtext/gallery.tsx","./src/components/nostr/richtext/hashtag.tsx","./src/components/nostr/richtext/link.tsx","./src/components/nostr/richtext/mention.tsx","./src/components/nostr/richtext/text.tsx","./src/components/nostr/richtext/index.ts","./src/components/nostr/kinds/baseeventrenderer.tsx","./src/components/nostr/kinds/kind0detailrenderer.tsx","./src/components/nostr/kinds/kind0renderer.tsx","./src/components/nostr/kinds/kind1063renderer.tsx","./src/components/nostr/kinds/kind1renderer.tsx","./src/components/nostr/kinds/kind20renderer.tsx","./src/components/nostr/kinds/kind21renderer.tsx","./src/components/nostr/kinds/kind22renderer.tsx","./src/components/nostr/kinds/kind30023detailrenderer.tsx","./src/components/nostr/kinds/kind30023renderer.tsx","./src/components/nostr/kinds/kind3renderer.tsx","./src/components/nostr/kinds/kind6renderer.tsx","./src/components/nostr/kinds/kind7renderer.tsx","./src/components/nostr/kinds/kind9735renderer.tsx","./src/components/nostr/kinds/kind9802detailrenderer.tsx","./src/components/nostr/kinds/kind9802renderer.tsx","./src/components/nostr/kinds/index.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/constants/kinds.ts","./src/constants/nips.ts","./src/core/logic.ts","./src/core/state.ts","./src/hooks/useaccountsync.ts","./src/hooks/usecopy.ts","./src/hooks/uselocale.ts","./src/hooks/usenip.ts","./src/hooks/usenip05.ts","./src/hooks/usenostrevent.ts","./src/hooks/useprofile.ts","./src/hooks/userelayconnection.ts","./src/hooks/userelayinfo.ts","./src/hooks/usereqtimeline.ts","./src/hooks/usetimeline.ts","./src/lib/decode-parser.ts","./src/lib/encode-parser.ts","./src/lib/imeta.ts","./src/lib/nip-icons.ts","./src/lib/nip-kinds.ts","./src/lib/nip05.ts","./src/lib/nip11.ts","./src/lib/nostr-utils.ts","./src/lib/nostr-validation.ts","./src/lib/open-parser.ts","./src/lib/profile-parser.ts","./src/lib/relay-parser.ts","./src/lib/req-parser.ts","./src/lib/utils.ts","./src/services/accounts.ts","./src/services/db.ts","./src/services/event-store.ts","./src/services/loaders.ts","./src/services/relay-pool.ts","./src/types/app.ts","./src/types/man.ts","./src/types/nip11.ts","./src/types/nostr.ts","./src/types/profile.ts"],"version":"5.6.3"}
\ No newline at end of file