mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
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
This commit is contained in:
@@ -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 }) {
|
||||
<div>{eventType}</div>
|
||||
<div className="text-muted-foreground">Storage</div>
|
||||
<div>
|
||||
{kind >= EPHEMERAL_START && kind < EPHEMERAL_END
|
||||
{isEphemeralKind(kind)
|
||||
? "Not stored (ephemeral)"
|
||||
: "Stored by relays"}
|
||||
</div>
|
||||
{kind >= PARAMETERIZED_REPLACEABLE_START &&
|
||||
kind < PARAMETERIZED_REPLACEABLE_END && (
|
||||
{isParameterizedReplaceableKind(kind) && (
|
||||
<>
|
||||
<div className="text-muted-foreground">Identifier</div>
|
||||
<code className="font-mono text-xs">d-tag</code>
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
162
src/lib/nostr-kinds.test.ts
Normal file
162
src/lib/nostr-kinds.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
72
src/lib/nostr-kinds.ts
Normal file
72
src/lib/nostr-kinds.ts
Normal file
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user