From c8a6bfacf45b1d17278c20623bec815f8ea4118b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 14 Dec 2025 23:54:49 +0100 Subject: [PATCH] feat: basic git stuff rendering --- TODO.md | 80 +++++ src/components/EventDetailViewer.tsx | 15 +- .../nostr/kinds/BaseEventRenderer.tsx | 5 +- .../nostr/kinds/Kind1621DetailRenderer.tsx | 322 ++++++++++++++++++ .../nostr/kinds/Kind1621Renderer.tsx | 110 ++++++ .../nostr/kinds/Kind30617DetailRenderer.tsx | 192 +++++++++++ .../nostr/kinds/Kind30617Renderer.tsx | 65 ++++ src/components/nostr/kinds/index.tsx | 4 + src/constants/kinds.ts | 18 +- src/lib/nip34-helpers.ts | 124 +++++++ 10 files changed, 920 insertions(+), 15 deletions(-) create mode 100644 src/components/nostr/kinds/Kind1621DetailRenderer.tsx create mode 100644 src/components/nostr/kinds/Kind1621Renderer.tsx create mode 100644 src/components/nostr/kinds/Kind30617DetailRenderer.tsx create mode 100644 src/components/nostr/kinds/Kind30617Renderer.tsx create mode 100644 src/lib/nip34-helpers.ts diff --git a/TODO.md b/TODO.md index 58a462d..01c0472 100644 --- a/TODO.md +++ b/TODO.md @@ -95,6 +95,86 @@ When an action is entered, show the list of available options below and provide - **NIP badges everywhere** - Use consistent NIP badge components for linking to NIP documentation - **External spec event kind support** - Add references and documentation links for commented-out event kinds from external specs (Blossom, Marmot Protocol, NKBIP, nostrocket, Corny Chat, NUD, etc.) in `src/constants/kinds.ts`. Consider adding a separate registry or documentation for non-official-NIP event kinds. +## Code Quality & Refactoring + +### Semantic Component Naming +**Priority**: Low | **Effort**: Medium +**Files**: All Kind*Renderer.tsx files, EventDetailViewer.tsx, and import locations + +**Current State**: +- Component names use technical kind numbers: Kind0DetailRenderer, Kind3DetailView, Kind30023DetailRenderer +- Makes code less self-documenting for developers unfamiliar with Nostr kind numbers +- Requires mental mapping between kind numbers and their semantic meaning + +**Proposed Renaming**: +- `Kind0DetailRenderer` → `ProfileMetadataRenderer` (kind 0) +- `Kind0Renderer` → `ProfileMetadataFeedRenderer` +- `Kind3DetailView` / `Kind3Renderer` → `ContactListRenderer` (kind 3) +- `Kind1621Renderer` / `Kind1621DetailRenderer` → `IssueRenderer` / `IssueDetailRenderer` (kind 1621, NIP-34) +- `Kind30023Renderer` / `Kind30023DetailRenderer` → `LongFormArticleRenderer` / `ArticleDetailRenderer` (kind 30023) +- `Kind30617Renderer` / `Kind30617DetailRenderer` → `RepositoryRenderer` / `RepositoryDetailRenderer` (kind 30617, NIP-34) +- `Kind9802Renderer` / `Kind9802DetailRenderer` → `HighlightRenderer` / `HighlightDetailRenderer` (kind 9802) +- `Kind10002Renderer` / `Kind10002DetailRenderer` → `RelayListRenderer` / `RelayListDetailRenderer` (kind 10002) +- And all other Kind*Renderer files following this pattern + +**Implementation Tasks**: +1. Rename all Kind*Renderer.tsx files to semantic names +2. Update component exports and function names +3. Update all imports in EventDetailViewer.tsx +4. Update kind registry in src/components/nostr/kinds/index.tsx +5. Run build and tests to verify no breakage +6. Update any documentation references + +**Benefits**: +- Self-documenting code - component name explains what it renders +- Better developer experience for new contributors +- Easier to find components by searching for semantic names +- Aligns with common practices in React codebases + +**Note**: Keep kind number comments in files to maintain traceability to Nostr specs + +### Locale-Aware Date Formatting Audit +**Priority**: Medium | **Effort**: Low +**Files**: All component files that display dates/times + +**Current State**: +- `BaseEventRenderer.tsx` correctly implements locale-aware formatting using `formatTimestamp` from useLocale hook +- `useGrimoire()` provides locale state from grimoire state atom +- Pattern: `formatTimestamp(event.created_at, "relative", locale.locale)` for relative times +- Pattern: `formatTimestamp(event.created_at, "absolute", locale.locale)` for full dates + +**Audit Tasks**: +1. Search codebase for all date/time formatting +2. Identify any components using `new Date().toLocaleString()` without locale parameter +3. Identify any hardcoded date formats +4. Replace with formatTimestamp utility where applicable +5. Verify all date displays respect user's locale setting +6. Test with different locales (en-US, es-ES, ja-JP, ar-SA for RTL) + +**Known Good Patterns**: +- ✅ BaseEventRenderer - Uses formatTimestamp with locale +- ✅ EventDetailViewer - No date display (delegates to renderers) +- ✅ ProfileViewer - No date display currently + +**Files to Check**: +- All Kind*Renderer.tsx files +- Timeline/feed components +- Any custom date displays +- Comment/reply timestamps +- Event metadata displays + +**Testing**: +- Change locale in grimoire state +- Verify all dates update to new locale format +- Test relative times ("2m ago", "3h ago") in different languages +- Test absolute times with various locale date formats + +**Benefits**: +- Consistent internationalization support +- Better UX for non-English users +- Follows best practices for locale-aware applications +- Prepares codebase for full i18n implementation (see Phase 3.4) + ### NIP-22 Comment Threading Support **Priority**: High **Files**: `src/components/nostr/kinds/Kind1Renderer.tsx`, potentially new `Kind1111Renderer.tsx` diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 7eda8fa..171114a 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -4,9 +4,11 @@ import { useNostrEvent } from "@/hooks/useNostrEvent"; import { KindRenderer } from "./nostr/kinds"; import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer"; import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer"; -import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer"; +import { Kind1621DetailRenderer } from "./nostr/kinds/Kind1621DetailRenderer"; import { Kind9802DetailRenderer } from "./nostr/kinds/Kind9802DetailRenderer"; import { Kind10002DetailRenderer } from "./nostr/kinds/Kind10002DetailRenderer"; +import { Kind30023DetailRenderer } from "./nostr/kinds/Kind30023DetailRenderer"; +import { Kind30617DetailRenderer } from "./nostr/kinds/Kind30617DetailRenderer"; import { JsonViewer } from "./JsonViewer"; import { RelayLink } from "./nostr/RelayLink"; import { @@ -33,6 +35,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { nip19, kinds } from "nostr-tools"; import { useCopy } from "../hooks/useCopy"; import { getSeenRelays } from "applesauce-core/helpers/relays"; +import { getTagValue } from "applesauce-core/helpers"; import { useRelayState } from "@/hooks/useRelayState"; import type { RelayState } from "@/types/relay-state"; @@ -138,7 +141,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { : nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, - identifier: event.tags.find((t) => t[0] === "d")?.[1] || "", + identifier: getTagValue(event, "d") || "", relays: relays, }); @@ -264,12 +267,16 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { ) : event.kind === kinds.Contacts ? ( - ) : event.kind === kinds.LongFormArticle ? ( - + ) : event.kind === 1621 ? ( + ) : event.kind === kinds.Highlights ? ( ) : event.kind === kinds.RelayList ? ( + ) : event.kind === kinds.LongFormArticle ? ( + + ) : event.kind === 30617 ? ( + ) : ( )} diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 2808c2c..0aeaa5f 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -16,6 +16,7 @@ import { useCopy } from "@/hooks/useCopy"; import { JsonViewer } from "@/components/JsonViewer"; import { formatTimestamp } from "@/hooks/useLocale"; import { nip19 } from "nostr-tools"; +import { getTagValue } from "applesauce-core/helpers"; import { EventFooter } from "@/components/EventFooter"; // NIP-01 Kind ranges @@ -61,7 +62,7 @@ export function EventMenu({ event }: { event: NostrEvent }) { let pointer; if (isAddressable) { // Find d-tag for identifier - const dTag = event.tags.find((t) => t[0] === "d")?.[1] || ""; + const dTag = getTagValue(event, "d") || ""; pointer = { kind: event.kind, pubkey: event.pubkey, @@ -86,7 +87,7 @@ export function EventMenu({ event }: { event: NostrEvent }) { if (isAddressable) { // Find d-tag for identifier - const dTag = event.tags.find((t) => t[0] === "d")?.[1] || ""; + const dTag = getTagValue(event, "d") || ""; const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, diff --git a/src/components/nostr/kinds/Kind1621DetailRenderer.tsx b/src/components/nostr/kinds/Kind1621DetailRenderer.tsx new file mode 100644 index 0000000..32e41a7 --- /dev/null +++ b/src/components/nostr/kinds/Kind1621DetailRenderer.tsx @@ -0,0 +1,322 @@ +import { useMemo } from "react"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { remarkNostrMentions } from "applesauce-content/markdown"; +import { nip19 } from "nostr-tools"; +import { Tag, GitBranch } from "lucide-react"; +import { UserName } from "../UserName"; +import { EmbeddedEvent } from "../EmbeddedEvent"; +import { MediaEmbed } from "../MediaEmbed"; +import { useGrimoire } from "@/core/state"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import type { NostrEvent } from "@/types/nostr"; +import { + getIssueTitle, + getIssueLabels, + getIssueRepositoryAddress, +} from "@/lib/nip34-helpers"; +import { + getRepositoryName, + getRepositoryIdentifier, +} from "@/lib/nip34-helpers"; + +/** + * Component to render nostr: mentions inline + */ +function NostrMention({ href }: { href: string }) { + const { addWindow } = useGrimoire(); + + try { + // Remove nostr: prefix and any trailing characters + const cleanHref = href.replace(/^nostr:/, "").trim(); + + // If it doesn't look like a nostr identifier, just return the href as-is + if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) { + return ( + + {href} + + ); + } + + const parsed = nip19.decode(cleanHref); + + switch (parsed.type) { + case "npub": + return ( + + + + ); + case "nprofile": + return ( + + + + ); + case "note": + return ( + { + addWindow( + "open", + { id: id as string }, + `Event ${(id as string).slice(0, 8)}...`, + ); + }} + /> + ); + case "nevent": + return ( + { + addWindow( + "open", + { id: id as string }, + `Event ${(id as string).slice(0, 8)}...`, + ); + }} + /> + ); + case "naddr": + return ( + { + addWindow( + "open", + pointer, + `${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`, + ); + }} + /> + ); + default: + return {cleanHref}; + } + } catch (error) { + // If parsing fails, just render as a regular link + console.error("Failed to parse nostr link:", href, error); + return ( + + {href} + + ); + } +} + +/** + * Detail renderer for Kind 1621 - Issue + * Displays full issue content with markdown rendering + */ +export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) { + const { addWindow } = useGrimoire(); + const title = useMemo(() => getIssueTitle(event), [event]); + const labels = useMemo(() => getIssueLabels(event), [event]); + const repoAddress = useMemo(() => getIssueRepositoryAddress(event), [event]); + + // Parse repository address + const repoPointer = useMemo(() => { + if (!repoAddress) return null; + try { + const [kindStr, pubkey, identifier] = repoAddress.split(":"); + return { + kind: parseInt(kindStr), + pubkey, + identifier, + }; + } catch { + return null; + } + }, [repoAddress]); + + // Fetch repository event + const repoEvent = useNostrEvent(repoPointer || undefined); + + // Get repository display name + const repoName = repoEvent + ? getRepositoryName(repoEvent) || + getRepositoryIdentifier(repoEvent) || + "Repository" + : repoPointer?.identifier || "Unknown Repository"; + + // Format created date + const createdDate = new Date(event.created_at * 1000).toLocaleDateString( + "en-US", + { + year: "numeric", + month: "long", + day: "numeric", + }, + ); + + const handleRepoClick = () => { + if (!repoPointer || !repoEvent) return; + addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`); + }; + + return ( +
+ {/* Issue Header */} +
+ {/* Title */} +

{title || "Untitled Issue"}

+ + {/* Repository Link */} + {repoAddress && ( +
+ Repository: + +
+ )} + + {/* Metadata */} +
+
+ By + +
+ + +
+ + {/* Labels */} + {labels.length > 0 && ( +
+ + {labels.map((label, idx) => ( + + {label} + + ))} +
+ )} +
+ + {/* Issue Body - Markdown */} + {event.content ? ( +
+ { + if (url.startsWith("nostr:")) return url; + return defaultUrlTransform(url); + }} + components={{ + // Enable images with zoom + img: ({ src, alt }) => + src ? ( + + ) : null, + // Handle nostr: links + a: ({ href, children, ...props }) => { + if (!href) return null; + + // Render nostr: mentions inline + if (href.startsWith("nostr:")) { + return ; + } + + // Regular links + return ( + + {children} + + ); + }, + // Style adjustments for dark theme + h1: ({ ...props }) => ( +

+ ), + h2: ({ ...props }) => ( +

+ ), + h3: ({ ...props }) => ( +

+ ), + p: ({ ...props }) => ( +

+ ), + code: ({ ...props }: any) => ( + + ), + blockquote: ({ ...props }) => ( +

+ ), + ul: ({ ...props }) => ( +
    + ), + ol: ({ ...props }) => ( +
      + ), + hr: () =>
      , + }} + > + {event.content} + +

+ ) : ( +

+ (No description provided) +

+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/Kind1621Renderer.tsx b/src/components/nostr/kinds/Kind1621Renderer.tsx new file mode 100644 index 0000000..ede873d --- /dev/null +++ b/src/components/nostr/kinds/Kind1621Renderer.tsx @@ -0,0 +1,110 @@ +import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; +import { GitBranch } from "lucide-react"; +import { useGrimoire } from "@/core/state"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { + getIssueTitle, + getIssueLabels, + getIssueRepositoryAddress, +} from "@/lib/nip34-helpers"; +import { + getRepositoryName, + getRepositoryIdentifier, +} from "@/lib/nip34-helpers"; +import { UserName } from "../UserName"; + +/** + * Renderer for Kind 1621 - Issue + * Displays as a compact issue card in feed view + */ +export function Kind1621Renderer({ event }: BaseEventProps) { + const { addWindow } = useGrimoire(); + const title = getIssueTitle(event); + const labels = getIssueLabels(event); + const repoAddress = getIssueRepositoryAddress(event); + + // Parse repository address to get the pointer + const repoPointer = repoAddress + ? (() => { + try { + // Address format: "kind:pubkey:identifier" + const [kindStr, pubkey, identifier] = repoAddress.split(":"); + return { + kind: parseInt(kindStr), + pubkey, + identifier, + }; + } catch { + return null; + } + })() + : null; + + // Fetch the repository event to get its name + const repoEvent = useNostrEvent( + repoPointer + ? { + kind: repoPointer.kind, + pubkey: repoPointer.pubkey, + identifier: repoPointer.identifier, + } + : undefined, + ); + + // Get repository display name + const repoName = repoEvent + ? getRepositoryName(repoEvent) || + getRepositoryIdentifier(repoEvent) || + "Repository" + : repoAddress?.split(":")[2] || "Unknown Repository"; + + const handleRepoClick = () => { + addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`); + }; + + return ( + +
+ {/* Issue Title */} +

+ {title || "Untitled Issue"} +

+ + {/* Repository Reference */} + {repoAddress && repoPointer && ( +
+ in +
+ + {repoName} +
+ by + +
+ )} + + {/* Labels */} + {labels.length > 0 && ( +
+ {labels.map((label, idx) => ( + + {label} + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/Kind30617DetailRenderer.tsx b/src/components/nostr/kinds/Kind30617DetailRenderer.tsx new file mode 100644 index 0000000..cc335b9 --- /dev/null +++ b/src/components/nostr/kinds/Kind30617DetailRenderer.tsx @@ -0,0 +1,192 @@ +import { useMemo } from "react"; +import { Globe, Copy, Users, CopyCheck, Server } from "lucide-react"; +import { UserName } from "../UserName"; +import { RelayLink } from "../RelayLink"; +import { useCopy } from "@/hooks/useCopy"; +import type { NostrEvent } from "@/types/nostr"; +import { + getRepositoryName, + getRepositoryDescription, + getRepositoryIdentifier, + getCloneUrls, + getWebUrls, + getMaintainers, + getRepositoryRelays, +} from "@/lib/nip34-helpers"; + +/** + * Detail renderer for Kind 30617 - Repository + * Displays full repository metadata with all URLs and maintainers + */ +export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) { + const name = useMemo(() => getRepositoryName(event), [event]); + const description = useMemo(() => getRepositoryDescription(event), [event]); + const identifier = useMemo(() => getRepositoryIdentifier(event), [event]); + const webUrls = useMemo(() => getWebUrls(event), [event]); + const cloneUrls = useMemo(() => getCloneUrls(event), [event]); + const maintainers = useMemo(() => getMaintainers(event), [event]); + const relays = useMemo(() => getRepositoryRelays(event), [event]); + + const displayName = name || identifier || "Repository"; + + return ( +
+ {/* Repository Header */} +
+ {/* Name */} +

{displayName}

+ + {/* Identifier */} + {identifier && ( + + /{identifier} + + )} + + {/* Description */} + {description && ( +

+ {description} +

+ )} +
+ + {/* URLs Section */} + {(webUrls.length > 0 || cloneUrls.length > 0) && ( +
+

+ + URLs +

+ + {/* Web URLs */} + {webUrls.length > 0 && ( +
+

+ Website +

+
    + {webUrls.map((url, idx) => ( + + ))} +
+
+ )} + + {/* Clone URLs */} + {cloneUrls.length > 0 && ( +
+

+ git URLs +

+
    + {cloneUrls.map((url, idx) => ( + + ))} +
+
+ )} +
+ )} + + {/* Maintainers Section */} + {maintainers.length > 0 && ( +
+

+ + Maintainers +

+
+ {maintainers.map((pubkey) => ( + + ))} +
+
+ )} + + {/* Relay Hints Section */} + {relays.length > 0 && ( +
+

+ + Relays +

+
    + {relays.map((url) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} + +/** + * Component to display a web URL with copy button + */ +function UrlItem({ url }: { url: string }) { + const { copy, copied } = useCopy(); + + return ( +
  • + + {url} + + +
  • + ); +} + +/** + * Component to display a clone URL with copy button + */ +function CloneUrlItem({ url }: { url: string }) { + const { copy, copied } = useCopy(); + + return ( +
  • + + {url} + + +
  • + ); +} diff --git a/src/components/nostr/kinds/Kind30617Renderer.tsx b/src/components/nostr/kinds/Kind30617Renderer.tsx new file mode 100644 index 0000000..4407482 --- /dev/null +++ b/src/components/nostr/kinds/Kind30617Renderer.tsx @@ -0,0 +1,65 @@ +import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; +import { GitBranch, Globe } from "lucide-react"; +import { + getRepositoryName, + getRepositoryDescription, + getWebUrls, +} from "@/lib/nip34-helpers"; +import { getReplaceableIdentifier } from "applesauce-core/helpers"; + +/** + * Renderer for Kind 30617 - Repository + * Displays as a compact repository card in feed view + */ +export function Kind30617Renderer({ event }: BaseEventProps) { + const name = getRepositoryName(event); + const description = getRepositoryDescription(event); + const identifier = getReplaceableIdentifier(event); + const webUrls = getWebUrls(event); + + // Use name if available, otherwise use identifier, fallback to "Repository" + const displayName = name || identifier || "Repository"; + + return ( + +
    + {/* Repository Info */} +
    + {/* Name and Owner */} +
    +
    + + + {displayName} + +
    +
    + + {/* Description */} + {description && ( +

    + {description} +

    + )} + + {/* URLs and Maintainers */} +
    + {/* First Web URL */} + {webUrls.length > 0 && ( + e.stopPropagation()} + > + + {webUrls[0]} + + )} +
    +
    +
    +
    + ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 7e5780a..9ac9db9 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -8,10 +8,12 @@ import { Kind20Renderer } from "./Kind20Renderer"; import { Kind21Renderer } from "./Kind21Renderer"; import { Kind22Renderer } from "./Kind22Renderer"; import { Kind1063Renderer } from "./Kind1063Renderer"; +import { Kind1621Renderer } from "./Kind1621Renderer"; import { Kind9735Renderer } from "./Kind9735Renderer"; import { Kind9802Renderer } from "./Kind9802Renderer"; import { Kind10002Renderer } from "./Kind10002Renderer"; import { Kind30023Renderer } from "./Kind30023Renderer"; +import { Kind30617Renderer } from "./Kind30617Renderer"; import { Kind39701Renderer } from "./Kind39701Renderer"; import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; import { NostrEvent } from "@/types/nostr"; @@ -34,6 +36,7 @@ const kindRenderers: Record> = { 22: Kind22Renderer, // Short Video (NIP-71) 1063: Kind1063Renderer, // File Metadata (NIP-94) 1111: Kind1Renderer, // Post + 1621: Kind1621Renderer, // Issue (NIP-34) 9735: Kind9735Renderer, // Zap Receipt 9802: Kind9802Renderer, // Highlight 10002: Kind10002Renderer, // Relay List Metadata (NIP-65) @@ -43,6 +46,7 @@ const kindRenderers: Record> = { 10050: GenericRelayListRenderer, // DM Relay List (NIP-51) 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article + 30617: Kind30617Renderer, // Repository (NIP-34) 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 1b38ebb..5ced3d0 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -489,29 +489,29 @@ export const EVENT_KINDS: Record = { }, 1630: { kind: 1630, - name: "Status", - description: "Status", + name: "Open Status", + description: "Open", nip: "34", icon: Activity, }, 1631: { kind: 1631, - name: "Status", - description: "Status", + name: "Applied/Merged", + description: "Applied / Merged for Patches; Resolved for Issues", nip: "34", icon: Activity, }, 1632: { kind: 1632, - name: "Status", - description: "Status", + name: "Closed Status", + description: "Closed", nip: "34", icon: Activity, }, 1633: { kind: 1633, - name: "Status", - description: "Status", + name: "Draft Status", + description: "Draft", nip: "34", icon: Activity, }, @@ -1216,7 +1216,7 @@ export const EVENT_KINDS: Record = { }, 30617: { kind: 30617, - name: "Repo Announce", + name: "Repository", description: "Repository announcements", nip: "34", icon: GitBranch, diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts new file mode 100644 index 0000000..851d868 --- /dev/null +++ b/src/lib/nip34-helpers.ts @@ -0,0 +1,124 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; + +/** + * NIP-34 Helper Functions + * Utility functions for parsing NIP-34 git event tags + */ + +// ============================================================================ +// Repository Event Helpers (Kind 30617) +// ============================================================================ + +/** + * Get the repository name from a repository event + * @param event Repository event (kind 30617) + * @returns Repository name or undefined + */ +export function getRepositoryName(event: NostrEvent): string | undefined { + return getTagValue(event, "name"); +} + +/** + * Get the repository description + * @param event Repository event (kind 30617) + * @returns Repository description or undefined + */ +export function getRepositoryDescription( + event: NostrEvent, +): string | undefined { + return getTagValue(event, "description"); +} + +/** + * Get the repository identifier (d tag) + * @param event Repository event (kind 30617) + * @returns Repository identifier or undefined + */ +export function getRepositoryIdentifier(event: NostrEvent): string | undefined { + return getTagValue(event, "d"); +} + +/** + * Get all clone URLs from a repository event + * @param event Repository event (kind 30617) + * @returns Array of clone URLs + */ +export function getCloneUrls(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]); +} + +/** + * Get all web URLs from a repository event + * @param event Repository event (kind 30617) + * @returns Array of web URLs + */ +export function getWebUrls(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "web").map((t) => t[1]); +} + +/** + * Get all maintainer pubkeys from a repository event + * @param event Repository event (kind 30617) + * @returns Array of maintainer pubkeys + */ +export function getMaintainers(event: NostrEvent): string[] { + return event.tags + .filter((t) => t[0] === "maintainers") + .map((t) => t[1]) + .filter((p: string) => p !== event.pubkey); +} + +/** + * Get relay hints for patches and issues + * @param event Repository event (kind 30617) + * @returns Array of relay URLs + */ +export function getRepositoryRelays(event: NostrEvent): string[] { + const relaysTag = event.tags.find((t) => t[0] === "relays"); + if (!relaysTag) return []; + const [, ...relays] = relaysTag; + return relays; +} + +// ============================================================================ +// Issue Event Helpers (Kind 1621) +// ============================================================================ + +/** + * Get the issue title/subject + * @param event Issue event (kind 1621) + * @returns Issue title or undefined + */ +export function getIssueTitle(event: NostrEvent): string | undefined { + return getTagValue(event, "subject"); +} + +/** + * Get all issue labels/tags + * @param event Issue event (kind 1621) + * @returns Array of label strings + */ +export function getIssueLabels(event: NostrEvent): string[] { + return event.tags.filter((t) => t[0] === "t").map((t) => t[1]); +} + +/** + * Get the repository address pointer for an issue + * @param event Issue event (kind 1621) + * @returns Repository address pointer (a tag) or undefined + */ +export function getIssueRepositoryAddress( + event: NostrEvent, +): string | undefined { + return getTagValue(event, "a"); +} + +/** + * Get the repository owner pubkey for an issue + * @param event Issue event (kind 1621) + * @returns Repository owner pubkey or undefined + */ +export function getIssueRepositoryOwner(event: NostrEvent): string | undefined { + return getTagValue(event, "p"); +}