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 }) {
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';
+}