Files
grimoire/src/components/DecodeViewer.tsx
2025-12-17 11:44:12 +01:00

332 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from "react";
import { Copy, Check, Plus, X, ExternalLink } from "lucide-react";
import {
parseDecodeCommand,
decodeNostr,
reencodeWithRelays,
type DecodedData,
} from "@/lib/decode-parser";
import { useGrimoire } from "@/core/state";
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { KindBadge } from "./KindBadge";
import { normalizeRelayURL } from "@/lib/relay-url";
interface DecodeViewerProps {
args: string[];
}
export default function DecodeViewer({ args }: DecodeViewerProps) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [error, setError] = useState<string | null>(null);
// Parse and decode
const decoded = useMemo<{ bech32: string; data: DecodedData } | null>(() => {
try {
const parsed = parseDecodeCommand(args);
const data = decodeNostr(parsed.bech32);
// Initialize relays from decoded data
if (data.type === "nprofile") {
setRelays(data.data.relays || []);
} else if (data.type === "nevent") {
setRelays(data.data.relays || []);
} else if (data.type === "naddr") {
setRelays(data.data.relays || []);
}
setError(null);
return { bech32: parsed.bech32, data };
} catch (err) {
setError(err instanceof Error ? err.message : "Decode error");
return null;
}
}, [args]);
// Re-encode with current relays
const reencoded = useMemo(() => {
if (!decoded) return null;
try {
return reencodeWithRelays(decoded.data, relays, decoded.bech32);
} catch {
return decoded.bech32;
}
}, [decoded, relays]);
const copyToClipboard = () => {
if (reencoded) {
copy(reencoded);
}
};
const addRelay = () => {
if (!newRelay.trim()) return;
// Auto-add wss:// if no protocol
let relayUrl = newRelay.trim();
if (!relayUrl.startsWith("ws://") && !relayUrl.startsWith("wss://")) {
relayUrl = `wss://${relayUrl}`;
}
try {
const url = new URL(relayUrl);
if (!url.protocol.startsWith("ws")) {
setError("Relay must be a WebSocket URL (ws:// or wss://)");
return;
}
setRelays([...relays, normalizeRelayURL(relayUrl)]);
setNewRelay("");
setError(null);
} catch {
setError("Invalid relay URL");
}
};
const removeRelay = (index: number) => {
setRelays(relays.filter((_, i) => i !== index));
};
const openEvent = () => {
if (!decoded) return;
if (decoded.data.type === "note") {
addWindow("open", { pointer: { id: decoded.data.data, relays } });
} else if (decoded.data.type === "nevent") {
addWindow("open", { pointer: { id: decoded.data.data.id, relays } });
} else if (decoded.data.type === "naddr") {
const { kind, pubkey, identifier } = decoded.data.data;
addWindow("open", { pointer: { kind, pubkey, identifier, relays } });
}
};
const openProfile = () => {
if (!decoded) return;
let pubkey: string | undefined;
if (decoded.data.type === "npub") {
pubkey = decoded.data.data;
} else if (decoded.data.type === "nprofile") {
pubkey = decoded.data.data.pubkey;
} else if (decoded.data.type === "naddr") {
pubkey = decoded.data.data.pubkey;
}
if (pubkey) {
addWindow("profile", { pubkey });
}
};
if (error) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-destructive text-sm font-mono">{error}</div>
</div>
);
}
if (!decoded) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground p-4">
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
const { type, data } = decoded.data;
const supportsRelays = ["nprofile", "nevent", "naddr"].includes(type);
const canOpenEvent = ["note", "nevent", "naddr"].includes(type);
const canOpenProfile = ["npub", "nprofile", "naddr"].includes(type);
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">DECODE {type.toUpperCase()}</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Decoded Information */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Decoded Data
</div>
{type === "npub" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{data}
</div>
</div>
)}
{type === "note" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Event ID</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{data}
</div>
</div>
)}
{type === "nsec" && (
<div className="space-y-1">
<div className="text-xs text-destructive font-semibold">
Private Key (Keep Secret!)
</div>
<div className="bg-destructive/10 p-3 rounded font-mono text-xs break-all border border-destructive">
{data}
</div>
</div>
)}
{type === "nprofile" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{(data as any).pubkey}
</div>
</div>
)}
{type === "nevent" && (
<>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Event ID</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{(data as any).id}
</div>
</div>
{(data as any).author && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Author</div>
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
{(data as any).author}
</div>
</div>
)}
</>
)}
{type === "naddr" && (
<>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Kind</div>
<div className="bg-muted p-3 rounded text-xs">
<KindBadge
kind={(data as any).kind}
variant="full"
iconClassname="size-3 text-muted-foreground"
clickable
/>
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Identifier</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all">
{(data as any).identifier}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Public Key</div>
<div className="bg-muted p-2 rounded font-mono text-xs break-all">
{(data as any).pubkey}
</div>
</div>
</>
)}
</div>
{/* Relay Editor */}
{supportsRelays && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
Relays ({relays.length})
</div>
<div className="space-y-2">
{relays.map((relay, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1 bg-muted p-2 rounded font-mono text-xs truncate">
{relay}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeRelay(index)}
>
<X className="size-3" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Input
placeholder="wss://relay.example.com"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addRelay()}
className="font-mono text-xs"
/>
<Button size="sm" onClick={addRelay}>
<Plus className="size-3" />
</Button>
</div>
</div>
</div>
)}
{/* Updated Identifier */}
<div className="space-y-2">
<div className="text-xs text-muted-foreground font-semibold">
{supportsRelays && relays.length > 0
? "Updated Identifier"
: "Original Identifier"}
</div>
<div className="bg-muted p-3 rounded font-mono text-xs break-all border border-accent">
{reencoded}
</div>
<Button
size="sm"
onClick={copyToClipboard}
className="w-full"
variant={copied ? "default" : "outline"}
>
{copied ? (
<>
<Check className="size-3 mr-2" />
Copied!
</>
) : (
<>
<Copy className="size-3 mr-2" />
Copy to Clipboard
</>
)}
</Button>
</div>
{/* Actions */}
<div className="flex gap-2">
{canOpenEvent && (
<Button
size="sm"
variant="outline"
onClick={openEvent}
className="flex-1"
>
<ExternalLink className="size-3 mr-2" />
Open Event
</Button>
)}
{canOpenProfile && (
<Button
size="sm"
variant="outline"
onClick={openProfile}
className="flex-1"
>
<ExternalLink className="size-3 mr-2" />
Open Profile
</Button>
)}
</div>
</div>
</div>
);
}