-
in
- {/* Repository */}
- {repoAddress &&
}
+
{statusType}
+ {repoAddress && (
+ <>
+
in
+
+ >
+ )}
{/* Commit ID */}
{shortCommitId && (
diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx
index 89027b1..55d7afd 100644
--- a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx
+++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx
@@ -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 (
);
}
diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx
index 97ab93e..38aff45 100644
--- a/src/components/nostr/kinds/PullRequestRenderer.tsx
+++ b/src/components/nostr/kinds/PullRequestRenderer.tsx
@@ -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 (
- {/* PR Title */}
-
- {subject || "Untitled Pull Request"}
-
+ {/* Status and PR Title */}
+
+
+
+ {subject || "Untitled Pull Request"}
+
+
- {/* Repository */}
- {repoAddress && (
-
- )}
+ {/* Status and Repository */}
+
+ {statusType}
+ {repoAddress && (
+ <>
+ in
+
+ >
+ )}
+
{/* Branch Name */}
{branchName && (
diff --git a/src/index.css b/src/index.css
index 611f2c8..5d7637e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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%;