From 96216450f4cb64fc5f404d9cff850926ce7cbc21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 11:58:46 +0000 Subject: [PATCH] feat: add centralized nostr kind utilities - Create src/lib/nostr-kinds.ts with: - Re-exports from nostr-tools/kinds (isRegularKind, isReplaceableKind, etc.) - New isParameterizedReplaceableKind() function - New isAddressableKind() for determining naddr vs nevent encoding - NIP-01 boundary constants with clarifying comments - getKindCategory() for display purposes - Update KindRenderer.tsx to use shared utilities: - Replace inline range checks with helper functions - Fix "Regular Lists" -> "Replaceable Events" naming - Simplify redundant condition (isReplaceableKind includes kinds 0, 3) - Update BaseEventRenderer.tsx to use isAddressableKind() - Add comprehensive tests for all utilities --- src/components/KindRenderer.tsx | 45 ++--- .../nostr/kinds/BaseEventRenderer.tsx | 39 +---- src/lib/nostr-kinds.test.ts | 162 ++++++++++++++++++ src/lib/nostr-kinds.ts | 72 ++++++++ 4 files changed, 254 insertions(+), 64 deletions(-) create mode 100644 src/lib/nostr-kinds.test.ts create mode 100644 src/lib/nostr-kinds.ts diff --git a/src/components/KindRenderer.tsx b/src/components/KindRenderer.tsx index 4682c76..da229ba 100644 --- a/src/components/KindRenderer.tsx +++ b/src/components/KindRenderer.tsx @@ -1,5 +1,4 @@ import { getKindInfo } from "@/constants/kinds"; -import { kinds } from "nostr-tools"; import { NIPBadge } from "./NIPBadge"; import { Copy, CopyCheck } from "lucide-react"; import { Button } from "./ui/button"; @@ -10,14 +9,11 @@ import { getContentTypeDescription, } from "@/lib/nostr-schema"; import { CenteredContent } from "./ui/CenteredContent"; - -// NIP-01 Kind ranges -const REPLACEABLE_START = 10000; -const REPLACEABLE_END = 20000; -const EPHEMERAL_START = 20000; -const EPHEMERAL_END = 30000; -const PARAMETERIZED_REPLACEABLE_START = 30000; -const PARAMETERIZED_REPLACEABLE_END = 40000; +import { + isReplaceableKind, + isEphemeralKind, + isParameterizedReplaceableKind, +} from "@/lib/nostr-kinds"; export default function KindRenderer({ kind }: { kind: number }) { const kindInfo = getKindInfo(kind); @@ -83,12 +79,11 @@ export default function KindRenderer({ kind }: { kind: number }) {
{eventType}
Storage
- {kind >= EPHEMERAL_START && kind < EPHEMERAL_END + {isEphemeralKind(kind) ? "Not stored (ephemeral)" : "Stored by relays"}
- {kind >= PARAMETERIZED_REPLACEABLE_START && - kind < PARAMETERIZED_REPLACEABLE_END && ( + {isParameterizedReplaceableKind(kind) && ( <>
Identifier
d-tag @@ -194,15 +189,9 @@ function getKindCategory(kind: number): string { if (kind >= 20 && kind <= 39) return "Media & Content"; if (kind >= 40 && kind <= 49) return "Channels"; if (kind >= 1000 && kind <= 9999) return "Application Specific"; - if (kind >= REPLACEABLE_START && kind < REPLACEABLE_END) - return "Regular Lists"; - if (kind >= EPHEMERAL_START && kind < EPHEMERAL_END) - return "Ephemeral Events"; - if ( - kind >= PARAMETERIZED_REPLACEABLE_START && - kind < PARAMETERIZED_REPLACEABLE_END - ) - return "Parameterized Replaceable"; + if (isReplaceableKind(kind)) return "Replaceable Events"; + if (isEphemeralKind(kind)) return "Ephemeral Events"; + if (isParameterizedReplaceableKind(kind)) return "Parameterized Replaceable"; if (kind >= 40000) return "Custom/Experimental"; return "Other"; } @@ -211,20 +200,14 @@ function getKindCategory(kind: number): string { * Determine the replaceability of an event kind */ function getEventType(kind: number): string { - if ( - kind === kinds.Metadata || - kind === kinds.Contacts || - (kind >= REPLACEABLE_START && kind < REPLACEABLE_END) - ) { + // nostr-tools' isReplaceableKind already includes kinds 0 (Metadata) and 3 (Contacts) + if (isReplaceableKind(kind)) { return "Replaceable"; } - if ( - kind >= PARAMETERIZED_REPLACEABLE_START && - kind < PARAMETERIZED_REPLACEABLE_END - ) { + if (isParameterizedReplaceableKind(kind)) { return "Parameterized Replaceable"; } - if (kind >= EPHEMERAL_START && kind < EPHEMERAL_END) { + if (isEphemeralKind(kind)) { return "Ephemeral"; } return "Regular"; diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 62fc786..cb04e62 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { NostrEvent } from "@/types/nostr"; import { UserName } from "../UserName"; import { KindBadge } from "@/components/KindBadge"; -// import { kinds } from "nostr-tools"; import { DropdownMenu, DropdownMenuContent, @@ -20,17 +19,7 @@ import { nip19 } from "nostr-tools"; import { getTagValue } from "applesauce-core/helpers"; import { EventFooter } from "@/components/EventFooter"; import { cn } from "@/lib/utils"; -// import { RichText } from "../RichText"; -// import { getEventReply } from "@/lib/nostr-utils"; -// import { useNostrEvent } from "@/hooks/useNostrEvent"; -// import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; -// import { Skeleton } from "@/components/ui/skeleton"; - -// NIP-01 Kind ranges -const REPLACEABLE_START = 10000; -const REPLACEABLE_END = 20000; -const PARAMETERIZED_REPLACEABLE_START = 30000; -const PARAMETERIZED_REPLACEABLE_END = 40000; +import { isAddressableKind } from "@/lib/nostr-kinds"; /** * Universal event properties and utilities shared across all kind renderers @@ -116,14 +105,9 @@ export function EventMenu({ event }: { event: NostrEvent }) { const [jsonDialogOpen, setJsonDialogOpen] = useState(false); const openEventDetail = () => { - // For replaceable/parameterized replaceable events, use AddressPointer - const isAddressable = - (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || - (event.kind >= PARAMETERIZED_REPLACEABLE_START && - event.kind < PARAMETERIZED_REPLACEABLE_END); - let pointer; - if (isAddressable) { + // For replaceable/parameterized replaceable events, use AddressPointer + if (isAddressableKind(event.kind)) { // Find d-tag for identifier const dTag = getTagValue(event, "d") || ""; pointer = { @@ -143,12 +127,7 @@ export function EventMenu({ event }: { event: NostrEvent }) { const copyEventId = () => { // For replaceable/parameterized replaceable events, encode as naddr - const isAddressable = - (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || - (event.kind >= PARAMETERIZED_REPLACEABLE_START && - event.kind < PARAMETERIZED_REPLACEABLE_END); - - if (isAddressable) { + if (isAddressableKind(event.kind)) { // Find d-tag for identifier const dTag = getTagValue(event, "d") || ""; const naddr = nip19.naddrEncode({ @@ -242,16 +221,10 @@ export function ClickableEventTitle({ const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); - // Determine if event is addressable/replaceable - const isAddressable = - (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || - (event.kind >= PARAMETERIZED_REPLACEABLE_START && - event.kind < PARAMETERIZED_REPLACEABLE_END); - let pointer; - if (isAddressable) { - // For replaceable/parameterized replaceable events, use AddressPointer + // For replaceable/parameterized replaceable events, use AddressPointer + if (isAddressableKind(event.kind)) { const dTag = getTagValue(event, "d") || ""; pointer = { kind: event.kind, diff --git a/src/lib/nostr-kinds.test.ts b/src/lib/nostr-kinds.test.ts new file mode 100644 index 0000000..a0f0c13 --- /dev/null +++ b/src/lib/nostr-kinds.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + isRegularKind, + isReplaceableKind, + isEphemeralKind, + isParameterizedReplaceableKind, + isAddressableKind, + getKindCategory, + REGULAR_START, + REGULAR_END, + REPLACEABLE_START, + REPLACEABLE_END, + EPHEMERAL_START, + EPHEMERAL_END, + PARAMETERIZED_REPLACEABLE_START, + PARAMETERIZED_REPLACEABLE_END, +} from "./nostr-kinds"; + +describe("nostr-kinds constants", () => { + it("should have correct NIP-01 boundaries", () => { + expect(REGULAR_START).toBe(0); + expect(REGULAR_END).toBe(10000); + expect(REPLACEABLE_START).toBe(10000); + expect(REPLACEABLE_END).toBe(20000); + expect(EPHEMERAL_START).toBe(20000); + expect(EPHEMERAL_END).toBe(30000); + expect(PARAMETERIZED_REPLACEABLE_START).toBe(30000); + expect(PARAMETERIZED_REPLACEABLE_END).toBe(40000); + }); +}); + +describe("isRegularKind (from nostr-tools)", () => { + it("should return true for regular kinds", () => { + expect(isRegularKind(1)).toBe(true); // Text note + expect(isRegularKind(7)).toBe(true); // Reaction + expect(isRegularKind(9999)).toBe(true); + }); + + it("should return false for special replaceable kinds 0 and 3", () => { + // nostr-tools treats 0 (Metadata) and 3 (Contacts) as replaceable, not regular + expect(isRegularKind(0)).toBe(false); + expect(isRegularKind(3)).toBe(false); + }); + + it("should return false for non-regular kinds", () => { + expect(isRegularKind(10000)).toBe(false); + expect(isRegularKind(20000)).toBe(false); + expect(isRegularKind(30000)).toBe(false); + }); +}); + +describe("isReplaceableKind (from nostr-tools)", () => { + it("should return true for replaceable kinds (0, 3, 10000-19999)", () => { + // nostr-tools includes 0 (Metadata) and 3 (Contacts) as replaceable + expect(isReplaceableKind(0)).toBe(true); // Metadata + expect(isReplaceableKind(3)).toBe(true); // Contacts + expect(isReplaceableKind(10000)).toBe(true); + expect(isReplaceableKind(10002)).toBe(true); // Relay list + expect(isReplaceableKind(19999)).toBe(true); + }); + + it("should return false for non-replaceable kinds", () => { + expect(isReplaceableKind(1)).toBe(false); + expect(isReplaceableKind(7)).toBe(false); + expect(isReplaceableKind(20000)).toBe(false); + expect(isReplaceableKind(30000)).toBe(false); + }); +}); + +describe("isEphemeralKind (from nostr-tools)", () => { + it("should return true for ephemeral kinds (20000-29999)", () => { + expect(isEphemeralKind(20000)).toBe(true); + expect(isEphemeralKind(22242)).toBe(true); // Auth + expect(isEphemeralKind(29999)).toBe(true); + }); + + it("should return false for non-ephemeral kinds", () => { + expect(isEphemeralKind(0)).toBe(false); + expect(isEphemeralKind(10000)).toBe(false); + expect(isEphemeralKind(19999)).toBe(false); + expect(isEphemeralKind(30000)).toBe(false); + }); +}); + +describe("isParameterizedReplaceableKind", () => { + it("should return true for parameterized replaceable kinds (30000-39999)", () => { + expect(isParameterizedReplaceableKind(30000)).toBe(true); + expect(isParameterizedReplaceableKind(30023)).toBe(true); // Long-form content + expect(isParameterizedReplaceableKind(30311)).toBe(true); // Live activity + expect(isParameterizedReplaceableKind(39999)).toBe(true); + }); + + it("should return false for non-parameterized replaceable kinds", () => { + expect(isParameterizedReplaceableKind(0)).toBe(false); + expect(isParameterizedReplaceableKind(1)).toBe(false); + expect(isParameterizedReplaceableKind(10002)).toBe(false); + expect(isParameterizedReplaceableKind(20000)).toBe(false); + expect(isParameterizedReplaceableKind(40000)).toBe(false); + }); +}); + +describe("isAddressableKind", () => { + it("should return true for special replaceable kinds 0 and 3", () => { + expect(isAddressableKind(0)).toBe(true); // Metadata + expect(isAddressableKind(3)).toBe(true); // Contacts + }); + + it("should return true for replaceable kinds (10000-19999)", () => { + expect(isAddressableKind(10000)).toBe(true); + expect(isAddressableKind(10002)).toBe(true); + expect(isAddressableKind(19999)).toBe(true); + }); + + it("should return true for parameterized replaceable kinds", () => { + expect(isAddressableKind(30000)).toBe(true); + expect(isAddressableKind(30023)).toBe(true); + expect(isAddressableKind(39999)).toBe(true); + }); + + it("should return false for regular kinds", () => { + expect(isAddressableKind(1)).toBe(false); + expect(isAddressableKind(7)).toBe(false); + expect(isAddressableKind(9999)).toBe(false); + }); + + it("should return false for ephemeral kinds", () => { + expect(isAddressableKind(20000)).toBe(false); + expect(isAddressableKind(22242)).toBe(false); + expect(isAddressableKind(29999)).toBe(false); + }); +}); + +describe("getKindCategory", () => { + it("should categorize special replaceable kinds 0 and 3", () => { + expect(getKindCategory(0)).toBe("replaceable"); + expect(getKindCategory(3)).toBe("replaceable"); + }); + + it("should categorize regular kinds", () => { + expect(getKindCategory(1)).toBe("regular"); + expect(getKindCategory(7)).toBe("regular"); + expect(getKindCategory(9999)).toBe("regular"); + }); + + it("should categorize replaceable kinds", () => { + expect(getKindCategory(10000)).toBe("replaceable"); + expect(getKindCategory(10002)).toBe("replaceable"); + expect(getKindCategory(19999)).toBe("replaceable"); + }); + + it("should categorize ephemeral kinds", () => { + expect(getKindCategory(20000)).toBe("ephemeral"); + expect(getKindCategory(22242)).toBe("ephemeral"); + expect(getKindCategory(29999)).toBe("ephemeral"); + }); + + it("should categorize parameterized replaceable kinds", () => { + expect(getKindCategory(30000)).toBe("parameterized_replaceable"); + expect(getKindCategory(30023)).toBe("parameterized_replaceable"); + expect(getKindCategory(39999)).toBe("parameterized_replaceable"); + }); +}); diff --git a/src/lib/nostr-kinds.ts b/src/lib/nostr-kinds.ts new file mode 100644 index 0000000..126ee26 --- /dev/null +++ b/src/lib/nostr-kinds.ts @@ -0,0 +1,72 @@ +/** + * Nostr event kind range constants and utilities + * + * Re-exports from nostr-tools where available, with additional + * Grimoire-specific utilities. + * + * Based on NIP-01 specification: + * - Regular kinds: 0-9999 (non-replaceable, except 0 and 3) + * - Replaceable kinds: 0, 3, 10000-19999 (replaced by newer) + * - Ephemeral kinds: 20000-29999 (not stored) + * - Parameterized replaceable: 30000-39999 (replaced by kind+pubkey+d-tag) + */ + +// Re-export from nostr-tools for consistency +export { + isRegularKind, + isReplaceableKind, + isEphemeralKind, + classifyKind, +} from "nostr-tools/kinds"; + +// Import for internal use +import { + isReplaceableKind as _isReplaceableKind, + isEphemeralKind as _isEphemeralKind, +} from "nostr-tools/kinds"; + +// Kind range boundaries (NIP-01) - exported for display purposes only +// Note: END values are exclusive (e.g., REGULAR covers 0-9999, not 10000) +// Exception: kinds 0 (Metadata) and 3 (Contacts) are replaceable despite being < 10000 +export const REGULAR_START = 0; +export const REGULAR_END = 10000; // exclusive: regular kinds are 0-9999 (except 0, 3) +export const REPLACEABLE_START = 10000; +export const REPLACEABLE_END = 20000; // exclusive: replaceable kinds are 10000-19999 +export const EPHEMERAL_START = 20000; +export const EPHEMERAL_END = 30000; // exclusive: ephemeral kinds are 20000-29999 +export const PARAMETERIZED_REPLACEABLE_START = 30000; +export const PARAMETERIZED_REPLACEABLE_END = 40000; // exclusive: parameterized replaceable are 30000-39999 + +/** + * Check if a kind is parameterized replaceable (NIP-01) + * Kinds 30000-39999 are replaced by newer events from same pubkey with same d-tag + * + * Note: nostr-tools calls this "addressable" but we use "parameterized replaceable" + * for consistency with NIP-01 terminology + */ +export function isParameterizedReplaceableKind(kind: number): boolean { + return kind >= PARAMETERIZED_REPLACEABLE_START && kind < PARAMETERIZED_REPLACEABLE_END; +} + +/** + * Check if a kind should use naddr/AddressPointer instead of nevent/EventPointer + * + * This includes both: + * - Replaceable kinds (0, 3, 10000-19999) - identified by pubkey+kind + * - Parameterized replaceable kinds (30000-39999) - identified by pubkey+kind+d-tag + * + * Use this to determine how to encode event references (naddr vs nevent) + */ +export function isAddressableKind(kind: number): boolean { + return _isReplaceableKind(kind) || isParameterizedReplaceableKind(kind); +} + +/** + * Get the category of a kind for display purposes + */ +export function getKindCategory(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'parameterized_replaceable' { + if (_isReplaceableKind(kind)) return 'replaceable'; + if (_isEphemeralKind(kind)) return 'ephemeral'; + if (isParameterizedReplaceableKind(kind)) return 'parameterized_replaceable'; + return 'regular'; +}