mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 14:37:04 +02:00
feat: relay command
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
25
TODO.md
Normal file
25
TODO.md
Normal file
@@ -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.
|
||||
@@ -103,6 +103,9 @@ export default function Home() {
|
||||
case "decode":
|
||||
content = <DecodeViewer args={window.props.args} />;
|
||||
break;
|
||||
case "relay":
|
||||
content = <RelayViewer url={window.props.url} />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
46
src/components/NIPBadge.tsx
Normal file
46
src/components/NIPBadge.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
onClick={openNIP}
|
||||
className={`flex items-center gap-1.5 rounded-md border bg-card px-2.5 py-1.5 text-sm hover:bg-muted/20 cursor-crosshair ${className}`}
|
||||
title={description}
|
||||
>
|
||||
<span className="font-mono font-medium">{nipNumber}</span>
|
||||
{showName && nipInfo && (
|
||||
<span className="text-muted-foreground">· {name}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
88
src/components/RelayViewer.tsx
Normal file
88
src/components/RelayViewer.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
{info?.icon ? (
|
||||
<img src={info.icon} alt={info.name || url} className="size-16" />
|
||||
) : (
|
||||
<Server className="size-16 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{info?.name || "Unknown Relay"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
||||
{url}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={() => copy(url)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{info?.description && (
|
||||
<p className="text-sm mt-2">{info.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
{(info?.contact || info?.pubkey) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Operator</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{info.contact && info.contact.length == 64 && (
|
||||
<UserName pubkey={info.contact} />
|
||||
)}
|
||||
{info.pubkey && info.pubkey.length === 64 && (
|
||||
<UserName pubkey={info.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Software */}
|
||||
{(info?.software || info?.version) && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-semibold text-sm">Software</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.software || info.version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supported NIPs */}
|
||||
{info?.supported_nips && info.supported_nips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold text-sm">Supported NIPs</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.supported_nips.map((num: number) => (
|
||||
<NIPBadge key={num} nipNumber={num} showName={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/hooks/useRelayInfo.ts
Normal file
51
src/hooks/useRelayInfo.ts
Normal file
@@ -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;
|
||||
}
|
||||
577
src/lib/nip-icons.ts
Normal file
577
src/lib/nip-icons.ts
Normal file
@@ -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<number | string, NIPInfo> = {
|
||||
// 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<string, NIPInfo[]> {
|
||||
const grouped: Record<string, NIPInfo[]> = {
|
||||
"Supported NIPs": [],
|
||||
};
|
||||
|
||||
nipNumbers.forEach((num) => {
|
||||
const info = getNIPInfo(num);
|
||||
// Skip deprecated NIPs
|
||||
if (info && !info.deprecated) {
|
||||
grouped["Supported NIPs"].push(info);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
100
src/lib/nip11.ts
Normal file
100
src/lib/nip11.ts
Normal file
@@ -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<RelayInformation | null> {
|
||||
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<RelayInformation | null> {
|
||||
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<RelayInformation | null> {
|
||||
const cached = await db.relayInfo.get(wsUrl);
|
||||
return cached?.info ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple relays in parallel
|
||||
*/
|
||||
export async function getRelayInfoBatch(
|
||||
wsUrls: string[],
|
||||
): Promise<Map<string, RelayInformation>> {
|
||||
const results = new Map<string, RelayInformation>();
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const info = await getRelayInfo(wsUrl);
|
||||
return info?.supported_nips?.includes(nipNumber) ?? false;
|
||||
}
|
||||
24
src/lib/nostr-validation.ts
Normal file
24
src/lib/nostr-validation.ts
Normal file
@@ -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();
|
||||
}
|
||||
36
src/lib/relay-parser.ts
Normal file
36
src/lib/relay-parser.ts
Normal file
@@ -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 <url>");
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<Profile>;
|
||||
nip05!: Table<Nip05>;
|
||||
nips!: Table<Nip>;
|
||||
relayInfo!: Table<RelayInfo>;
|
||||
|
||||
constructor(name: string) {
|
||||
super(name);
|
||||
this.version(3).stores({
|
||||
this.version(4).stores({
|
||||
profiles: "&pubkey",
|
||||
nip05: "&nip05",
|
||||
nips: "&id",
|
||||
relayInfo: "&url",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ export type AppId =
|
||||
| "open"
|
||||
| "profile"
|
||||
| "encode"
|
||||
| "decode";
|
||||
| "decode"
|
||||
| "relay";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
src/types/nip11.ts
Normal file
99
src/types/nip11.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user