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:
Claude
2026-01-23 11:45:01 +00:00
parent f6e68b5592
commit 34cab50040
5 changed files with 710 additions and 64 deletions

View File

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

View File

@@ -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 && (

View File

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

View File

@@ -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">

View File

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