From dab250260f99250b966cd18ce10ed620dfbb4372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Mon, 15 Dec 2025 11:27:13 +0100 Subject: [PATCH] fix: PR body rendering --- src/components/DynamicWindowTitle.tsx | 46 +- src/components/EventDetailViewer.tsx | 14 +- src/components/ProfileViewer.tsx | 2 +- src/components/WindowTitle.tsx | 6 +- .../nostr/LinkPreview/PlainLink.tsx | 17 +- src/components/nostr/RichText.tsx | 46 +- src/components/nostr/RichText/Link.tsx | 6 +- src/components/nostr/RichText/Text.tsx | 50 +- .../nostr/kinds/BaseEventRenderer.tsx | 72 ++- ...ilRenderer.tsx => IssueDetailRenderer.tsx} | 8 +- ...Kind1621Renderer.tsx => IssueRenderer.tsx} | 20 +- .../nostr/kinds/Kind30023DetailRenderer.tsx | 4 +- .../nostr/kinds/Kind30023Renderer.tsx | 16 +- .../nostr/kinds/Kind39701Renderer.tsx | 14 +- .../nostr/kinds/PatchDetailRenderer.tsx | 225 +++++++++ src/components/nostr/kinds/PatchRenderer.tsx | 111 +++++ .../nostr/kinds/PullRequestDetailRenderer.tsx | 437 ++++++++++++++++++ .../nostr/kinds/PullRequestRenderer.tsx | 126 +++++ ...derer.tsx => RepositoryDetailRenderer.tsx} | 9 +- ...617Renderer.tsx => RepositoryRenderer.tsx} | 17 +- src/components/nostr/kinds/index.tsx | 12 +- src/constants/kinds.ts | 25 +- src/lib/event-title.ts | 85 ++++ src/lib/nip34-helpers.test.ts | 180 ++++++++ src/lib/nip34-helpers.ts | 157 +++++++ 25 files changed, 1565 insertions(+), 140 deletions(-) rename src/components/nostr/kinds/{Kind1621DetailRenderer.tsx => IssueDetailRenderer.tsx} (97%) rename src/components/nostr/kinds/{Kind1621Renderer.tsx => IssueRenderer.tsx} (86%) create mode 100644 src/components/nostr/kinds/PatchDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/PatchRenderer.tsx create mode 100644 src/components/nostr/kinds/PullRequestDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/PullRequestRenderer.tsx rename src/components/nostr/kinds/{Kind30617DetailRenderer.tsx => RepositoryDetailRenderer.tsx} (94%) rename src/components/nostr/kinds/{Kind30617Renderer.tsx => RepositoryRenderer.tsx} (82%) create mode 100644 src/lib/event-title.ts create mode 100644 src/lib/nip34-helpers.test.ts diff --git a/src/components/DynamicWindowTitle.tsx b/src/components/DynamicWindowTitle.tsx index 7f9a339..be8dba1 100644 --- a/src/components/DynamicWindowTitle.tsx +++ b/src/components/DynamicWindowTitle.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { ReactElement, useMemo } from "react"; import { WindowInstance } from "@/types/app"; import { useProfile } from "@/hooks/useProfile"; import { useNostrEvent } from "@/hooks/useNostrEvent"; @@ -11,7 +11,7 @@ import { } from "@/constants/command-icons"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; import type { LucideIcon } from "lucide-react"; -import { kinds, nip19 } from "nostr-tools"; +import { nip19 } from "nostr-tools"; import { ProfileContent } from "applesauce-core/helpers"; import { formatEventIds, @@ -19,9 +19,11 @@ import { formatTimeRangeCompact, formatGenericTag, } from "@/lib/filter-formatters"; +import { getEventDisplayTitle } from "@/lib/event-title"; +import { UserName } from "./nostr/UserName"; export interface WindowTitleData { - title: string; + title: string | ReactElement; icon?: LucideIcon; tooltip?: string; } @@ -205,36 +207,24 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { return `Profile ${profilePubkey.slice(0, 8)}...`; }, [appId, profilePubkey, profile]); - // Event titles + // Event titles - use unified title extraction const eventPointer: EventPointer | AddressPointer | undefined = appId === "open" ? props.pointer : undefined; const event = useNostrEvent(eventPointer); const eventTitle = useMemo(() => { if (appId !== "open" || !event) return null; - const kindName = getKindName(event.kind); - - // For text-based events, show a preview - if (event.kind === kinds.ShortTextNote && event.content) { - const preview = event.content.slice(0, 40).trim(); - return preview ? `${kindName}: ${preview}...` : kindName; - } - - // For articles (kind 30023), show title tag - if (event.kind === kinds.LongFormArticle) { - const titleTag = event.tags.find((t) => t[0] === "title")?.[1]; - if (titleTag) { - return titleTag.length > 50 ? `${titleTag.slice(0, 50)}...` : titleTag; - } - } - - // For highlights (kind 9802), show preview - if (event.kind === kinds.Highlights && event.content) { - const preview = event.content.slice(0, 40).trim(); - return preview ? `Highlight: ${preview}...` : "Highlight"; - } - - return kindName; + return ( +
+
+ {getKindName(event.kind)} + : +
+ {getEventDisplayTitle(event, false)} + - + +
+ ); }, [appId, event]); // Kind titles @@ -429,7 +419,7 @@ function useDynamicTitle(window: WindowInstance): WindowTitleData { // Generate final title data with icon and tooltip return useMemo(() => { - let title: string; + let title: ReactElement | string; let icon: LucideIcon | undefined; let tooltip: string | undefined; diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 171114a..b3ec3ee 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -4,11 +4,13 @@ import { useNostrEvent } from "@/hooks/useNostrEvent"; import { KindRenderer } from "./nostr/kinds"; import { Kind0DetailRenderer } from "./nostr/kinds/Kind0DetailRenderer"; import { Kind3DetailView } from "./nostr/kinds/Kind3Renderer"; -import { Kind1621DetailRenderer } from "./nostr/kinds/Kind1621DetailRenderer"; +import { IssueDetailRenderer } from "./nostr/kinds/IssueDetailRenderer"; +import { PatchDetailRenderer } from "./nostr/kinds/PatchDetailRenderer"; +import { PullRequestDetailRenderer } from "./nostr/kinds/PullRequestDetailRenderer"; 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 { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer"; import { JsonViewer } from "./JsonViewer"; import { RelayLink } from "./nostr/RelayLink"; import { @@ -267,8 +269,12 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { ) : event.kind === kinds.Contacts ? ( + ) : event.kind === 1617 ? ( + + ) : event.kind === 1618 ? ( + ) : event.kind === 1621 ? ( - + ) : event.kind === kinds.Highlights ? ( ) : event.kind === kinds.RelayList ? ( @@ -276,7 +282,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { ) : event.kind === kinds.LongFormArticle ? ( ) : event.kind === 30617 ? ( - + ) : ( )} diff --git a/src/components/ProfileViewer.tsx b/src/components/ProfileViewer.tsx index 4d5fb72..119cb7a 100644 --- a/src/components/ProfileViewer.tsx +++ b/src/components/ProfileViewer.tsx @@ -284,7 +284,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) { /> {/* NIP-05 */} {profile.nip05 && ( -
+
)} diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx index 246a6d6..c5c8a8f 100644 --- a/src/components/WindowTitle.tsx +++ b/src/components/WindowTitle.tsx @@ -23,6 +23,10 @@ export function WindowTile({ const { title, icon, tooltip } = useDynamicWindowTitle(window); const Icon = icon; + // Convert title to string for MosaicWindow (which only accepts strings) + // The actual title (with React elements) is rendered in the custom toolbar + const titleString = typeof title === "string" ? title : tooltip || window.title; + // Custom toolbar renderer to include icon const renderToolbar = () => { return ( @@ -47,7 +51,7 @@ export function WindowTile({ }; return ( - + onClose(id)} /> diff --git a/src/components/nostr/LinkPreview/PlainLink.tsx b/src/components/nostr/LinkPreview/PlainLink.tsx index 401ba7d..b84613c 100644 --- a/src/components/nostr/LinkPreview/PlainLink.tsx +++ b/src/components/nostr/LinkPreview/PlainLink.tsx @@ -1,21 +1,32 @@ +import { cn } from "@/lib/utils"; + interface PlainLinkProps { url: string; + className?: string; } -export function PlainLink({ url }: PlainLinkProps) { +export function PlainLink({ url, className }: PlainLinkProps) { const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); }; + // Format URL for display: remove scheme and trailing slashes + const displayUrl = url + .replace(/^https?:\/\//, "") // Remove http:// or https:// + .replace(/\/$/, ""); // Remove trailing slash + return ( - {url} + {displayUrl} ); } diff --git a/src/components/nostr/RichText.tsx b/src/components/nostr/RichText.tsx index 8f0a1c1..5db48a4 100644 --- a/src/components/nostr/RichText.tsx +++ b/src/components/nostr/RichText.tsx @@ -94,36 +94,24 @@ export function RichText({ } : undefined; const renderedContent = useRenderedContent( - trimmedEvent as NostrEvent, + content + ? ({ + content, + } as NostrEvent) + : trimmedEvent, contentComponents, ); - // If plain content is provided, just render it - if (content && !event) { - const lines = content.trim().split("\n"); - return ( -
- {lines.map((line, idx) => ( -
- {line || "\u00A0"} -
- ))} -
- ); - } - - // Render event content with rich formatting - if (event) { - return ( - - -
- {renderedContent} -
-
-
- ); - } - - return null; + return ( + + +
+ {renderedContent} +
+
+
+ ); } diff --git a/src/components/nostr/RichText/Link.tsx b/src/components/nostr/RichText/Link.tsx index d1ffb0c..95302d1 100644 --- a/src/components/nostr/RichText/Link.tsx +++ b/src/components/nostr/RichText/Link.tsx @@ -36,7 +36,7 @@ export function Link({ node }: LinkNodeProps) { type="image" preset="inline" enableZoom - className="inline-block" + className="my-2 inline-block" /> ); } @@ -50,7 +50,7 @@ export function Link({ node }: LinkNodeProps) { url={href} type="video" preset="inline" - className="inline-block" + className="my-2 inline-block" /> ); } @@ -65,7 +65,7 @@ export function Link({ node }: LinkNodeProps) { url={href} type="audio" onAudioClick={handleAudioClick} - className="inline-block" + className="my-2 inline-block" /> {text || "\u00A0"}; + const lines = useMemo(() => text.split("\n"), [text]); + if (text.includes("\n")) { + return ( + <> + {lines.map((line, idx) => + line.trim().length === 0 ? ( +
+ ) : idx === 0 || idx === lines.length - 1 ? ( + {line} // FIXME: this should be span or div depnding on context + ) : ( +
+ {line} +
+ ), + )} + + ); } - - // If has newlines, use regular inline spans with
tags - const lines = text.split("\n"); - return ( - <> - {lines.map((line, idx) => { - const isRTL = hasRTLCharacters(line); - return ( - <> - {idx > 0 &&
} - - {line || "\u00A0"} - - - ); - })} - - ); + return {text}; } diff --git a/src/components/nostr/kinds/BaseEventRenderer.tsx b/src/components/nostr/kinds/BaseEventRenderer.tsx index 0aeaa5f..5f101d0 100644 --- a/src/components/nostr/kinds/BaseEventRenderer.tsx +++ b/src/components/nostr/kinds/BaseEventRenderer.tsx @@ -18,6 +18,8 @@ import { formatTimestamp } from "@/hooks/useLocale"; import { nip19 } from "nostr-tools"; import { getTagValue } from "applesauce-core/helpers"; import { EventFooter } from "@/components/EventFooter"; +import { cn } from "@/lib/utils"; +import { getEventDisplayTitle } from "@/lib/event-title"; // NIP-01 Kind ranges const REPLACEABLE_START = 10000; @@ -75,7 +77,9 @@ export function EventMenu({ event }: { event: NostrEvent }) { }; } - addWindow("open", { pointer }, `Event ${event.id.slice(0, 8)}...`); + // Use automatic title extraction for better window titles + const title = getEventDisplayTitle(event); + addWindow("open", { pointer }, title); }; const copyEventId = () => { @@ -156,6 +160,72 @@ export function EventMenu({ event }: { event: NostrEvent }) { ); } +/** + * Clickable event title component + * Opens the event in a new window when clicked + * Supports both regular events and addressable/replaceable events + */ +interface ClickableEventTitleProps { + event: NostrEvent; + children: React.ReactNode; + windowTitle?: string; + className?: string; + as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "span" | "div"; +} + +export function ClickableEventTitle({ + event, + children, + windowTitle, + className, + as: Component = "h3", +}: ClickableEventTitleProps) { + const { addWindow } = useGrimoire(); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + + // Determine if event is addressable/replaceable + const isAddressable = + (event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) || + (event.kind >= PARAMETERIZED_REPLACEABLE_START && + event.kind < PARAMETERIZED_REPLACEABLE_END); + + let pointer; + // Use provided windowTitle, or fall back to automatic title extraction + const title = windowTitle || getEventDisplayTitle(event); + + if (isAddressable) { + // For replaceable/parameterized replaceable events, use AddressPointer + const dTag = getTagValue(event, "d") || ""; + pointer = { + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }; + } else { + // For regular events, use EventPointer + pointer = { + id: event.id, + }; + } + + addWindow("open", { pointer }, title); + }; + + return ( + + {children} + + ); +} + /** * Base event container with universal header * Kind-specific renderers can wrap their content with this diff --git a/src/components/nostr/kinds/Kind1621DetailRenderer.tsx b/src/components/nostr/kinds/IssueDetailRenderer.tsx similarity index 97% rename from src/components/nostr/kinds/Kind1621DetailRenderer.tsx rename to src/components/nostr/kinds/IssueDetailRenderer.tsx index 32e41a7..0038bfd 100644 --- a/src/components/nostr/kinds/Kind1621DetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -3,7 +3,7 @@ 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 { Tag, FolderGit2 } from "lucide-react"; import { UserName } from "../UserName"; import { EmbeddedEvent } from "../EmbeddedEvent"; import { MediaEmbed } from "../MediaEmbed"; @@ -127,7 +127,7 @@ function NostrMention({ href }: { href: string }) { * Detail renderer for Kind 1621 - Issue * Displays full issue content with markdown rendering */ -export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) { +export function IssueDetailRenderer({ event }: { event: NostrEvent }) { const { addWindow } = useGrimoire(); const title = useMemo(() => getIssueTitle(event), [event]); const labels = useMemo(() => getIssueLabels(event), [event]); @@ -193,7 +193,7 @@ export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) { : "text-muted-foreground cursor-not-allowed" }`} > - + {repoName}
@@ -309,7 +309,7 @@ export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) { hr: () =>
, }} > - {event.content} + {event.content.replace(/\\n/g, '\n')} ) : ( diff --git a/src/components/nostr/kinds/Kind1621Renderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx similarity index 86% rename from src/components/nostr/kinds/Kind1621Renderer.tsx rename to src/components/nostr/kinds/IssueRenderer.tsx index ede873d..6349689 100644 --- a/src/components/nostr/kinds/Kind1621Renderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -1,5 +1,9 @@ -import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; -import { GitBranch } from "lucide-react"; +import { + BaseEventContainer, + type BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { FolderGit2 } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { @@ -17,7 +21,7 @@ import { UserName } from "../UserName"; * Renderer for Kind 1621 - Issue * Displays as a compact issue card in feed view */ -export function Kind1621Renderer({ event }: BaseEventProps) { +export function IssueRenderer({ event }: BaseEventProps) { const { addWindow } = useGrimoire(); const title = getIssueTitle(event); const labels = getIssueLabels(event); @@ -66,9 +70,13 @@ export function Kind1621Renderer({ event }: BaseEventProps) {
{/* Issue Title */} -

+ {title || "Untitled Issue"} -

+ {/* Repository Reference */} {repoAddress && repoPointer && ( @@ -80,7 +88,7 @@ export function Kind1621Renderer({ event }: BaseEventProps) { cursor-crosshair underline decoration-dotted hover:text-primary `} > - + {repoName}
by diff --git a/src/components/nostr/kinds/Kind30023DetailRenderer.tsx b/src/components/nostr/kinds/Kind30023DetailRenderer.tsx index ea30364..6e4f01e 100644 --- a/src/components/nostr/kinds/Kind30023DetailRenderer.tsx +++ b/src/components/nostr/kinds/Kind30023DetailRenderer.tsx @@ -163,7 +163,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) { : null; return ( -
+
{/* Article Header */}
{/* Title */} @@ -290,7 +290,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) { hr: () =>
, }} > - {event.content} + {event.content.replace(/\\n/g, '\n')}
diff --git a/src/components/nostr/kinds/Kind30023Renderer.tsx b/src/components/nostr/kinds/Kind30023Renderer.tsx index 8fe0b1b..716497d 100644 --- a/src/components/nostr/kinds/Kind30023Renderer.tsx +++ b/src/components/nostr/kinds/Kind30023Renderer.tsx @@ -1,5 +1,9 @@ import { useMemo } from "react"; -import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer"; +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; import { getArticleTitle, getArticleSummary, @@ -15,10 +19,16 @@ export function Kind30023Renderer({ event }: BaseEventProps) { return ( -
+
{/* Title */} {title && ( -

{title}

+ + {title} + )} {/* 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) {

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