fix: PR body rendering

This commit is contained in:
Alejandro Gómez
2025-12-15 11:27:13 +01:00
parent 5092a4fe5f
commit dab250260f
25 changed files with 1565 additions and 140 deletions

View File

@@ -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 (
<div className="flex items-center gap-1">
<div className="flex items-center gap-0">
{getKindName(event.kind)}
<span>:</span>
</div>
{getEventDisplayTitle(event, false)}
<span> - </span>
<UserName pubkey={event.pubkey} />
</div>
);
}, [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;

View File

@@ -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) {
<Kind0DetailRenderer event={event} />
) : event.kind === kinds.Contacts ? (
<Kind3DetailView event={event} />
) : event.kind === 1617 ? (
<PatchDetailRenderer event={event} />
) : event.kind === 1618 ? (
<PullRequestDetailRenderer event={event} />
) : event.kind === 1621 ? (
<Kind1621DetailRenderer event={event} />
<IssueDetailRenderer event={event} />
) : event.kind === kinds.Highlights ? (
<Kind9802DetailRenderer event={event} />
) : event.kind === kinds.RelayList ? (
@@ -276,7 +282,7 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
) : event.kind === kinds.LongFormArticle ? (
<Kind30023DetailRenderer event={event} />
) : event.kind === 30617 ? (
<Kind30617DetailRenderer event={event} />
<RepositoryDetailRenderer event={event} />
) : (
<KindRenderer event={event} />
)}

View File

@@ -284,7 +284,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
/>
{/* NIP-05 */}
{profile.nip05 && (
<div className="text-xs text-muted-foreground">
<div className="text-xs">
<Nip05 pubkey={pubkey} profile={profile} />
</div>
)}

View File

@@ -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 (
<MosaicWindow path={path} title={title} renderToolbar={renderToolbar}>
<MosaicWindow path={path} title={titleString} renderToolbar={renderToolbar}>
<ErrorBoundary level="window">
<WindowRenderer window={window} onClose={() => onClose(id)} />
</ErrorBoundary>

View File

@@ -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 (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground underline decoration-dotted hover:text-foreground cursor-crosshair break-all"
className={cn(
"text-muted-foreground underline decoration-dotted cursor-crosshair hover:text-foreground break-all",
className,
)}
onClick={handleClick}
>
{url}
{displayUrl}
</a>
);
}

View File

@@ -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 (
<div className={cn("leading-relaxed break-words", className)}>
{lines.map((line, idx) => (
<div key={idx} dir="auto">
{line || "\u00A0"}
</div>
))}
</div>
);
}
// Render event content with rich formatting
if (event) {
return (
<DepthContext.Provider value={depth}>
<OptionsContext.Provider value={mergedOptions}>
<div className={cn("leading-relaxed break-words", className)}>
{renderedContent}
</div>
</OptionsContext.Provider>
</DepthContext.Provider>
);
}
return null;
return (
<DepthContext.Provider value={depth}>
<OptionsContext.Provider value={mergedOptions}>
<div
dir="auto"
className={cn("leading-relaxed break-words", className)}
>
{renderedContent}
</div>
</OptionsContext.Provider>
</DepthContext.Provider>
);
}

View File

@@ -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"
/>
<MediaDialog
open={dialogOpen}

View File

@@ -1,39 +1,33 @@
import { CommonData } from "applesauce-content/nast";
import { useMemo } from "react";
interface TextNodeProps {
node: {
type: "text";
value: string;
data?: CommonData;
};
}
// Check if text contains RTL characters (Arabic, Hebrew, Persian, etc.)
function hasRTLCharacters(text: string): boolean {
return /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(text);
}
export function Text({ node }: TextNodeProps) {
const text = node.value;
// If no newlines, render as inline span
if (!text.includes("\n")) {
const isRTL = hasRTLCharacters(text);
return <span dir={isRTL ? "rtl" : "auto"}>{text || "\u00A0"}</span>;
const lines = useMemo(() => text.split("\n"), [text]);
if (text.includes("\n")) {
return (
<>
{lines.map((line, idx) =>
line.trim().length === 0 ? (
<br />
) : idx === 0 || idx === lines.length - 1 ? (
<span dir="auto">{line}</span> // FIXME: this should be span or div depnding on context
) : (
<div dir="auto" key={idx}>
{line}
</div>
),
)}
</>
);
}
// If has newlines, use regular inline spans with <br> tags
const lines = text.split("\n");
return (
<>
{lines.map((line, idx) => {
const isRTL = hasRTLCharacters(line);
return (
<>
{idx > 0 && <br key={`br-${idx}`} />}
<span key={idx} dir={isRTL ? "rtl" : "auto"}>
{line || "\u00A0"}
</span>
</>
);
})}
</>
);
return <span dir="auto">{text}</span>;
}

View File

@@ -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 (
<Component
className={cn(
"cursor-crosshair hover:underline hover:decoration-dotted",
className,
)}
onClick={handleClick}
>
{children}
</Component>
);
}
/**
* Base event container with universal header
* Kind-specific renderers can wrap their content with this

View File

@@ -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"
}`}
>
<GitBranch className="size-4" />
<FolderGit2 className="size-4" />
{repoName}
</button>
</div>
@@ -309,7 +309,7 @@ export function Kind1621DetailRenderer({ event }: { event: NostrEvent }) {
hr: () => <hr className="my-4" />,
}}
>
{event.content}
{event.content.replace(/\\n/g, '\n')}
</ReactMarkdown>
</article>
) : (

View File

@@ -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) {
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Issue Title */}
<h3 className="font-semibold text-foreground">
<ClickableEventTitle
event={event}
windowTitle={title || "Untitled Issue"}
className="font-semibold text-foreground"
>
{title || "Untitled Issue"}
</h3>
</ClickableEventTitle>
{/* Repository Reference */}
{repoAddress && repoPointer && (
@@ -80,7 +88,7 @@ export function Kind1621Renderer({ event }: BaseEventProps) {
cursor-crosshair underline decoration-dotted hover:text-primary
`}
>
<GitBranch className="size-3" />
<FolderGit2 className="size-3" />
<span>{repoName}</span>
</div>
<span>by</span>

View File

@@ -163,7 +163,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
: null;
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Article Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Title */}
@@ -290,7 +290,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
hr: () => <hr className="my-4" />,
}}
>
{event.content}
{event.content.replace(/\\n/g, '\n')}
</ReactMarkdown>
</article>
</div>

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<div dir="auto" className="flex flex-col gap-2">
{/* Title */}
{title && (
<h3 className="text-lg font-bold text-foreground">{title}</h3>
<ClickableEventTitle
event={event}
windowTitle={title}
className="text-lg font-bold text-foreground"
>
{title}
</ClickableEventTitle>
)}
{/* Summary */}

View File

@@ -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) {
<div className="flex flex-col gap-2">
{/* Title */}
{title && (
<h3 className="text-lg font-bold text-foreground">{title}</h3>
<ClickableEventTitle
event={event}
windowTitle={title}
className="text-lg font-bold text-foreground"
>
{title}
</ClickableEventTitle>
)}
{/* URL with external link icon */}

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Patch Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">{subject || "Untitled Patch"}</h1>
{/* Status Badges */}
{(isRoot || isRootRevision) && (
<div className="flex flex-wrap items-center gap-2">
{isRoot && (
<span className="px-3 py-1 bg-accent/20 text-accent text-sm border border-accent/30">
Root Patch
</span>
)}
{isRootRevision && (
<span className="px-3 py-1 bg-primary/20 text-primary text-sm border border-primary/30">
Root Revision
</span>
)}
</div>
)}
{/* 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"
}`}
>
<FolderGit2 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>
</header>
{/* Commit Information */}
{(commitId || parentCommit || committer) && (
<section className="flex flex-col gap-3">
<h2 className="text-xl font-semibold flex items-center gap-2">
<GitCommit className="size-5 flex-shrink-0" />
Commit Information
</h2>
{/* Commit ID */}
{commitId && (
<div className="flex items-center gap-2 p-2 bg-muted/30">
<span className="text-sm text-muted-foreground">Commit:</span>
<code className="flex-1 text-sm font-mono line-clamp-1 truncate">
{commitId}
</code>
<button
onClick={() => copy(commitId)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy commit ID"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Parent Commit */}
{parentCommit && (
<div className="flex items-center gap-2 p-2 bg-muted/30">
<span className="text-sm text-muted-foreground">Parent:</span>
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
{parentCommit}
</code>
<button
onClick={() => copy(parentCommit)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy parent commit ID"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Committer Info */}
{committer && (
<div className="flex items-start gap-2 p-2 bg-muted/30">
<User className="size-4 text-muted-foreground mt-0.5" />
<div className="flex flex-row gap-2 text-sm truncate line-clamp-1">
<span className="text-muted-foreground">Committer: </span>
<div className="flex flex-row gap-1 truncate line-clamp-1">
<span className="font-semibold">{committer.name}</span>
{committer.email && (
<span className="text-muted-foreground">
&lt;{committer.email}&gt;
</span>
)}
</div>
</div>
</div>
)}
</section>
)}
{/* Patch Content */}
{event.content && (
<section className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Patch</h2>
<div className="relative">
<pre className="overflow-x-auto text-xs font-mono bg-muted/30 p-4 whitespace-pre-wrap break-words">
{event.content}
</pre>
<button
onClick={() => copy(event.content)}
className="absolute top-2 right-2 p-2 bg-background/90 hover:bg-muted border border-border rounded"
aria-label="Copy patch"
>
{copied ? (
<CopyCheck className="size-4 text-muted-foreground" />
) : (
<Copy className="size-4 text-muted-foreground" />
)}
</button>
</div>
</section>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Patch Subject */}
<ClickableEventTitle
event={event}
windowTitle={subject || "Untitled Patch"}
className="font-semibold text-foreground"
>
{subject || "Untitled Patch"}
</ClickableEventTitle>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>in</span>
{/* Repository */}
{repoAddress && repoPointer && (
<div
onClick={handleRepoClick}
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary"
>
<FolderGit2 className="size-3" />
<span>{repoName}</span>
</div>
)}
{/* Commit ID */}
{shortCommitId && (
<>
<span className="text-muted-foreground"></span>
<code className="text-muted-foreground font-mono text-xs">
{shortCommitId}
</code>
</>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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 (
<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) {
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 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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* PR Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">
{subject || "Untitled Pull Request"}
</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"
}`}
>
<FolderGit2 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>
{/* Branch and Commit Info */}
{(branchName || commitId || mergeBase) && (
<section className="flex flex-col gap-3">
<h2 className="text-xl font-semibold flex items-center gap-2">
<GitBranch className="size-5" />
Branch Information
</h2>
{/* Branch Name */}
{branchName && (
<div className="flex items-center gap-2 p-2 bg-muted/30">
<span className="text-sm text-muted-foreground">Branch:</span>
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
{branchName}
</code>
<button
onClick={() => copy(branchName)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy branch name"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Commit ID */}
{commitId && (
<div className="flex items-center gap-2 p-2 bg-muted/30">
<span className="text-sm text-muted-foreground">Commit:</span>
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
{commitId}
</code>
<button
onClick={() => copy(commitId)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy commit ID"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Merge Base */}
{mergeBase && (
<div className="flex items-center gap-2 p-2 bg-muted/30">
<span className="text-sm text-muted-foreground">Merge Base:</span>
<code className="flex-1 text-sm font-mono truncate line-clamp-1">
{mergeBase}
</code>
<button
onClick={() => copy(mergeBase)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy merge base"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</div>
)}
{/* Clone URLs */}
{cloneUrls.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">
Clone URLs
</h3>
<ul className="flex flex-col gap-2">
{cloneUrls.map((url, idx) => (
<li
key={idx}
className="flex items-center gap-2 p-2 bg-muted/30 font-mono"
>
<code className="flex-1 text-sm 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>
))}
</ul>
</div>
)}
</section>
)}
{/* PR Description - 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={{
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
) : null,
a: ({ href, children, ...props }) => {
if (!href) return null;
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
return (
<a
href={href}
className="text-accent underline decoration-dotted"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
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.replace(/\\n/g, '\n')}
</ReactMarkdown>
</article>
</>
) : (
<p className="text-sm text-muted-foreground italic">
(No description provided)
</p>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* PR Title */}
<div className="flex-1 min-w-0">
<ClickableEventTitle
event={event}
windowTitle={subject || "Untitled Pull Request"}
className="font-semibold text-foreground"
>
{subject || "Untitled Pull Request"}
</ClickableEventTitle>
</div>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>in</span>
{/* Repository */}
{repoAddress && repoPointer && (
<div
onClick={handleRepoClick}
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary"
>
<FolderGit2 className="size-3" />
<span>{repoName}</span>
</div>
)}
{/* Branch Name */}
{branchName && (
<>
<span className="text-muted-foreground"></span>
<code className="text-muted-foreground font-mono text-xs">
{branchName}
</code>
</>
)}
</div>
{/* Labels */}
{labels.length > 0 && (
<div className="flex items-center gap-1 overflow-x-scroll">
{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

@@ -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 */}
<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">

View File

@@ -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) {
<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">
<ClickableEventTitle
event={event}
windowTitle={`Repository: ${displayName}`}
className="text-lg font-semibold text-foreground"
as="span"
>
{displayName}
</span>
</ClickableEventTitle>
</div>
</div>

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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<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)
30617: RepositoryRenderer, // Repository (NIP-34)
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};

View File

@@ -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<number | string, EventKind> = {
name: "Merge Request",
description: "Merge Requests",
nip: "54",
icon: GitBranch,
icon: GitMerge,
},
// Marketplace
@@ -457,14 +464,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
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<number | string, EventKind> = {
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<number | string, EventKind> = {
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,

85
src/lib/event-title.ts Normal file
View File

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

View File

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

View File

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