feat: kind links

This commit is contained in:
Alejandro Gómez
2025-12-12 22:22:28 +01:00
parent e595ddc7a9
commit 32160480e2
11 changed files with 84 additions and 52 deletions

View File

@@ -2,11 +2,7 @@ import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import "./command-launcher.css";

View File

@@ -10,6 +10,7 @@ import { useGrimoire } from "@/core/state";
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { KindBadge } from "./KindBadge";
interface DecodeViewerProps {
args: string[];
@@ -216,20 +217,21 @@ export default function DecodeViewer({ args }: DecodeViewerProps) {
)}
{type === "naddr" && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Kind</div>
<div className="bg-muted p-2 rounded font-mono text-xs">
{(data as any).kind}
</div>
<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 className="space-y-1 col-span-2">
<div className="text-xs text-muted-foreground">
Identifier
</div>
<div className="bg-muted p-2 rounded font-mono text-xs truncate">
{(data as any).identifier}
</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">

View File

@@ -1,5 +1,6 @@
import { getKindInfo } from "@/constants/kinds";
import { cn } from "@/lib/utils";
import { useGrimoire } from "@/core/state";
interface KindBadgeProps {
kind: number;
@@ -9,6 +10,7 @@ interface KindBadgeProps {
variant?: "default" | "compact" | "full";
className?: string;
iconClassname?: string;
clickable?: boolean;
}
export function KindBadge({
@@ -19,11 +21,20 @@ export function KindBadge({
variant = "default",
className = "",
iconClassname = "text-muted-foreground",
clickable = false,
}: KindBadgeProps) {
const { addWindow } = useGrimoire();
const kindInfo = getKindInfo(kind);
const Icon = kindInfo?.icon;
const style = "inline-flex items-center gap-2 text-foreground";
const interactiveStyle = clickable ? "cursor-pointer" : "";
const handleClick = () => {
if (clickable) {
addWindow("kind", { number: String(kind) }, `Kind ${kind}`);
}
};
// Apply variant presets or use props
let showIcon = propShowIcon ?? true;
@@ -42,7 +53,10 @@ export function KindBadge({
if (!kindInfo) {
return (
<div className={cn(style, className)}>
<div
className={cn(style, interactiveStyle, className)}
onClick={handleClick}
>
<span>Kind {kind}</span>
</div>
);
@@ -50,11 +64,22 @@ export function KindBadge({
return (
<div
className={cn(style, className)}
title={`${kindInfo.description} (NIP-${kindInfo.nip})`}
className={cn(style, interactiveStyle, className)}
title={`${kindInfo.description} (NIP-${kindInfo.nip})${clickable ? " - Click to view" : ""}`}
onClick={handleClick}
>
{showIcon && Icon && <Icon className={cn("size-4", iconClassname)} />}
{showName && <span>{kindInfo.name}</span>}
{showName && (
<span
className={
clickable
? "cursor-crosshair hover:underline decoration-dotted"
: ""
}
>
{kindInfo.name}
</span>
)}
{showKindNumber && <span>({kind})</span>}
</div>
);

View File

@@ -47,7 +47,7 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
</h3>
<div className="flex flex-wrap gap-2">
{kinds.map((kind) => (
<KindBadge key={kind} kind={kind} variant="full" />
<KindBadge key={kind} kind={kind} variant="full" clickable />
))}
</div>
</div>

View File

@@ -50,7 +50,7 @@ export function Kind3DetailView({ event }: { event: any }) {
const { state } = useGrimoire();
const followedPubkeys = getTagValues(event, "p").filter(
(pk) => pk.length === 64
(pk) => pk.length === 64,
);
const topics = getTagValues(event, "t");

View File

@@ -90,7 +90,11 @@ export {
} from "./BaseEventRenderer";
export type { BaseEventProps } from "./BaseEventRenderer";
export { Kind1Renderer } from "./Kind1Renderer";
export { RepostRenderer, Kind6Renderer, Kind16Renderer } from "./RepostRenderer";
export {
RepostRenderer,
Kind6Renderer,
Kind16Renderer,
} from "./RepostRenderer";
export { Kind7Renderer } from "./Kind7Renderer";
export { Kind20Renderer } from "./Kind20Renderer";
export { Kind21Renderer } from "./Kind21Renderer";

View File

@@ -9,11 +9,7 @@ export const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
<span
ref={ref}
className={cn("sr-only", className)}
{...props}
/>
<span ref={ref} className={cn("sr-only", className)} {...props} />
));
VisuallyHidden.displayName = "VisuallyHidden";

View File

@@ -64,7 +64,10 @@ describe("parseReqCommand", () => {
"-a",
"user@domain.com,alice@example.com",
]);
expect(result.nip05Authors).toEqual(["user@domain.com", "alice@example.com"]);
expect(result.nip05Authors).toEqual([
"user@domain.com",
"alice@example.com",
]);
expect(result.filter.authors).toBeUndefined();
});
@@ -76,10 +79,7 @@ describe("parseReqCommand", () => {
});
it("should deduplicate NIP-05 identifiers", () => {
const result = parseReqCommand([
"-a",
"user@domain.com,user@domain.com",
]);
const result = parseReqCommand(["-a", "user@domain.com,user@domain.com"]);
expect(result.nip05Authors).toEqual(["user@domain.com"]);
});
});
@@ -120,8 +120,14 @@ describe("parseReqCommand", () => {
});
it("should accumulate NIP-05 identifiers for #p tags", () => {
const result = parseReqCommand(["-p", "user@domain.com,alice@example.com"]);
expect(result.nip05PTags).toEqual(["user@domain.com", "alice@example.com"]);
const result = parseReqCommand([
"-p",
"user@domain.com,alice@example.com",
]);
expect(result.nip05PTags).toEqual([
"user@domain.com",
"alice@example.com",
]);
expect(result.filter["#p"]).toBeUndefined();
});

View File

@@ -22,9 +22,9 @@ export interface ParsedReqCommand {
function parseCommaSeparated<T>(
value: string,
parser: (v: string) => T | null,
target: Set<T>
target: Set<T>,
): boolean {
const values = value.split(',').map(v => v.trim());
const values = value.split(",").map((v) => v.trim());
let addedAny = false;
for (const val of values) {
@@ -102,7 +102,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const kind = parseInt(v, 10);
return isNaN(kind) ? null : kind;
},
kinds
kinds,
);
i += addedAny ? 2 : 1;
break;
@@ -116,7 +116,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
break;
}
let addedAny = false;
const values = nextArg.split(',').map(a => a.trim());
const values = nextArg.split(",").map((a) => a.trim());
for (const authorStr of values) {
if (!authorStr) continue;
// Check if it's a NIP-05 identifier
@@ -156,7 +156,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const addedAny = parseCommaSeparated(
nextArg,
parseNoteOrHex,
eventIds
eventIds,
);
i += addedAny ? 2 : 1;
break;
@@ -169,7 +169,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
break;
}
let addedAny = false;
const values = nextArg.split(',').map(p => p.trim());
const values = nextArg.split(",").map((p) => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check if it's a NIP-05 identifier
@@ -194,7 +194,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const addedAny = parseCommaSeparated(
nextArg,
(v) => v, // hashtags are already strings
tTags
tTags,
);
i += addedAny ? 2 : 1;
} else {
@@ -209,7 +209,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const addedAny = parseCommaSeparated(
nextArg,
(v) => v, // d-tags are already strings
dTags
dTags,
);
i += addedAny ? 2 : 1;
} else {

View File

@@ -157,7 +157,8 @@ export const manPages: Record<string, ManPageEntry> = {
},
{
flag: "-e <id>",
description: "Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3",
description:
"Filter by referenced event ID (#e tag). Supports comma-separated values: -e id1,id2,id3",
},
{
flag: "-p <npub|hex|nip05>",
@@ -166,11 +167,13 @@ export const manPages: Record<string, ManPageEntry> = {
},
{
flag: "-t <hashtag>",
description: "Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
description:
"Filter by hashtag (#t tag). Supports comma-separated values: -t nostr,bitcoin,lightning",
},
{
flag: "-d <identifier>",
description: "Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2",
description:
"Filter by d-tag identifier (replaceable events). Supports comma-separated values: -d article1,article2",
},
{
flag: "--since <time>",

View File

@@ -1,14 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: 'node',
environment: "node",
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
"@": path.resolve(__dirname, "./src"),
},
},
});