feat(nip34): extract useRepositoryRelays hook, add patch series nav and PR Updates renderer

- Extract duplicated relay resolution logic (repo relays → outbox → aggregators)
  into a reusable useRepositoryRelays hook, removing ~200 lines across 6 renderers
- Add PatchSeriesNav component to PatchDetailRenderer for navigating patch series
  via NIP-10 threading (root patches and their children)
- Add Kind 1619 PR Updates renderer (feed + detail) with commit list, branch info,
  and embedded PR reference
- Add PR Update helper functions to nip34-helpers.ts

https://claude.ai/code/session_01TUzfLDbarxHDYQRA2fyYTr
This commit is contained in:
Claude
2026-02-12 22:52:48 +00:00
parent 62ce435043
commit 2a662a9101
12 changed files with 540 additions and 291 deletions

View File

@@ -7,20 +7,16 @@ import {
getIssueTitle,
getIssueLabels,
getIssueRepositoryAddress,
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 { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
import { formatTimestamp } from "@/hooks/useLocale";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
/**
* Detail renderer for Kind 1621 - Issue (NIP-34)
@@ -31,55 +27,10 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
const labels = getIssueLabels(event);
const repoAddress = getIssueRepositoryAddress(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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events that reference this issue
// Status events use e tag with root marker to reference the issue
const statusFilter = useMemo(
() => ({
kinds: [1630, 1631, 1632, 1633],

View File

@@ -8,18 +8,14 @@ import {
getIssueTitle,
getIssueLabels,
getIssueRepositoryAddress,
getRepositoryRelays,
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 { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
/**
* Renderer for Kind 1621 - Issue
@@ -30,52 +26,8 @@ export function IssueRenderer({ event }: BaseEventProps) {
const labels = getIssueLabels(event);
const repoAddress = getIssueRepositoryAddress(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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events that reference this issue
const statusFilter = useMemo(

View File

@@ -0,0 +1,153 @@
import { GitPullRequestArrow, GitBranch, Copy, CopyCheck } from "lucide-react";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { RepositoryLink } from "../RepositoryLink";
import { useCopy } from "@/hooks/useCopy";
import { formatTimestamp } from "@/hooks/useLocale";
import { useGrimoire } from "@/core/state";
import type { NostrEvent } from "@/types/nostr";
import type { EventPointer } from "nostr-tools/nip19";
import {
getPRUpdatePREventId,
getPRUpdatePRRelayHint,
getPRUpdateCommits,
getPRUpdateBranchName,
getPRUpdateRepositoryAddress,
} from "@/lib/nip34-helpers";
/**
* Detail renderer for Kind 1619 - Pull Request Updates (NIP-34)
* Full view showing the update details and referenced PR
*/
export function PRUpdateDetailRenderer({ event }: { event: NostrEvent }) {
const { copy, copied } = useCopy();
const { addWindow } = useGrimoire();
const prEventId = getPRUpdatePREventId(event);
const relayHint = getPRUpdatePRRelayHint(event);
const commits = getPRUpdateCommits(event);
const branchName = getPRUpdateBranchName(event);
const repoAddress = getPRUpdateRepositoryAddress(event);
// Build event pointer for the referenced PR
const prPointer: EventPointer | undefined = prEventId
? { id: prEventId, relays: relayHint ? [relayHint] : undefined }
: undefined;
const createdDate = formatTimestamp(event.created_at, "long");
return (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Header */}
<header className="flex flex-col gap-3 pb-4 border-b border-border">
<h1 className="text-2xl font-bold flex items-center gap-2">
<GitPullRequestArrow className="size-6" />
Pull Request Update
</h1>
{/* Repository Link */}
{repoAddress && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Repository:</span>
<RepositoryLink
repoAddress={repoAddress}
iconSize="size-4"
className="font-mono"
/>
</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>
{/* Branch and Commits */}
{(branchName || commits.length > 0) && (
<section className="flex flex-col gap-3">
<h2 className="text-xl font-semibold flex items-center gap-2">
<GitBranch className="size-5" />
Update Details
</h2>
{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">
{branchName}
</code>
</div>
)}
{commits.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">
Commits ({commits.length})
</h3>
<ul className="flex flex-col gap-1">
{commits.map((hash, idx) => (
<li
key={idx}
className="flex items-center gap-2 p-2 bg-muted/30"
>
<code className="flex-1 text-sm font-mono truncate">
{hash}
</code>
<button
onClick={() => copy(hash)}
className="flex-shrink-0 p-1 hover:bg-muted"
aria-label="Copy commit hash"
>
{copied ? (
<CopyCheck className="size-3 text-muted-foreground" />
) : (
<Copy className="size-3 text-muted-foreground" />
)}
</button>
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Description */}
{event.content ? (
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Description</h2>
<MarkdownContent content={event.content} />
</section>
) : (
<p className="text-sm text-muted-foreground italic">
(No description provided)
</p>
)}
{/* Referenced PR */}
{prPointer && (
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Pull Request</h2>
<EmbeddedEvent
eventPointer={prPointer}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`PR ${(id as string).slice(0, 8)}...`,
);
}}
className="border border-muted rounded overflow-hidden"
/>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { GitPullRequestArrow } from "lucide-react";
import {
BaseEventContainer,
type BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
getPRUpdatePREventId,
getPRUpdatePRRelayHint,
getPRUpdateCommitTip,
getPRUpdateBranchName,
getPRUpdateRepositoryAddress,
} from "@/lib/nip34-helpers";
import { RepositoryLink } from "../RepositoryLink";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { useGrimoire } from "@/core/state";
import type { EventPointer } from "nostr-tools/nip19";
/**
* Renderer for Kind 1619 - Pull Request Updates (NIP-34)
* Displays a compact card showing the PR update with a reference
* to the original PR event
*/
export function PRUpdateRenderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const prEventId = getPRUpdatePREventId(event);
const relayHint = getPRUpdatePRRelayHint(event);
const commitTip = getPRUpdateCommitTip(event);
const branchName = getPRUpdateBranchName(event);
const repoAddress = getPRUpdateRepositoryAddress(event);
const shortCommit = commitTip ? commitTip.slice(0, 7) : undefined;
// Build event pointer for the referenced PR
const prPointer: EventPointer | undefined = prEventId
? { id: prEventId, relays: relayHint ? [relayHint] : undefined }
: undefined;
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<ClickableEventTitle
event={event}
className="font-semibold text-foreground"
>
<span className="flex items-center gap-1.5">
<GitPullRequestArrow className="size-3.5 flex-shrink-0" />
PR Update
{branchName && (
<code className="text-xs font-mono text-muted-foreground">
{branchName}
</code>
)}
</span>
</ClickableEventTitle>
{/* Metadata line */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{repoAddress && (
<>
<span className="text-muted-foreground">in</span>
<RepositoryLink repoAddress={repoAddress} />
</>
)}
{shortCommit && (
<>
<span className="text-muted-foreground"></span>
<code className="text-muted-foreground font-mono text-xs">
{shortCommit}
</code>
</>
)}
</div>
{/* Description preview */}
{event.content && (
<p className="text-xs text-muted-foreground line-clamp-2">
{event.content}
</p>
)}
{/* Embedded PR reference */}
{prPointer && (
<EmbeddedEvent
eventPointer={prPointer}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`PR ${(id as string).slice(0, 8)}...`,
);
}}
className="border border-muted rounded overflow-hidden"
/>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -15,18 +15,15 @@ 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 { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
import { PatchSeriesNav } from "./PatchSeriesNav";
/**
* Detail renderer for Kind 1617 - Patch
@@ -43,44 +40,8 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events
const statusFilter = useMemo(
@@ -162,6 +123,9 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
</div>
</header>
{/* Patch Series Navigation */}
<PatchSeriesNav event={event} relays={statusRelays} />
{/* Commit Information */}
{(commitId || parentCommit || committer) && (
<section className="flex flex-col gap-3">

View File

@@ -8,17 +8,13 @@ import {
getPatchSubject,
getPatchCommitId,
getPatchRepositoryAddress,
getRepositoryRelays,
getValidStatusAuthors,
findCurrentStatus,
} from "@/lib/nip34-helpers";
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
import { getOutboxes } from "applesauce-core/helpers";
import { RepositoryLink } from "../RepositoryLink";
import { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
/**
* Renderer for Kind 1617 - Patch
@@ -32,52 +28,8 @@ 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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events that reference this patch
const statusFilter = useMemo(

View File

@@ -0,0 +1,135 @@
import { useMemo } from "react";
import { Layers, ChevronRight } from "lucide-react";
import { getNip10References } from "applesauce-common/helpers/threading";
import { useTimeline } from "@/hooks/useTimeline";
import { useGrimoire } from "@/core/state";
import { getPatchSubject, isPatchRoot } from "@/lib/nip34-helpers";
import { formatTimestamp } from "@/hooks/useLocale";
import type { NostrEvent } from "@/types/nostr";
interface PatchSeriesNavProps {
event: NostrEvent;
relays: string[];
}
/**
* Patch series navigation component for the PatchDetailRenderer.
*
* NIP-34 patches form a series via NIP-10 threading:
* - The first patch is tagged ["t", "root"]
* - Subsequent patches include ["e", rootPatchId, relay, "root"]
*
* This component finds the root patch, then queries all patches
* that reference the same root to build a navigable series list.
*/
export function PatchSeriesNav({ event, relays }: PatchSeriesNavProps) {
const { addWindow } = useGrimoire();
const isRoot = isPatchRoot(event);
// Parse NIP-10 references to find the root patch
const refs = getNip10References(event);
const rootPointer = refs.root?.e;
const rootEventId = rootPointer?.id;
// The effective root ID: if this IS the root patch, use its own ID;
// otherwise use the referenced root
const seriesRootId = isRoot ? event.id : rootEventId;
// Query all kind 1617 patches that reference the same root
const seriesFilter = useMemo(() => {
if (!seriesRootId) return null;
return {
kinds: [1617],
"#e": [seriesRootId],
};
}, [seriesRootId]);
const { events: relatedPatches } = useTimeline(
seriesRootId ? `patch-series-${seriesRootId}` : "patch-series-noop",
seriesFilter ?? { kinds: [1617], "#e": ["noop"] },
seriesFilter ? relays : [],
{ limit: 50 },
);
// Build sorted series: root first, then related patches by created_at
const series = useMemo(() => {
if (!seriesRootId) return [];
// Collect all patches in the series
const patches: NostrEvent[] = [];
// If this is the root, include it; relatedPatches are the children
if (isRoot) {
patches.push(event);
patches.push(...relatedPatches.filter((p) => p.id !== event.id));
} else {
// Include related patches (which reference the root)
patches.push(...relatedPatches.filter((p) => p.id !== event.id));
}
// Sort by created_at ascending (oldest first = series order)
patches.sort((a, b) => a.created_at - b.created_at);
return patches;
}, [seriesRootId, isRoot, event, relatedPatches]);
// Don't show if there are no related patches
if (series.length === 0) return null;
const handlePatchClick = (patchEvent: NostrEvent) => {
addWindow(
"open",
{ id: patchEvent.id },
getPatchSubject(patchEvent) || "Patch",
);
};
return (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5">
<Layers className="size-3.5" />
Patch Series ({series.length + (isRoot ? 0 : 1)})
</h2>
<div className="flex flex-col border border-border rounded overflow-hidden">
{/* Root patch indicator (if we're not the root and have a rootEventId) */}
{!isRoot && rootEventId && (
<button
onClick={() => addWindow("open", { id: rootEventId }, "Root Patch")}
className="flex items-center gap-2 px-3 py-2 text-xs bg-accent/10 text-accent hover:bg-accent/20 transition-colors text-left"
>
<ChevronRight className="size-3 flex-shrink-0" />
<span className="font-semibold">Root Patch</span>
</button>
)}
{/* Series patches */}
{series.map((patch, idx) => {
const isCurrent = patch.id === event.id;
const subject = getPatchSubject(patch) || `Patch ${idx + 1}`;
return (
<button
key={patch.id}
onClick={() => !isCurrent && handlePatchClick(patch)}
disabled={isCurrent}
className={`flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors border-t border-border first:border-t-0 ${
isCurrent
? "bg-primary/10 text-primary font-semibold cursor-default"
: "hover:bg-muted/50 text-foreground cursor-crosshair"
}`}
>
<span className="text-muted-foreground flex-shrink-0 w-5 text-right">
{idx + 1}.
</span>
<span className="truncate flex-1">{subject}</span>
<span className="text-muted-foreground flex-shrink-0">
{formatTimestamp(patch.created_at, "relative")}
</span>
</button>
);
})}
</div>
</section>
);
}

View File

@@ -13,19 +13,15 @@ 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 { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
/**
* Detail renderer for Kind 1618 - Pull Request
@@ -42,44 +38,8 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events
const statusFilter = useMemo(

View File

@@ -10,18 +10,14 @@ import {
getPullRequestLabels,
getPullRequestBranchName,
getPullRequestRepositoryAddress,
getRepositoryRelays,
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 { StatusIndicator } from "../StatusIndicator";
import { useTimeline } from "@/hooks/useTimeline";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { useRepositoryRelays } from "@/hooks/useRepositoryRelays";
/**
* Renderer for Kind 1618 - Pull Request
@@ -33,52 +29,8 @@ 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]);
const { relays: statusRelays, repositoryEvent } =
useRepositoryRelays(repoAddress);
// Fetch status events that reference this PR
const statusFilter = useMemo(

View File

@@ -23,6 +23,8 @@ import { PatchRenderer } from "./PatchRenderer";
import { PatchDetailRenderer } from "./PatchDetailRenderer";
import { PullRequestRenderer } from "./PullRequestRenderer";
import { PullRequestDetailRenderer } from "./PullRequestDetailRenderer";
import { PRUpdateRenderer } from "./PRUpdateRenderer";
import { PRUpdateDetailRenderer } from "./PRUpdateDetailRenderer";
import { Kind9735Renderer } from "./ZapReceiptRenderer";
import { Kind9802Renderer } from "./HighlightRenderer";
import { Kind9802DetailRenderer } from "./HighlightDetailRenderer";
@@ -189,6 +191,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1619: PRUpdateRenderer, // PR Updates (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
1630: IssueStatusRenderer, // Open Status (NIP-34)
1631: IssueStatusRenderer, // Applied/Merged/Resolved Status (NIP-34)
@@ -303,6 +306,7 @@ const detailRenderers: Record<
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1619: PRUpdateDetailRenderer, // PR Updates Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
1630: IssueStatusDetailRenderer, // Open Status Detail (NIP-34)
1631: IssueStatusDetailRenderer, // Applied/Merged/Resolved Status Detail (NIP-34)

View File

@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
import { getOutboxes } from "applesauce-core/helpers";
import { useNostrEvent } from "./useNostrEvent";
import { getRepositoryRelays } from "@/lib/nip34-helpers";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
/**
* Hook to resolve relay URLs for a NIP-34 repository address.
*
* Implements the standard relay fallback chain:
* 1. Repository-configured relays (from the repo event's `relays` tag)
* 2. Repository author's outbox (write) relays (from kind 10002)
* 3. AGGREGATOR_RELAYS as final fallback
*
* Also returns the fetched repository event, useful for getting
* maintainers list and other repo metadata.
*
* @param repoAddress - Repository address in "kind:pubkey:identifier" format
*/
export function useRepositoryRelays(repoAddress: string | undefined) {
const parsedRepo = useMemo(
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
[repoAddress],
);
const repoPointer = useMemo(() => {
if (!parsedRepo) return undefined;
return {
kind: parsedRepo.kind,
pubkey: parsedRepo.pubkey,
identifier: parsedRepo.identifier,
};
}, [parsedRepo]);
const repositoryEvent = useNostrEvent(repoPointer);
const repoAuthorRelayListPointer = useMemo(() => {
if (!parsedRepo?.pubkey) return undefined;
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
}, [parsedRepo?.pubkey]);
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
const relays = 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]);
return { relays, repositoryEvent };
}

View File

@@ -328,6 +328,73 @@ export function getPullRequestRepositoryAddress(
return getTagValue(event, "a");
}
// ============================================================================
// Pull Request Update Event Helpers (Kind 1619)
// ============================================================================
const PRUpdateCommitsSymbol = Symbol("prUpdateCommits");
/**
* Get the referenced PR event ID from a PR Update event
* @param event PR Update event (kind 1619)
* @returns PR event ID or undefined
*/
export function getPRUpdatePREventId(event: NostrEvent): string | undefined {
const eTag = event.tags.find((t) => t[0] === "e");
return eTag?.[1];
}
/**
* Get the relay hint for the referenced PR
* @param event PR Update event (kind 1619)
* @returns Relay URL or undefined
*/
export function getPRUpdatePRRelayHint(event: NostrEvent): string | undefined {
const eTag = event.tags.find((t) => t[0] === "e");
return eTag?.[2] || undefined;
}
/**
* Get the repository address from a PR Update event
* @param event PR Update event (kind 1619)
* @returns Repository address (a tag) or undefined
*/
export function getPRUpdateRepositoryAddress(
event: NostrEvent,
): string | undefined {
return getTagValue(event, "a");
}
/**
* Get the new commit tip from a PR Update event
* @param event PR Update event (kind 1619)
* @returns Commit hash or undefined
*/
export function getPRUpdateCommitTip(event: NostrEvent): string | undefined {
return getTagValue(event, "c");
}
/**
* Get all commit hashes from a PR Update event
* PR updates may include multiple commit tags for the new commits
* @param event PR Update event (kind 1619)
* @returns Array of commit hashes
*/
export function getPRUpdateCommits(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, PRUpdateCommitsSymbol, () =>
event.tags.filter((t) => t[0] === "c" && t[1]).map((t) => t[1]),
);
}
/**
* Get the branch name from a PR Update event
* @param event PR Update event (kind 1619)
* @returns Branch name or undefined
*/
export function getPRUpdateBranchName(event: NostrEvent): string | undefined {
return getTagValue(event, "branch-name");
}
// ============================================================================
// Repository State Event Helpers (Kind 30618)
// ============================================================================