refactor: copy hook

This commit is contained in:
Alejandro Gómez
2025-12-10 23:18:10 +01:00
parent 8ffd0fd2cb
commit 929365a813
15 changed files with 225 additions and 75 deletions

View File

@@ -7,6 +7,7 @@ import {
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";
@@ -16,7 +17,7 @@ interface DecodeViewerProps {
export default function DecodeViewer({ args }: DecodeViewerProps) {
const { addWindow } = useGrimoire();
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopy();
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -56,9 +57,7 @@ export default function DecodeViewer({ args }: DecodeViewerProps) {
const copyToClipboard = () => {
if (reencoded) {
navigator.clipboard.writeText(reencoded);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copy(reencoded);
}
};

View File

@@ -5,6 +5,7 @@ import {
encodeToNostr,
type ParsedEncodeCommand,
} from "@/lib/encode-parser";
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -13,7 +14,7 @@ interface EncodeViewerProps {
}
export default function EncodeViewer({ args }: EncodeViewerProps) {
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopy();
const [relays, setRelays] = useState<string[]>([]);
const [newRelay, setNewRelay] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -46,9 +47,7 @@ export default function EncodeViewer({ args }: EncodeViewerProps) {
const copyToClipboard = () => {
if (encoded) {
navigator.clipboard.writeText(encoded);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copy(encoded);
}
};

View File

@@ -9,6 +9,7 @@ import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer";
import { KindBadge } from "./KindBadge";
import {
Copy,
Check,
ChevronDown,
ChevronRight,
FileJson,
@@ -16,6 +17,7 @@ import {
Circle,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { useCopy } from "../hooks/useCopy";
import { getSeenRelays } from "applesauce-core/helpers/relays";
export interface EventDetailViewerProps {
@@ -40,10 +42,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
);
}
// Helper to copy to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const { copy, copied } = useCopy();
// Get relays this event was seen on using applesauce
const seenRelaysSet = getSeenRelays(event);
@@ -79,11 +78,15 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
{/* Left: Event ID */}
<button
onClick={() => copyToClipboard(bech32Id)}
onClick={() => copy(bech32Id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
title={bech32Id}
>
<Copy className="size-3 flex-shrink-0" />
{copied ? (
<Check className="size-3 flex-shrink-0 text-green-500" />
) : (
<Copy className="size-3 flex-shrink-0" />
)}
<code className="truncate">
{bech32Id.slice(0, 16)}...{bech32Id.slice(-8)}
</code>
@@ -151,11 +154,15 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
<div className="border-b border-border px-4 py-2 bg-muted">
<div className="flex justify-end mb-2">
<button
onClick={() => copyToClipboard(JSON.stringify(event, null, 2))}
onClick={() => copy(JSON.stringify(event, null, 2))}
className="hover:text-foreground text-muted-foreground transition-colors text-xs flex items-center gap-1"
>
<Copy className="size-3" />
Copy JSON
{copied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
{copied ? "Copied!" : "Copy JSON"}
</button>
</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-words bg-background p-2 rounded border border-border font-mono">

View File

@@ -14,6 +14,7 @@ import { EventDetailViewer } from "./EventDetailViewer";
import { ProfileViewer } from "./ProfileViewer";
import EncodeViewer from "./EncodeViewer";
import DecodeViewer from "./DecodeViewer";
import { RelayViewer } from "./RelayViewer";
import KindRenderer from "./KindRenderer";
import { Terminal } from "lucide-react";
import UserMenu from "./nostr/user-menu";

View File

@@ -7,6 +7,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useCopy } from "../hooks/useCopy";
interface JsonViewerProps {
data: any;
@@ -21,14 +22,12 @@ export function JsonViewer({
onOpenChange,
title = "Raw JSON",
}: JsonViewerProps) {
const [copied, setCopied] = useState(false);
const { copy, copied } = useCopy();
const jsonString = JSON.stringify(data, null, 2);
const handleCopy = () => {
navigator.clipboard.writeText(jsonString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
copy(jsonString);
};
return (

View File

@@ -4,6 +4,7 @@ import { UserName } from "./nostr/UserName";
import Nip05 from "./nostr/nip05";
import {
Copy,
Check,
ChevronDown,
ChevronRight,
User as UserIcon,
@@ -14,6 +15,7 @@ import {
import { kinds, nip19 } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
import { useCopy } from "../hooks/useCopy";
import { RichText } from "./nostr/RichText";
export interface ProfileViewerProps {
@@ -46,10 +48,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
[eventStore, pubkey],
);
// Helper to copy to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const { copy, copied } = useCopy();
// Combine all relays (inbox + outbox) for nprofile
const allRelays = [...new Set([...inboxRelays, ...outboxRelays])];
@@ -69,11 +68,15 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between gap-3">
{/* Left: npub/nprofile */}
<button
onClick={() => copyToClipboard(identifier)}
onClick={() => copy(identifier)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors truncate min-w-0"
title={identifier}
>
<Copy className="size-3 flex-shrink-0" />
{copied ? (
<Check className="size-3 flex-shrink-0 text-green-500" />
) : (
<Copy className="size-3 flex-shrink-0" />
)}
<code className="truncate">
{identifier.slice(0, 16)}...{identifier.slice(-8)}
</code>
@@ -180,7 +183,10 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
<div className="flex flex-col gap-4 max-w-2xl">
<div className="flex flex-col gap-0">
{/* Display Name */}
<UserName pubkey={pubkey} className="text-2xl font-bold" />
<UserName
pubkey={pubkey}
className="text-2xl font-bold pointer-events-none"
/>
{/* NIP-05 */}
{profile.nip05 && (
<div className="text-xs text-muted-foreground">

View File

@@ -10,8 +10,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Menu, Copy, FileJson, ExternalLink } from "lucide-react";
import { Menu, Copy, Check, FileJson, ExternalLink } from "lucide-react";
import { useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { JsonViewer } from "@/components/JsonViewer";
import { formatTimestamp } from "@/hooks/useLocale";
@@ -42,6 +43,7 @@ export function EventAuthor({ pubkey }: { pubkey: string }) {
*/
export function EventMenu({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const [jsonDialogOpen, setJsonDialogOpen] = useState(false);
const openEventDetail = () => {
@@ -71,7 +73,7 @@ export function EventMenu({ event }: { event: NostrEvent }) {
};
const copyEventId = () => {
navigator.clipboard.writeText(event.id);
copy(event.id);
};
const viewEventJson = () => {
@@ -104,8 +106,12 @@ export function EventMenu({ event }: { event: NostrEvent }) {
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
<Copy className="size-4 mr-2" />
Copy ID
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
) : (
<Copy className="size-4 mr-2" />
)}
{copied ? "Copied!" : "Copy ID"}
</DropdownMenuItem>
<DropdownMenuItem onClick={viewEventJson}>
<FileJson className="size-4 mr-2" />
@@ -139,10 +145,18 @@ export function BaseEventContainer({
}) {
// Format relative time for display
const { locale } = useGrimoire();
const relativeTime = formatTimestamp(event.created_at, "relative", locale.locale);
const relativeTime = formatTimestamp(
event.created_at,
"relative",
locale.locale,
);
// Format absolute timestamp for hover (ISO-8601 style)
const absoluteTime = formatTimestamp(event.created_at, "absolute", locale.locale);
const absoluteTime = formatTimestamp(
event.created_at,
"absolute",
locale.locale,
);
return (
<div className="flex flex-col gap-2 p-3 border-b border-border/50 last:border-0">

45
src/hooks/useCopy.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useState, useCallback, useRef, useEffect } from "react";
/**
* Hook for copying text to clipboard with temporary "copied" state
* @param timeout - Duration in ms to show "copied" state (default: 3000)
* @returns Object with copy function and copied state
*/
export function useCopy(timeout = 3000) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout to reset copied state
timeoutRef.current = setTimeout(() => {
setCopied(false);
}, timeout);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
setCopied(false);
}
},
[timeout]
);
return { copy, copied };
}

View File

@@ -1,4 +1,5 @@
import { nip19 } from "nostr-tools";
import { isValidHexEventId, isValidHexPubkey } from "./nostr-validation";
export type EncodeType = "npub" | "note" | "nevent" | "nprofile" | "naddr";
@@ -88,24 +89,24 @@ function validateEncodeInput(
relays: string[],
author?: string,
) {
// Validate hex strings are 64 characters
// Validate hex strings
if (type === "npub" || type === "nprofile") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
if (!isValidHexPubkey(value)) {
throw new Error("Pubkey must be 64-character hex string");
}
}
if (type === "note") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
if (!isValidHexEventId(value)) {
throw new Error("Event ID must be 64-character hex string");
}
}
if (type === "nevent") {
if (!/^[0-9a-f]{64}$/i.test(value)) {
if (!isValidHexEventId(value)) {
throw new Error("Event ID must be 64-character hex string");
}
if (author && !/^[0-9a-f]{64}$/i.test(author)) {
if (author && !isValidHexPubkey(author)) {
throw new Error("Author pubkey must be 64-character hex string");
}
}
@@ -121,7 +122,7 @@ function validateEncodeInput(
if (isNaN(kind)) {
throw new Error("Kind must be a number");
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
if (!isValidHexPubkey(pubkey)) {
throw new Error("Pubkey must be 64-character hex string");
}
}

View File

@@ -3,43 +3,80 @@ import { queryProfile } from "nostr-tools/nip05";
/**
* NIP-05 Identifier Resolution
* Resolves user@domain identifiers to Nostr pubkeys using nostr-tools
*
* Supports both formats:
* - user@domain.com
* - domain.com (normalized to _@domain.com)
*/
/**
* Check if a string looks like a NIP-05 identifier (user@domain)
* Check if a string looks like a NIP-05 identifier
* Accepts both user@domain and bare domain formats
*/
export function isNip05(value: string): boolean {
if (!value) return false;
// Must match user@domain format
return /^[a-zA-Z0-9._-]+@[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value);
// Match user@domain format
const userAtDomain = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value);
// Match bare domain format (domain.com -> _@domain.com)
const bareDomain = /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value);
return userAtDomain || bareDomain;
}
/**
* Normalize a NIP-05 identifier
* Converts bare domains to the _@domain format
* @param value - NIP-05 identifier or bare domain
* @returns Normalized identifier with @
*/
export function normalizeNip05(value: string): string {
if (!value) return value;
// Already in user@domain format
if (value.includes("@")) {
return value;
}
// Bare domain -> _@domain
if (/^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value)) {
return `_@${value}`;
}
return value;
}
/**
* Resolve a NIP-05 identifier to a pubkey using nostr-tools
* @param nip05 - The NIP-05 identifier (user@domain or _@domain)
* @param nip05 - The NIP-05 identifier (user@domain, domain.com, or _@domain)
* @returns The hex pubkey or null if resolution fails
*/
export async function resolveNip05(nip05: string): Promise<string | null> {
if (!isNip05(nip05)) return null;
// Normalize bare domains to _@domain
const normalized = normalizeNip05(nip05);
try {
const profile = await queryProfile(nip05);
const profile = await queryProfile(normalized);
if (!profile?.pubkey) {
console.warn(`NIP-05: No pubkey found for ${nip05}`);
console.warn(`NIP-05: No pubkey found for ${normalized}`);
return null;
}
console.log(`NIP-05: Resolved ${nip05}${profile.pubkey}`);
console.log(`NIP-05: Resolved ${nip05}${normalized}${profile.pubkey}`);
return profile.pubkey.toLowerCase();
} catch (error) {
console.warn(`NIP-05: Resolution failed for ${nip05}:`, error);
console.warn(`NIP-05: Resolution failed for ${normalized}:`, error);
return null;
}
}
/**
* Resolve multiple NIP-05 identifiers in parallel
* Automatically normalizes bare domains to _@domain format
*/
export async function resolveNip05Batch(
identifiers: string[],
@@ -50,6 +87,7 @@ export async function resolveNip05Batch(
identifiers.map(async (nip05) => {
const pubkey = await resolveNip05(nip05);
if (pubkey) {
// Store with original identifier as key
results.set(nip05, pubkey);
}
}),

View File

@@ -1,4 +1,5 @@
import { nip19 } from "nostr-tools";
import { isValidHexEventId, isValidHexPubkey, normalizeHex } from "./nostr-validation";
// Define pointer types locally since they're not exported from nostr-tools
export interface EventPointer {
@@ -70,11 +71,11 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
}
}
// Check if it's a hex event ID (64 chars, hex only)
if (/^[0-9a-f]{64}$/i.test(identifier)) {
// Check if it's a hex event ID
if (isValidHexEventId(identifier)) {
return {
pointer: {
id: identifier.toLowerCase(),
id: normalizeHex(identifier),
},
};
}
@@ -92,14 +93,14 @@ export function parseOpenCommand(args: string[]): ParsedOpenCommand {
throw new Error("Invalid address format: kind must be a number");
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
if (!isValidHexPubkey(pubkey)) {
throw new Error("Invalid address format: pubkey must be 64 hex chars");
}
return {
pointer: {
kind,
pubkey: pubkey.toLowerCase(),
pubkey: normalizeHex(pubkey),
identifier: dTag,
},
};

View File

@@ -1,4 +1,6 @@
import { nip19 } from "nostr-tools";
import { isNip05, resolveNip05 } from "./nip05";
import { isValidHexPubkey, normalizeHex } from "./nostr-validation";
export interface ParsedProfileCommand {
pubkey: string;
@@ -10,9 +12,12 @@ export interface ParsedProfileCommand {
* - npub1... (bech32 npub)
* - nprofile1... (bech32 nprofile with relay hints)
* - abc123... (64-char hex pubkey)
* - nip05@domain.com (NIP-05 identifier - not implemented yet)
* - user@domain.com (NIP-05 identifier)
* - domain.com (bare domain, resolved as _@domain.com)
*/
export function parseProfileCommand(args: string[]): ParsedProfileCommand {
export async function parseProfileCommand(
args: string[],
): Promise<ParsedProfileCommand> {
const identifier = args[0];
if (!identifier) {
@@ -42,21 +47,25 @@ export function parseProfileCommand(args: string[]): ParsedProfileCommand {
}
}
// Check if it's a hex pubkey (64 chars, hex only)
if (/^[0-9a-f]{64}$/i.test(identifier)) {
// Check if it's a hex pubkey
if (isValidHexPubkey(identifier)) {
return {
pubkey: identifier.toLowerCase(),
pubkey: normalizeHex(identifier),
};
}
// Check if it's a NIP-05 identifier (user@domain.com)
if (identifier.includes("@")) {
throw new Error(
"NIP-05 identifier lookup not yet implemented. Please use npub or hex pubkey.",
);
if (isNip05(identifier)) {
const pubkey = await resolveNip05(identifier);
if (!pubkey) {
throw new Error(
`Failed to resolve NIP-05 identifier: ${identifier}. Please check the identifier and try again.`,
);
}
return { pubkey };
}
throw new Error(
"Invalid user identifier. Supported formats: npub1..., nprofile1..., or hex pubkey",
"Invalid user identifier. Supported formats: npub1..., nprofile1..., hex pubkey, user@domain.com, or domain.com",
);
}

View File

@@ -1,6 +1,7 @@
import { nip19 } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { isNip05 } from "./nip05";
import { isValidHexPubkey, isValidHexEventId, normalizeHex } from "./nostr-validation";
export interface ParsedReqCommand {
filter: NostrFilter;
@@ -268,9 +269,9 @@ function parseNpubOrHex(value: string): string | null {
}
}
// Check if it's hex (64 chars, hex characters)
if (/^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
// Check if it's hex pubkey
if (isValidHexPubkey(value)) {
return normalizeHex(value);
}
return null;
@@ -294,9 +295,9 @@ function parseNoteOrHex(value: string): string | null {
}
}
// Check if it's hex (64 chars, hex characters)
if (/^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
// Check if it's hex event ID
if (isValidHexEventId(value)) {
return normalizeHex(value);
}
return null;

View File

@@ -123,7 +123,7 @@ export const manPages: Record<string, ManPageEntry> = {
{
flag: "-a, --author <npub|hex|nip05>",
description:
"Filter by author pubkey (supports npub, hex, or NIP-05 identifier like user@domain.com)",
"Filter by author pubkey (supports npub, hex, NIP-05 identifier, or bare domain)",
},
{
flag: "-l, --limit <number>",
@@ -136,7 +136,7 @@ export const manPages: Record<string, ManPageEntry> = {
{
flag: "-p <npub|hex|nip05>",
description:
"Filter by mentioned pubkey (#p tag, supports npub, hex, or NIP-05 identifier)",
"Filter by mentioned pubkey (#p tag, supports npub, hex, NIP-05, or bare domain)",
},
{
flag: "-t <hashtag>",
@@ -175,6 +175,7 @@ export const manPages: Record<string, ManPageEntry> = {
"req -k 1 -l 20 Get 20 recent notes (streams live by default)",
"req -k 0 -a npub1... Get profile for author",
"req -k 1 -a user@domain.com Get notes from NIP-05 identifier",
"req -k 1 -a dergigi.com Get notes from bare domain (resolves to _@dergigi.com)",
"req -k 1 -p verbiricha@habla.news Get notes mentioning NIP-05 user",
"req -k 1 --since 1h relay.damus.io Get notes from last hour",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
@@ -261,7 +262,7 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "profile <identifier>",
description:
"Open a detailed view of a Nostr user profile. Accepts multiple identifier formats including npub, nprofile, and hex pubkeys. Displays profile metadata, inbox/outbox relays, and raw JSON.",
"Open a detailed view of a Nostr user profile. Accepts multiple identifier formats including npub, nprofile, hex pubkeys, and NIP-05 identifiers (including bare domains). Displays profile metadata, inbox/outbox relays, and raw JSON.",
options: [
{
flag: "<identifier>",
@@ -272,12 +273,15 @@ export const manPages: Record<string, ManPageEntry> = {
"profile npub1abc... Open profile by npub",
"profile nprofile1xyz... Open profile with relay hints",
"profile abc123... Open profile by hex pubkey (64 chars)",
"profile user@domain.com Open profile by NIP-05 identifier",
"profile jack@cash.app Open profile using NIP-05",
"profile dergigi.com Open profile by domain (resolves to _@dergigi.com)",
],
seeAlso: ["open", "req"],
appId: "profile",
category: "Nostr",
argParser: (args: string[]) => {
const parsed = parseProfileCommand(args);
argParser: async (args: string[]) => {
const parsed = await parseProfileCommand(args);
return parsed;
},
},
@@ -345,4 +349,30 @@ export const manPages: Record<string, ManPageEntry> = {
return { args };
},
},
relay: {
name: "relay",
section: "1",
synopsis: "relay <url>",
description:
"View detailed information about a Nostr relay. Displays NIP-11 relay information document including connection status, supported NIPs, operator details, limitations, and software information.",
options: [
{
flag: "<url>",
description:
"Relay WebSocket URL (wss:// or ws://) or domain (auto-adds wss://)",
},
],
examples: [
"relay wss://relay.damus.io View relay information",
"relay relay.primal.net Auto-adds wss:// protocol",
"relay nos.lol View relay capabilities",
],
seeAlso: ["req", "profile"],
appId: "relay",
category: "Nostr",
argParser: (args: string[]) => {
const parsed = parseRelayCommand(args);
return parsed;
},
},
};