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

80
TODO.md
View File

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

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