)}
{/* Summary */}
diff --git a/src/components/nostr/kinds/Kind39701Renderer.tsx b/src/components/nostr/kinds/Kind39701Renderer.tsx
index e6a608f..7f85f55 100644
--- a/src/components/nostr/kinds/Kind39701Renderer.tsx
+++ b/src/components/nostr/kinds/Kind39701Renderer.tsx
@@ -1,4 +1,8 @@
-import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
+import {
+ BaseEventContainer,
+ BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
import { RichText } from "../RichText";
import { ExternalLink } from "lucide-react";
@@ -22,7 +26,13 @@ export function Kind39701Renderer({ event }: BaseEventProps) {
{/* Title */}
{title && (
-
{title}
+
+ {title}
+
)}
{/* URL with external link icon */}
diff --git a/src/components/nostr/kinds/PatchDetailRenderer.tsx b/src/components/nostr/kinds/PatchDetailRenderer.tsx
new file mode 100644
index 0000000..b45b8cc
--- /dev/null
+++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx
@@ -0,0 +1,225 @@
+import { useMemo } from "react";
+import { GitCommit, FolderGit2, User, Copy, CopyCheck } from "lucide-react";
+import { UserName } from "../UserName";
+import { useCopy } from "@/hooks/useCopy";
+import { useGrimoire } from "@/core/state";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import type { NostrEvent } from "@/types/nostr";
+import {
+ getPatchSubject,
+ getPatchCommitId,
+ getPatchParentCommit,
+ getPatchCommitter,
+ getPatchRepositoryAddress,
+ isPatchRoot,
+ isPatchRootRevision,
+} from "@/lib/nip34-helpers";
+import {
+ getRepositoryName,
+ getRepositoryIdentifier,
+} from "@/lib/nip34-helpers";
+
+/**
+ * Detail renderer for Kind 1617 - Patch
+ * Displays full patch metadata and content
+ */
+export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
+ const { addWindow } = useGrimoire();
+ const { copy, copied } = useCopy();
+
+ const subject = useMemo(() => getPatchSubject(event), [event]);
+ const commitId = useMemo(() => getPatchCommitId(event), [event]);
+ const parentCommit = useMemo(() => getPatchParentCommit(event), [event]);
+ const committer = useMemo(() => getPatchCommitter(event), [event]);
+ const repoAddress = useMemo(() => getPatchRepositoryAddress(event), [event]);
+ const isRoot = useMemo(() => isPatchRoot(event), [event]);
+ const isRootRevision = useMemo(() => isPatchRootRevision(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";
+
+ const handleRepoClick = () => {
+ if (!repoPointer || !repoEvent) return;
+ addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
+ };
+
+ // Format created date
+ const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
+ "en-US",
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ },
+ );
+
+ return (
+
+ {/* Patch Header */}
+
+ {/* Title */}
+ {subject || "Untitled Patch"}
+
+ {/* Status Badges */}
+ {(isRoot || isRootRevision) && (
+
+ {isRoot && (
+
+ Root Patch
+
+ )}
+ {isRootRevision && (
+
+ Root Revision
+
+ )}
+
+ )}
+
+ {/* Repository Link */}
+ {repoAddress && (
+
+ Repository:
+
+
+ )}
+
+ {/* Metadata */}
+
+
+ By
+
+
+
•
+
+
+
+
+ {/* Commit Information */}
+ {(commitId || parentCommit || committer) && (
+
+
+
+ Commit Information
+
+
+ {/* Commit ID */}
+ {commitId && (
+
+ Commit:
+
+ {commitId}
+
+
+
+ )}
+
+ {/* Parent Commit */}
+ {parentCommit && (
+
+ Parent:
+
+ {parentCommit}
+
+
+
+ )}
+
+ {/* Committer Info */}
+ {committer && (
+
+
+
+
Committer:
+
+ {committer.name}
+ {committer.email && (
+
+ <{committer.email}>
+
+ )}
+
+
+
+ )}
+
+ )}
+
+ {/* Patch Content */}
+ {event.content && (
+
+ Patch
+
+
+ {event.content}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/PatchRenderer.tsx b/src/components/nostr/kinds/PatchRenderer.tsx
new file mode 100644
index 0000000..df47837
--- /dev/null
+++ b/src/components/nostr/kinds/PatchRenderer.tsx
@@ -0,0 +1,111 @@
+import {
+ BaseEventContainer,
+ type BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { FolderGit2 } from "lucide-react";
+import { useGrimoire } from "@/core/state";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import {
+ getPatchSubject,
+ getPatchCommitId,
+ getPatchRepositoryAddress,
+} from "@/lib/nip34-helpers";
+import {
+ getRepositoryName,
+ getRepositoryIdentifier,
+} from "@/lib/nip34-helpers";
+
+/**
+ * Renderer for Kind 1617 - Patch
+ * Displays as a compact patch card in feed view
+ */
+export function PatchRenderer({ event }: BaseEventProps) {
+ const { addWindow } = useGrimoire();
+ const subject = getPatchSubject(event);
+ const commitId = getPatchCommitId(event);
+ const repoAddress = getPatchRepositoryAddress(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 = () => {
+ if (!repoPointer) return;
+ addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
+ };
+
+ // Shorten commit ID for display
+ const shortCommitId = commitId ? commitId.slice(0, 7) : undefined;
+
+ return (
+
+
+ {/* Patch Subject */}
+
+ {subject || "Untitled Patch"}
+
+
+ {/* Metadata */}
+
+
in
+ {/* Repository */}
+ {repoAddress && repoPointer && (
+
+
+ {repoName}
+
+ )}
+
+ {/* Commit ID */}
+ {shortCommitId && (
+ <>
+
•
+
+ {shortCommitId}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx
new file mode 100644
index 0000000..9246003
--- /dev/null
+++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx
@@ -0,0 +1,437 @@
+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 { GitBranch, FolderGit2, Tag, Copy, CopyCheck } from "lucide-react";
+import { UserName } from "../UserName";
+import { EmbeddedEvent } from "../EmbeddedEvent";
+import { MediaEmbed } from "../MediaEmbed";
+import { useCopy } from "@/hooks/useCopy";
+import { useGrimoire } from "@/core/state";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import type { NostrEvent } from "@/types/nostr";
+import {
+ getPullRequestSubject,
+ getPullRequestLabels,
+ getPullRequestCommitId,
+ getPullRequestBranchName,
+ getPullRequestCloneUrls,
+ getPullRequestMergeBase,
+ getPullRequestRepositoryAddress,
+} 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 {
+ const cleanHref = href.replace(/^nostr:/, "").trim();
+
+ 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) {
+ console.error("Failed to parse nostr link:", href, error);
+ return (
+
+ {href}
+
+ );
+ }
+}
+
+/**
+ * Detail renderer for Kind 1618 - Pull Request
+ * Displays full PR content with markdown rendering
+ */
+export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
+ const { addWindow } = useGrimoire();
+ const { copy, copied } = useCopy();
+
+ const subject = useMemo(() => getPullRequestSubject(event), [event]);
+ const labels = useMemo(() => getPullRequestLabels(event), [event]);
+ const commitId = useMemo(() => getPullRequestCommitId(event), [event]);
+ const branchName = useMemo(() => getPullRequestBranchName(event), [event]);
+ const cloneUrls = useMemo(() => getPullRequestCloneUrls(event), [event]);
+ const mergeBase = useMemo(() => getPullRequestMergeBase(event), [event]);
+ const repoAddress = useMemo(
+ () => getPullRequestRepositoryAddress(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 (
+
+ {/* PR Header */}
+
+ {/* Title */}
+
+ {subject || "Untitled Pull Request"}
+
+
+ {/* Repository Link */}
+ {repoAddress && (
+
+ Repository:
+
+
+ )}
+
+ {/* Metadata */}
+
+
+ By
+
+
+
•
+
+
+
+ {/* Labels */}
+ {labels.length > 0 && (
+
+
+ {labels.map((label, idx) => (
+
+ {label}
+
+ ))}
+
+ )}
+
+
+ {/* Branch and Commit Info */}
+ {(branchName || commitId || mergeBase) && (
+
+
+
+ Branch Information
+
+
+ {/* Branch Name */}
+ {branchName && (
+
+ Branch:
+
+ {branchName}
+
+
+
+ )}
+
+ {/* Commit ID */}
+ {commitId && (
+
+ Commit:
+
+ {commitId}
+
+
+
+ )}
+
+ {/* Merge Base */}
+ {mergeBase && (
+
+ Merge Base:
+
+ {mergeBase}
+
+
+
+ )}
+
+ {/* Clone URLs */}
+ {cloneUrls.length > 0 && (
+
+
+ Clone URLs
+
+
+ {cloneUrls.map((url, idx) => (
+ -
+
+ {url}
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* PR Description - Markdown */}
+ {event.content ? (
+ <>
+
+ {
+ if (url.startsWith("nostr:")) return url;
+ return defaultUrlTransform(url);
+ }}
+ components={{
+ img: ({ src, alt }) =>
+ src ? (
+
+ ) : null,
+ a: ({ href, children, ...props }) => {
+ if (!href) return null;
+
+ if (href.startsWith("nostr:")) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ h1: ({ ...props }) => (
+
+ ),
+ h2: ({ ...props }) => (
+
+ ),
+ h3: ({ ...props }) => (
+
+ ),
+ p: ({ ...props }) => (
+
+ ),
+ code: ({ ...props }: any) => (
+
+ ),
+ blockquote: ({ ...props }) => (
+
+ ),
+ ul: ({ ...props }) => (
+
+ ),
+ ol: ({ ...props }) => (
+
+ ),
+ hr: () =>
,
+ }}
+ >
+ {event.content.replace(/\\n/g, '\n')}
+
+
+ >
+ ) : (
+
+ (No description provided)
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx
new file mode 100644
index 0000000..91ecb04
--- /dev/null
+++ b/src/components/nostr/kinds/PullRequestRenderer.tsx
@@ -0,0 +1,126 @@
+import {
+ BaseEventContainer,
+ type BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { FolderGit2 } from "lucide-react";
+import { useGrimoire } from "@/core/state";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import {
+ getPullRequestSubject,
+ getPullRequestLabels,
+ getPullRequestBranchName,
+ getPullRequestRepositoryAddress,
+} from "@/lib/nip34-helpers";
+import {
+ getRepositoryName,
+ getRepositoryIdentifier,
+} from "@/lib/nip34-helpers";
+
+/**
+ * Renderer for Kind 1618 - Pull Request
+ * Displays as a compact PR card in feed view
+ */
+export function PullRequestRenderer({ event }: BaseEventProps) {
+ const { addWindow } = useGrimoire();
+ const subject = getPullRequestSubject(event);
+ const labels = getPullRequestLabels(event);
+ const branchName = getPullRequestBranchName(event);
+ const repoAddress = getPullRequestRepositoryAddress(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 = () => {
+ if (!repoPointer) return;
+ addWindow("open", { pointer: repoPointer }, `Repository: ${repoName}`);
+ };
+
+ return (
+
+
+ {/* PR Title */}
+
+
+ {subject || "Untitled Pull Request"}
+
+
+
+ {/* Metadata */}
+
+
in
+ {/* Repository */}
+ {repoAddress && repoPointer && (
+
+
+ {repoName}
+
+ )}
+
+ {/* Branch Name */}
+ {branchName && (
+ <>
+
•
+
+ {branchName}
+
+ >
+ )}
+
+
+ {/* Labels */}
+ {labels.length > 0 && (
+
+ {labels.map((label, idx) => (
+
+ {label}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/Kind30617DetailRenderer.tsx b/src/components/nostr/kinds/RepositoryDetailRenderer.tsx
similarity index 94%
rename from src/components/nostr/kinds/Kind30617DetailRenderer.tsx
rename to src/components/nostr/kinds/RepositoryDetailRenderer.tsx
index cc335b9..49e9c1e 100644
--- a/src/components/nostr/kinds/Kind30617DetailRenderer.tsx
+++ b/src/components/nostr/kinds/RepositoryDetailRenderer.tsx
@@ -18,7 +18,7 @@ import {
* Detail renderer for Kind 30617 - Repository
* Displays full repository metadata with all URLs and maintainers
*/
-export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) {
+export function RepositoryDetailRenderer({ event }: { event: NostrEvent }) {
const name = useMemo(() => getRepositoryName(event), [event]);
const description = useMemo(() => getRepositoryDescription(event), [event]);
const identifier = useMemo(() => getRepositoryIdentifier(event), [event]);
@@ -36,13 +36,6 @@ export function Kind30617DetailRenderer({ event }: { event: NostrEvent }) {
{/* Name */}
{displayName}
- {/* Identifier */}
- {identifier && (
-
- /{identifier}
-
- )}
-
{/* Description */}
{description && (
diff --git a/src/components/nostr/kinds/Kind30617Renderer.tsx b/src/components/nostr/kinds/RepositoryRenderer.tsx
similarity index 82%
rename from src/components/nostr/kinds/Kind30617Renderer.tsx
rename to src/components/nostr/kinds/RepositoryRenderer.tsx
index 4407482..5e5350e 100644
--- a/src/components/nostr/kinds/Kind30617Renderer.tsx
+++ b/src/components/nostr/kinds/RepositoryRenderer.tsx
@@ -1,4 +1,8 @@
-import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
+import {
+ BaseEventContainer,
+ type BaseEventProps,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
import { GitBranch, Globe } from "lucide-react";
import {
getRepositoryName,
@@ -11,7 +15,7 @@ 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) {
+export function RepositoryRenderer({ event }: BaseEventProps) {
const name = getRepositoryName(event);
const description = getRepositoryDescription(event);
const identifier = getReplaceableIdentifier(event);
@@ -29,9 +33,14 @@ export function Kind30617Renderer({ event }: BaseEventProps) {
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 9ac9db9..887eba0 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -8,12 +8,14 @@ import { Kind20Renderer } from "./Kind20Renderer";
import { Kind21Renderer } from "./Kind21Renderer";
import { Kind22Renderer } from "./Kind22Renderer";
import { Kind1063Renderer } from "./Kind1063Renderer";
-import { Kind1621Renderer } from "./Kind1621Renderer";
+import { IssueRenderer } from "./IssueRenderer";
+import { PatchRenderer } from "./PatchRenderer";
+import { PullRequestRenderer } from "./PullRequestRenderer";
import { Kind9735Renderer } from "./Kind9735Renderer";
import { Kind9802Renderer } from "./Kind9802Renderer";
import { Kind10002Renderer } from "./Kind10002Renderer";
import { Kind30023Renderer } from "./Kind30023Renderer";
-import { Kind30617Renderer } from "./Kind30617Renderer";
+import { RepositoryRenderer } from "./RepositoryRenderer";
import { Kind39701Renderer } from "./Kind39701Renderer";
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { NostrEvent } from "@/types/nostr";
@@ -36,7 +38,9 @@ const kindRenderers: Record> = {
22: Kind22Renderer, // Short Video (NIP-71)
1063: Kind1063Renderer, // File Metadata (NIP-94)
1111: Kind1Renderer, // Post
- 1621: Kind1621Renderer, // Issue (NIP-34)
+ 1617: PatchRenderer, // Patch (NIP-34)
+ 1618: PullRequestRenderer, // Pull Request (NIP-34)
+ 1621: IssueRenderer, // Issue (NIP-34)
9735: Kind9735Renderer, // Zap Receipt
9802: Kind9802Renderer, // Highlight
10002: Kind10002Renderer, // Relay List Metadata (NIP-65)
@@ -46,7 +50,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)
+ 30617: RepositoryRenderer, // Repository (NIP-34)
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index 5ced3d0..3a20dcd 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -6,18 +6,24 @@ import {
BarChart3,
Bookmark,
Calendar,
+ CheckCircle2,
+ CircleDot,
Cloud,
Coins,
Compass,
Eye,
EyeOff,
FileCode,
+ FileDiff,
FileEdit,
FileText,
Filter,
Flag,
+ FolderGit2,
Gavel,
GitBranch,
+ GitMerge,
+ GitPullRequest,
Grid3x3,
Hash,
Heart,
@@ -55,6 +61,7 @@ import {
UserX,
Video,
Wallet,
+ XCircle,
Zap,
type LucideIcon,
} from "lucide-react";
@@ -335,7 +342,7 @@ export const EVENT_KINDS: Record = {
name: "Merge Request",
description: "Merge Requests",
nip: "54",
- icon: GitBranch,
+ icon: GitMerge,
},
// Marketplace
@@ -457,14 +464,14 @@ export const EVENT_KINDS: Record = {
name: "Patch",
description: "Git Patches",
nip: "34",
- icon: GitBranch,
+ icon: FileDiff,
},
1618: {
kind: 1618,
name: "Pull Request",
description: "Git Pull Requests",
nip: "34",
- icon: GitBranch,
+ icon: GitPullRequest,
},
1619: {
kind: 1619,
@@ -492,28 +499,28 @@ export const EVENT_KINDS: Record = {
name: "Open Status",
description: "Open",
nip: "34",
- icon: Activity,
+ icon: CircleDot,
},
1631: {
kind: 1631,
name: "Applied/Merged",
description: "Applied / Merged for Patches; Resolved for Issues",
nip: "34",
- icon: Activity,
+ icon: CheckCircle2,
},
1632: {
kind: 1632,
name: "Closed Status",
description: "Closed",
nip: "34",
- icon: Activity,
+ icon: XCircle,
},
1633: {
kind: 1633,
name: "Draft Status",
description: "Draft",
nip: "34",
- icon: Activity,
+ icon: FileEdit,
},
// Problem tracking - External spec (nostrocket), commented out
@@ -1219,14 +1226,14 @@ export const EVENT_KINDS: Record = {
name: "Repository",
description: "Repository announcements",
nip: "34",
- icon: GitBranch,
+ icon: FolderGit2,
},
30618: {
kind: 30618,
name: "Repo State",
description: "Repository state announcements",
nip: "34",
- icon: GitBranch,
+ icon: FolderGit2,
},
30818: {
kind: 30818,
diff --git a/src/lib/event-title.ts b/src/lib/event-title.ts
new file mode 100644
index 0000000..3af6e20
--- /dev/null
+++ b/src/lib/event-title.ts
@@ -0,0 +1,85 @@
+import { NostrEvent } from "@/types/nostr";
+import { getTagValue } from "applesauce-core/helpers";
+import { kinds } from "nostr-tools";
+import { getArticleTitle } from "applesauce-core/helpers/article";
+import {
+ getRepositoryName,
+ getIssueTitle,
+ getPatchSubject,
+ getPullRequestSubject,
+} from "@/lib/nip34-helpers";
+import { getKindInfo } from "@/constants/kinds";
+
+/**
+ * Get a human-readable display title for any event
+ *
+ * Priority order:
+ * 1. Kind-specific helper functions (most accurate)
+ * 2. Generic 'subject' or 'title' tags
+ * 3. Event kind name as fallback
+ *
+ * @param event - The Nostr event
+ * @returns Human-readable title string
+ */
+export function getEventDisplayTitle(
+ event: NostrEvent,
+ showKind = true,
+): string {
+ // Try kind-specific helpers first (most accurate)
+ let title: string | undefined;
+
+ switch (event.kind) {
+ case kinds.LongFormArticle: // Long-form article
+ title = getArticleTitle(event);
+ break;
+ case 30617: // Repository
+ title = getRepositoryName(event);
+ break;
+ case 1621: // Issue
+ title = getIssueTitle(event);
+ break;
+ case 1617: // Patch
+ title = getPatchSubject(event);
+ break;
+ case 1618: // Pull request
+ title = getPullRequestSubject(event);
+ break;
+ }
+
+ if (title) return title;
+
+ // Try generic tag extraction
+ title =
+ getTagValue(event, "subject") ||
+ getTagValue(event, "title") ||
+ getTagValue(event, "name");
+ if (title) return title;
+
+ // Fall back to kind name
+ const kindInfo = getKindInfo(event.kind);
+ if (showKind && kindInfo) {
+ return kindInfo.name;
+ }
+
+ // Ultimate fallback
+ if (showKind) {
+ return `Kind ${event.kind}`;
+ }
+
+ return event.content;
+}
+
+/**
+ * Get a window title for an event with optional prefix
+ *
+ * @param event - The Nostr event
+ * @param prefix - Optional prefix for the title (e.g., "Repository:", "Article:")
+ * @returns Formatted window title
+ */
+export function getEventWindowTitle(
+ event: NostrEvent,
+ prefix?: string,
+): string {
+ const title = getEventDisplayTitle(event);
+ return prefix ? `${prefix} ${title}` : title;
+}
diff --git a/src/lib/nip34-helpers.test.ts b/src/lib/nip34-helpers.test.ts
new file mode 100644
index 0000000..a6fca8d
--- /dev/null
+++ b/src/lib/nip34-helpers.test.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect } from "vitest";
+
+/**
+ * Test helper to parse unified diff content
+ * This is a copy of the parseUnifiedDiff function from PatchDetailRenderer
+ * for testing purposes
+ */
+function parseUnifiedDiff(content: string): {
+ hunks: string[];
+ oldFile?: { fileName?: string };
+ newFile?: { fileName?: string };
+} | null {
+ const lines = content.split("\n");
+ const hunks: string[] = [];
+ let oldFileName: string | undefined;
+ let newFileName: string | undefined;
+ let currentHunk: string[] = [];
+ let inHunk = false;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Extract file names from --- and +++ lines
+ if (line.startsWith("---")) {
+ const match = line.match(/^---\s+(?:a\/)?(.+?)(?:\s|$)/);
+ if (match) oldFileName = match[1];
+ } else if (line.startsWith("+++")) {
+ const match = line.match(/^\+\+\+\s+(?:b\/)?(.+?)(?:\s|$)/);
+ if (match) newFileName = match[1];
+ }
+ // Start of a new hunk
+ else if (line.startsWith("@@")) {
+ // Save previous hunk if exists
+ if (currentHunk.length > 0) {
+ hunks.push(currentHunk.join("\n"));
+ }
+ currentHunk = [line];
+ inHunk = true;
+ }
+ // Content lines within a hunk (start with +, -, or space)
+ else if (
+ inHunk &&
+ (line.startsWith("+") ||
+ line.startsWith("-") ||
+ line.startsWith(" ") ||
+ line === "")
+ ) {
+ currentHunk.push(line);
+ }
+ // End of current hunk
+ else if (inHunk && !line.startsWith("@@")) {
+ if (currentHunk.length > 0) {
+ hunks.push(currentHunk.join("\n"));
+ currentHunk = [];
+ }
+ inHunk = false;
+ }
+ }
+
+ // Don't forget the last hunk
+ if (currentHunk.length > 0) {
+ hunks.push(currentHunk.join("\n"));
+ }
+
+ if (hunks.length === 0) {
+ return null;
+ }
+
+ return {
+ hunks,
+ oldFile: oldFileName ? { fileName: oldFileName } : undefined,
+ newFile: newFileName ? { fileName: newFileName } : undefined,
+ };
+}
+
+describe("parseUnifiedDiff", () => {
+ it("should parse a simple unified diff", () => {
+ const diff = `diff --git a/file.ts b/file.ts
+index abc123..def456 100644
+--- a/file.ts
++++ b/file.ts
+@@ -1,5 +1,6 @@
+ context line
+-deleted line
++added line
+ context line`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result).not.toBeNull();
+ expect(result?.hunks).toHaveLength(1);
+ expect(result?.oldFile?.fileName).toBe("file.ts");
+ expect(result?.newFile?.fileName).toBe("file.ts");
+ expect(result?.hunks[0]).toContain("@@ -1,5 +1,6 @@");
+ expect(result?.hunks[0]).toContain("-deleted line");
+ expect(result?.hunks[0]).toContain("+added line");
+ });
+
+ it("should parse multiple hunks", () => {
+ const diff = `--- a/file.ts
++++ b/file.ts
+@@ -1,3 +1,3 @@
+ line 1
+-old line 2
++new line 2
+ line 3
+@@ -10,3 +10,4 @@
+ line 10
++new line 11
+ line 12`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result).not.toBeNull();
+ expect(result?.hunks).toHaveLength(2);
+ expect(result?.hunks[0]).toContain("@@ -1,3 +1,3 @@");
+ expect(result?.hunks[1]).toContain("@@ -10,3 +10,4 @@");
+ });
+
+ it("should extract file names with a/ and b/ prefixes", () => {
+ const diff = `--- a/src/components/Button.tsx
++++ b/src/components/Button.tsx
+@@ -1,1 +1,1 @@
+-old
++new`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result?.oldFile?.fileName).toBe("src/components/Button.tsx");
+ expect(result?.newFile?.fileName).toBe("src/components/Button.tsx");
+ });
+
+ it("should extract file names without a/ and b/ prefixes", () => {
+ const diff = `--- file.ts
++++ file.ts
+@@ -1,1 +1,1 @@
+-old
++new`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result?.oldFile?.fileName).toBe("file.ts");
+ expect(result?.newFile?.fileName).toBe("file.ts");
+ });
+
+ it("should return null for content with no hunks", () => {
+ const diff = `This is not a valid diff
+Just some random text`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result).toBeNull();
+ });
+
+ it("should handle empty lines within hunks", () => {
+ const diff = `--- a/file.ts
++++ b/file.ts
+@@ -1,5 +1,5 @@
+ line 1
+
+-old line 3
++new line 3
+ line 4`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result).not.toBeNull();
+ expect(result?.hunks).toHaveLength(1);
+ expect(result?.hunks[0]).toContain("");
+ });
+
+ it("should handle context lines (starting with space)", () => {
+ const diff = `--- a/file.ts
++++ b/file.ts
+@@ -1,3 +1,3 @@
+ context line 1
+-deleted
++added
+ context line 3`;
+
+ const result = parseUnifiedDiff(diff);
+ expect(result).not.toBeNull();
+ expect(result?.hunks[0]).toContain(" context line 1");
+ expect(result?.hunks[0]).toContain(" context line 3");
+ });
+});
diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts
index 851d868..8ac4885 100644
--- a/src/lib/nip34-helpers.ts
+++ b/src/lib/nip34-helpers.ts
@@ -122,3 +122,160 @@ export function getIssueRepositoryAddress(
export function getIssueRepositoryOwner(event: NostrEvent): string | undefined {
return getTagValue(event, "p");
}
+
+// ============================================================================
+// Patch Event Helpers (Kind 1617)
+// ============================================================================
+
+/**
+ * Get the patch subject from content or subject tag
+ * @param event Patch event (kind 1617)
+ * @returns Patch subject/title or undefined
+ */
+export function getPatchSubject(event: NostrEvent): string | undefined {
+ // Try subject tag first
+ const subjectTag = getTagValue(event, "subject");
+ if (subjectTag) return subjectTag;
+
+ // Try to extract from content (first line or "Subject:" header from git format-patch)
+ const content = event.content.trim();
+ const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
+ if (subjectMatch) return subjectMatch[1].trim();
+
+ // Fallback to first line
+ const firstLine = content.split("\n")[0];
+ return firstLine?.length > 0 ? firstLine : undefined;
+}
+
+/**
+ * Get the commit ID from a patch event
+ * @param event Patch event (kind 1617)
+ * @returns Commit ID or undefined
+ */
+export function getPatchCommitId(event: NostrEvent): string | undefined {
+ return getTagValue(event, "commit");
+}
+
+/**
+ * Get the parent commit ID from a patch event
+ * @param event Patch event (kind 1617)
+ * @returns Parent commit ID or undefined
+ */
+export function getPatchParentCommit(event: NostrEvent): string | undefined {
+ return getTagValue(event, "parent-commit");
+}
+
+/**
+ * Get committer info from a patch event
+ * @param event Patch event (kind 1617)
+ * @returns Committer object with name, email, timestamp, timezone or undefined
+ */
+export function getPatchCommitter(
+ event: NostrEvent,
+):
+ | { name: string; email: string; timestamp: string; timezone: string }
+ | undefined {
+ const committerTag = event.tags.find((t) => t[0] === "committer");
+ if (!committerTag || committerTag.length < 5) return undefined;
+
+ const [, name, email, timestamp, timezone] = committerTag;
+ return { name, email, timestamp, timezone };
+}
+
+/**
+ * Get the repository address for a patch
+ * @param event Patch event (kind 1617)
+ * @returns Repository address pointer (a tag) or undefined
+ */
+export function getPatchRepositoryAddress(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "a");
+}
+
+/**
+ * Check if patch is root/first in series
+ * @param event Patch event (kind 1617)
+ * @returns True if this is a root patch
+ */
+export function isPatchRoot(event: NostrEvent): boolean {
+ return event.tags.some((t) => t[0] === "t" && t[1] === "root");
+}
+
+/**
+ * Check if patch is first in a revision series
+ * @param event Patch event (kind 1617)
+ * @returns True if this is a root revision
+ */
+export function isPatchRootRevision(event: NostrEvent): boolean {
+ return event.tags.some((t) => t[0] === "t" && t[1] === "root-revision");
+}
+
+// ============================================================================
+// Pull Request Event Helpers (Kind 1618)
+// ============================================================================
+
+/**
+ * Get the PR subject/title
+ * @param event PR event (kind 1618)
+ * @returns PR subject or undefined
+ */
+export function getPullRequestSubject(event: NostrEvent): string | undefined {
+ return getTagValue(event, "subject");
+}
+
+/**
+ * Get PR labels
+ * @param event PR event (kind 1618)
+ * @returns Array of label strings
+ */
+export function getPullRequestLabels(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
+}
+
+/**
+ * Get the current commit ID (tip of PR branch)
+ * @param event PR event (kind 1618)
+ * @returns Commit ID or undefined
+ */
+export function getPullRequestCommitId(event: NostrEvent): string | undefined {
+ return getTagValue(event, "c");
+}
+
+/**
+ * Get all clone URLs for a PR
+ * @param event PR event (kind 1618)
+ * @returns Array of clone URLs
+ */
+export function getPullRequestCloneUrls(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
+}
+
+/**
+ * Get the branch name for a PR
+ * @param event PR event (kind 1618)
+ * @returns Branch name or undefined
+ */
+export function getPullRequestBranchName(event: NostrEvent): string | undefined {
+ return getTagValue(event, "branch-name");
+}
+
+/**
+ * Get the merge base commit ID
+ * @param event PR event (kind 1618)
+ * @returns Merge base commit ID or undefined
+ */
+export function getPullRequestMergeBase(event: NostrEvent): string | undefined {
+ return getTagValue(event, "merge-base");
+}
+
+/**
+ * Get the repository address for a PR
+ * @param event PR event (kind 1618)
+ * @returns Repository address pointer (a tag) or undefined
+ */
+export function getPullRequestRepositoryAddress(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "a");
+}