mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 17:19:27 +02:00
feat(nip34): Add status rendering to Patch and PR renderers
- PatchRenderer and PatchDetailRenderer now show merge/closed/draft status - PullRequestRenderer and PullRequestDetailRenderer now show merge/closed/draft status - Status events fetched from repository relays with author outbox fallback - For patches and PRs, kind 1631 displays as "merged" instead of "resolved" - Fixed destructive color contrast in dark theme (30.6% -> 50% lightness) https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { GitCommit, User, Copy, CopyCheck } from "lucide-react";
|
||||
import {
|
||||
GitCommit,
|
||||
User,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -14,50 +24,189 @@ import {
|
||||
getPatchRepositoryAddress,
|
||||
isPatchRoot,
|
||||
isPatchRootRevision,
|
||||
getRepositoryRelays,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { getOutboxes } from "applesauce-core/helpers";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* Get the icon for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color classes for a status badge
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
case 1631: // Merged - positive
|
||||
return "bg-accent/20 text-accent border-accent/30";
|
||||
case 1632: // Closed - negative
|
||||
return "bg-destructive/20 text-destructive border-destructive/30";
|
||||
case 1633: // Draft - muted
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1617 - Patch
|
||||
* Displays full patch metadata and content
|
||||
* Displays full patch metadata and content with status
|
||||
*/
|
||||
export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
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]);
|
||||
const subject = getPatchSubject(event);
|
||||
const commitId = getPatchCommitId(event);
|
||||
const parentCommit = getPatchParentCommit(event);
|
||||
const committer = getPatchCommitter(event);
|
||||
const repoAddress = getPatchRepositoryAddress(event);
|
||||
const isRoot = isPatchRoot(event);
|
||||
const isRootRevision = isPatchRootRevision(event);
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks
|
||||
const statusRelays = useMemo(() => {
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents, loading: statusLoading } = useTimeline(
|
||||
`patch-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 20 },
|
||||
);
|
||||
|
||||
// Get valid status authors
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
// Status display - for patches, 1631 means "merged"
|
||||
const statusType = currentStatus
|
||||
? currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind)
|
||||
: "open";
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusBadgeClasses = currentStatus
|
||||
? getStatusBadgeClasses(currentStatus.kind)
|
||||
: "bg-muted/50 text-foreground border-border";
|
||||
|
||||
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">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
{statusLoading ? (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading status...</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium border ${statusBadgeClasses}`}
|
||||
>
|
||||
<StatusIcon className="size-4" />
|
||||
<span className="capitalize">{statusType}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Root badges */}
|
||||
{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>
|
||||
|
||||
{/* 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">
|
||||
@@ -169,6 +318,33 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Status History */}
|
||||
{currentStatus && (
|
||||
<section className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Last Status Update
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserName pubkey={currentStatus.pubkey} />
|
||||
<span className="text-muted-foreground">
|
||||
{currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind) || "updated"}{" "}
|
||||
this patch
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<time className="text-muted-foreground">
|
||||
{formatTimestamp(currentStatus.created_at, "date")}
|
||||
</time>
|
||||
</div>
|
||||
{currentStatus.content && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentStatus.content}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { CircleDot, CheckCircle2, XCircle, FileEdit } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
@@ -7,12 +9,58 @@ import {
|
||||
getPatchSubject,
|
||||
getPatchCommitId,
|
||||
getPatchRepositoryAddress,
|
||||
getRepositoryRelays,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { getOutboxes } from "applesauce-core/helpers";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* Get the icon for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for a status kind
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusColorClass(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "text-foreground";
|
||||
case 1631: // Merged - positive
|
||||
return "text-accent";
|
||||
case 1632: // Closed - negative
|
||||
return "text-destructive";
|
||||
case 1633: // Draft - muted
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1617 - Patch
|
||||
* Displays as a compact patch card in feed view
|
||||
* Displays as a compact patch card in feed view with status
|
||||
*/
|
||||
export function PatchRenderer({ event }: BaseEventProps) {
|
||||
const subject = getPatchSubject(event);
|
||||
@@ -22,22 +70,117 @@ export function PatchRenderer({ event }: BaseEventProps) {
|
||||
// Shorten commit ID for display
|
||||
const shortCommitId = commitId ? commitId.slice(0, 7) : undefined;
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks:
|
||||
// 1. Repository configured relays
|
||||
// 2. Repo author's outbox (write) relays
|
||||
// 3. AGGREGATOR_RELAYS as final fallback
|
||||
const statusRelays = useMemo(() => {
|
||||
// Try repository relays first
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
|
||||
// Try repo author's outbox relays
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events that reference this patch
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents } = useTimeline(
|
||||
`patch-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
// Get valid status authors (patch author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Status display - for patches, 1631 means "merged" not "resolved"
|
||||
const statusType = currentStatus
|
||||
? currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind)
|
||||
: "open";
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusColorClass = currentStatus
|
||||
? getStatusColorClass(currentStatus.kind)
|
||||
: "text-foreground";
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Patch Subject */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Patch"}
|
||||
</ClickableEventTitle>
|
||||
{/* Status and Subject */}
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`size-4 flex-shrink-0 ${statusColorClass}`} />
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Patch"}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>in</span>
|
||||
{/* Repository */}
|
||||
{repoAddress && <RepositoryLink repoAddress={repoAddress} />}
|
||||
<span className={statusColorClass}>{statusType}</span>
|
||||
{repoAddress && (
|
||||
<>
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Commit ID */}
|
||||
{shortCommitId && (
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { GitBranch, Tag, Copy, CopyCheck } from "lucide-react";
|
||||
import {
|
||||
GitBranch,
|
||||
Tag,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -13,35 +23,175 @@ import {
|
||||
getPullRequestCloneUrls,
|
||||
getPullRequestMergeBase,
|
||||
getPullRequestRepositoryAddress,
|
||||
getRepositoryRelays,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { getOutboxes } from "applesauce-core/helpers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* Get the icon for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color classes for a status badge
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
case 1631: // Merged - positive
|
||||
return "bg-accent/20 text-accent border-accent/30";
|
||||
case 1632: // Closed - negative
|
||||
return "bg-destructive/20 text-destructive border-destructive/30";
|
||||
case 1633: // Draft - muted
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1618 - Pull Request
|
||||
* Displays full PR content with markdown rendering
|
||||
* Displays full PR content with markdown rendering and status
|
||||
*/
|
||||
export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
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],
|
||||
const subject = getPullRequestSubject(event);
|
||||
const labels = getPullRequestLabels(event);
|
||||
const commitId = getPullRequestCommitId(event);
|
||||
const branchName = getPullRequestBranchName(event);
|
||||
const cloneUrls = getPullRequestCloneUrls(event);
|
||||
const mergeBase = getPullRequestMergeBase(event);
|
||||
const repoAddress = getPullRequestRepositoryAddress(event);
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks
|
||||
const statusRelays = useMemo(() => {
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents, loading: statusLoading } = useTimeline(
|
||||
`pr-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 20 },
|
||||
);
|
||||
|
||||
// Get valid status authors
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
// Status display - for PRs, 1631 means "merged"
|
||||
const statusType = currentStatus
|
||||
? currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind)
|
||||
: "open";
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusBadgeClasses = currentStatus
|
||||
? getStatusBadgeClasses(currentStatus.kind)
|
||||
: "bg-muted/50 text-foreground border-border";
|
||||
|
||||
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">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
{statusLoading ? (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading status...</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium border ${statusBadgeClasses}`}
|
||||
>
|
||||
<StatusIcon className="size-4" />
|
||||
<span className="capitalize">{statusType}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
{subject || "Untitled Pull Request"}
|
||||
@@ -195,6 +345,33 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
(No description provided)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status History */}
|
||||
{currentStatus && (
|
||||
<section className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Last Status Update
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserName pubkey={currentStatus.pubkey} />
|
||||
<span className="text-muted-foreground">
|
||||
{currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind) || "updated"}{" "}
|
||||
this pull request
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<time className="text-muted-foreground">
|
||||
{formatTimestamp(currentStatus.created_at, "date")}
|
||||
</time>
|
||||
</div>
|
||||
{currentStatus.content && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentStatus.content}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,74 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import {
|
||||
getPullRequestSubject,
|
||||
getPullRequestLabels,
|
||||
getPullRequestBranchName,
|
||||
getPullRequestRepositoryAddress,
|
||||
getRepositoryRelays,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { getOutboxes } from "applesauce-core/helpers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* Get the icon for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for a status kind
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusColorClass(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "text-foreground";
|
||||
case 1631: // Merged - positive
|
||||
return "text-accent";
|
||||
case 1632: // Closed - negative
|
||||
return "text-destructive";
|
||||
case 1633: // Draft - muted
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1618 - Pull Request
|
||||
* Displays as a compact PR card in feed view
|
||||
* Displays as a compact PR card in feed view with status
|
||||
*/
|
||||
export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
const subject = getPullRequestSubject(event);
|
||||
@@ -23,25 +76,122 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
const branchName = getPullRequestBranchName(event);
|
||||
const repoAddress = getPullRequestRepositoryAddress(event);
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks:
|
||||
// 1. Repository configured relays
|
||||
// 2. Repo author's outbox (write) relays
|
||||
// 3. AGGREGATOR_RELAYS as final fallback
|
||||
const statusRelays = useMemo(() => {
|
||||
// Try repository relays first
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
|
||||
// Try repo author's outbox relays
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events that reference this PR
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents } = useTimeline(
|
||||
`pr-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
// Get valid status authors (PR author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Status display - for PRs, 1631 means "merged" not "resolved"
|
||||
const statusType = currentStatus
|
||||
? currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind)
|
||||
: "open";
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusColorClass = currentStatus
|
||||
? getStatusColorClass(currentStatus.kind)
|
||||
: "text-foreground";
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* PR Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
</ClickableEventTitle>
|
||||
{/* Status and PR Title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`size-4 flex-shrink-0 ${statusColorClass}`} />
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Repository */}
|
||||
{repoAddress && (
|
||||
<RepositoryLink
|
||||
repoAddress={repoAddress}
|
||||
className="truncate line-clamp-1 text-xs"
|
||||
/>
|
||||
)}
|
||||
{/* Status and Repository */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={statusColorClass}>{statusType}</span>
|
||||
{repoAddress && (
|
||||
<>
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink
|
||||
repoAddress={repoAddress}
|
||||
className="truncate line-clamp-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Branch Name */}
|
||||
{branchName && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
--muted-foreground: 215 20.2% 70%;
|
||||
--accent: 270 100% 70%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive: 0 72% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
||||
Reference in New Issue
Block a user