feat: basic git stuff rendering

This commit is contained in:
Alejandro Gómez
2025-12-14 23:54:49 +01:00
parent f4d0048b30
commit c8a6bfacf4
10 changed files with 920 additions and 15 deletions

View File

@@ -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) {
<Kind0DetailRenderer event={event} />
) : event.kind === kinds.Contacts ? (
<Kind3DetailView event={event} />
) : event.kind === kinds.LongFormArticle ? (
<Kind30023DetailRenderer event={event} />
) : event.kind === 1621 ? (
<Kind1621DetailRenderer event={event} />
) : event.kind === kinds.Highlights ? (
<Kind9802DetailRenderer event={event} />
) : event.kind === kinds.RelayList ? (
<Kind10002DetailRenderer event={event} />
) : event.kind === kinds.LongFormArticle ? (
<Kind30023DetailRenderer event={event} />
) : event.kind === 30617 ? (
<Kind30617DetailRenderer event={event} />
) : (
<KindRenderer event={event} />
)}

View File

@@ -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,

View File

@@ -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 (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
const parsed = nip19.decode(cleanHref);
switch (parsed.type) {
case "npub":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data}
className="text-accent font-semibold"
/>
</span>
);
case "nprofile":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data.pubkey}
className="text-accent font-semibold"
/>
</span>
);
case "note":
return (
<EmbeddedEvent
eventId={parsed.data}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "nevent":
return (
<EmbeddedEvent
eventId={parsed.data.id}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "naddr":
return (
<EmbeddedEvent
addressPointer={parsed.data}
onOpen={(pointer) => {
addWindow(
"open",
pointer,
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
);
}}
/>
);
default:
return <span className="text-muted-foreground">{cleanHref}</span>;
}
} catch (error) {
// If parsing fails, just render as a regular link
console.error("Failed to parse nostr link:", href, error);
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
}
/**
* 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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Issue Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">{title || "Untitled Issue"}</h1>
{/* Repository Link */}
{repoAddress && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Repository:</span>
<button
onClick={repoEvent ? handleRepoClick : undefined}
disabled={!repoEvent}
className={`flex items-center gap-2 font-mono ${
repoEvent
? "text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-primary"
: "text-muted-foreground cursor-not-allowed"
}`}
>
<GitBranch className="size-4" />
{repoName}
</button>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
<span></span>
<time>{createdDate}</time>
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<Tag className="size-3 text-muted-foreground" />
{labels.map((label, idx) => (
<span
key={idx}
className="px-3 py-1 border border-muted border-dotted text-muted-foreground text-xs"
>
{label}
</span>
))}
</div>
)}
</header>
{/* Issue Body - Markdown */}
{event.content ? (
<article className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
skipHtml
urlTransform={(url) => {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}}
components={{
// Enable images with zoom
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
) : null,
// Handle nostr: links
a: ({ href, children, ...props }) => {
if (!href) return null;
// Render nostr: mentions inline
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
// Regular links
return (
<a
href={href}
className="text-accent underline decoration-dotted"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
// Style adjustments for dark theme
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...props} />
),
code: ({ ...props }: any) => (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
/>
),
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: ({ ...props }) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}}
>
{event.content}
</ReactMarkdown>
</article>
) : (
<p className="text-sm text-muted-foreground italic">
(No description provided)
</p>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Issue Title */}
<h3 className="font-semibold text-foreground">
{title || "Untitled Issue"}
</h3>
{/* Repository Reference */}
{repoAddress && repoPointer && (
<div className="flex items-center gap-1 text-xs line-clamp-1">
<span>in </span>
<div
onClick={handleRepoClick}
className={`flex items-center gap-1 text-muted-foreground
cursor-crosshair underline decoration-dotted hover:text-primary
`}
>
<GitBranch className="size-3" />
<span>{repoName}</span>
</div>
<span>by</span>
<UserName pubkey={repoPointer.pubkey} />
</div>
)}
{/* Labels */}
{labels.length > 0 && (
<div
className="flex
items-center gap-1 overflow-x-scroll my-1"
>
{labels.map((label, idx) => (
<span
key={idx}
className="px-2 py-0.5 border border-muted border-dotted text-xs text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Repository Header */}
<header className="flex flex-col gap-4 border-b border-border pb-4">
{/* Name */}
<h1 className="text-3xl font-bold">{displayName}</h1>
{/* Identifier */}
{identifier && (
<code className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1 w-fit">
<UserName pubkey={event.pubkey} />/{identifier}
</code>
)}
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground leading-relaxed">
{description}
</p>
)}
</header>
{/* URLs Section */}
{(webUrls.length > 0 || cloneUrls.length > 0) && (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Globe className="size-5" />
URLs
</h2>
{/* Web URLs */}
{webUrls.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">
Website
</h3>
<ul className="flex flex-col gap-2">
{webUrls.map((url, idx) => (
<UrlItem key={idx} url={url} />
))}
</ul>
</div>
)}
{/* Clone URLs */}
{cloneUrls.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">
git URLs
</h3>
<ul className="flex flex-col gap-2">
{cloneUrls.map((url, idx) => (
<CloneUrlItem key={idx} url={url} />
))}
</ul>
</div>
)}
</section>
)}
{/* Maintainers Section */}
{maintainers.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Users className="size-5" />
Maintainers
</h2>
<div className="flex flex-wrap gap-3">
{maintainers.map((pubkey) => (
<UserName
key={pubkey}
pubkey={pubkey}
className="font-semibold"
/>
))}
</div>
</section>
)}
{/* Relay Hints Section */}
{relays.length > 0 && (
<section className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Server className="size-5" />
Relays
</h2>
<ul className="flex flex-col gap-2">
{relays.map((url) => (
<li key={url} className="flex items-center gap-2">
<RelayLink
url={url}
showInboxOutbox={false}
className="hover:bg-background hover:underline hover:decoration-dotted"
urlClassname="text-sm"
iconClassname="size-3"
/>
</li>
))}
</ul>
</section>
)}
</div>
);
}
/**
* Component to display a web URL with copy button
*/
function UrlItem({ url }: { url: string }) {
const { copy, copied } = useCopy();
return (
<li className="flex items-center gap-2 p-2 bg-muted/30 group">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-sm text-muted-foreground hover:underline hover:decoration-dotted cursor-crosshair line-clamp-1 break-all"
>
{url}
</a>
<button
onClick={() => copy(url)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy URL"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</li>
);
}
/**
* Component to display a clone URL with copy button
*/
function CloneUrlItem({ url }: { url: string }) {
const { copy, copied } = useCopy();
return (
<li className="flex items-center gap-2 p-2 bg-muted/30 font-mono group">
<code className="flex-1 text-sm text-muted-foreground break-all line-clamp-1">
{url}
</code>
<button
onClick={() => copy(url)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy clone URL"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</li>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-3">
{/* Repository Info */}
<div className="flex flex-col gap-2">
{/* Name and Owner */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<GitBranch className="size-4 text-muted-foreground flex-shrink-0" />
<span className="text-lg font-semibold text-foreground">
{displayName}
</span>
</div>
</div>
{/* Description */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-3">
{description}
</p>
)}
{/* URLs and Maintainers */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{/* First Web URL */}
{webUrls.length > 0 && (
<a
href={webUrls[0]}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-muted-foreground underline decoration-dotted cursor-crosshair line-clamp-1"
onClick={(e) => e.stopPropagation()}
>
<Globe className="size-3" />
<span className="truncate line-clamp-1">{webUrls[0]}</span>
</a>
)}
</div>
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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<number, React.ComponentType<BaseEventProps>> = {
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)
};

View File

@@ -489,29 +489,29 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
},
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<number | string, EventKind> = {
},
30617: {
kind: 30617,
name: "Repo Announce",
name: "Repository",
description: "Repository announcements",
nip: "34",
icon: GitBranch,

124
src/lib/nip34-helpers.ts Normal file
View File

@@ -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");
}