From 2a662a9101c38d079317ddb2d9e4f5b77ecf4b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 22:52:48 +0000 Subject: [PATCH] feat(nip34): extract useRepositoryRelays hook, add patch series nav and PR Updates renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../nostr/kinds/IssueDetailRenderer.tsx | 55 +------ src/components/nostr/kinds/IssueRenderer.tsx | 54 +------ .../nostr/kinds/PRUpdateDetailRenderer.tsx | 153 ++++++++++++++++++ .../nostr/kinds/PRUpdateRenderer.tsx | 101 ++++++++++++ .../nostr/kinds/PatchDetailRenderer.tsx | 50 +----- src/components/nostr/kinds/PatchRenderer.tsx | 54 +------ src/components/nostr/kinds/PatchSeriesNav.tsx | 135 ++++++++++++++++ .../nostr/kinds/PullRequestDetailRenderer.tsx | 46 +----- .../nostr/kinds/PullRequestRenderer.tsx | 54 +------ src/components/nostr/kinds/index.tsx | 4 + src/hooks/useRepositoryRelays.ts | 58 +++++++ src/lib/nip34-helpers.ts | 67 ++++++++ 12 files changed, 540 insertions(+), 291 deletions(-) create mode 100644 src/components/nostr/kinds/PRUpdateDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/PRUpdateRenderer.tsx create mode 100644 src/components/nostr/kinds/PatchSeriesNav.tsx create mode 100644 src/hooks/useRepositoryRelays.ts diff --git a/src/components/nostr/kinds/IssueDetailRenderer.tsx b/src/components/nostr/kinds/IssueDetailRenderer.tsx index 6aed758..6da6fb5 100644 --- a/src/components/nostr/kinds/IssueDetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -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], diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index 6bbeee0..775b594 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -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( diff --git a/src/components/nostr/kinds/PRUpdateDetailRenderer.tsx b/src/components/nostr/kinds/PRUpdateDetailRenderer.tsx new file mode 100644 index 0000000..f2e56ce --- /dev/null +++ b/src/components/nostr/kinds/PRUpdateDetailRenderer.tsx @@ -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 ( +
+ {/* Header */} +
+

+ + Pull Request Update +

+ + {/* Repository Link */} + {repoAddress && ( +
+ Repository: + +
+ )} + + {/* Metadata */} +
+
+ By + +
+ + +
+
+ + {/* Branch and Commits */} + {(branchName || commits.length > 0) && ( +
+

+ + Update Details +

+ + {branchName && ( +
+ Branch: + + {branchName} + +
+ )} + + {commits.length > 0 && ( +
+

+ Commits ({commits.length}) +

+
    + {commits.map((hash, idx) => ( +
  • + + {hash} + + +
  • + ))} +
+
+ )} +
+ )} + + {/* Description */} + {event.content ? ( +
+

Description

+ +
+ ) : ( +

+ (No description provided) +

+ )} + + {/* Referenced PR */} + {prPointer && ( +
+

Pull Request

+ { + addWindow( + "open", + { id: id as string }, + `PR ${(id as string).slice(0, 8)}...`, + ); + }} + className="border border-muted rounded overflow-hidden" + /> +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/PRUpdateRenderer.tsx b/src/components/nostr/kinds/PRUpdateRenderer.tsx new file mode 100644 index 0000000..9c363f4 --- /dev/null +++ b/src/components/nostr/kinds/PRUpdateRenderer.tsx @@ -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 ( + +
+ {/* Title */} + + + + PR Update + {branchName && ( + + {branchName} + + )} + + + + {/* Metadata line */} +
+ {repoAddress && ( + <> + in + + + )} + {shortCommit && ( + <> + + + {shortCommit} + + + )} +
+ + {/* Description preview */} + {event.content && ( +

+ {event.content} +

+ )} + + {/* Embedded PR reference */} + {prPointer && ( + { + addWindow( + "open", + { id: id as string }, + `PR ${(id as string).slice(0, 8)}...`, + ); + }} + className="border border-muted rounded overflow-hidden" + /> + )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/PatchDetailRenderer.tsx b/src/components/nostr/kinds/PatchDetailRenderer.tsx index de5f914..bece367 100644 --- a/src/components/nostr/kinds/PatchDetailRenderer.tsx +++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx @@ -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 }) { + {/* Patch Series Navigation */} + + {/* Commit Information */} {(commitId || parentCommit || committer) && (
diff --git a/src/components/nostr/kinds/PatchRenderer.tsx b/src/components/nostr/kinds/PatchRenderer.tsx index 60649a1..bf9eb01 100644 --- a/src/components/nostr/kinds/PatchRenderer.tsx +++ b/src/components/nostr/kinds/PatchRenderer.tsx @@ -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( diff --git a/src/components/nostr/kinds/PatchSeriesNav.tsx b/src/components/nostr/kinds/PatchSeriesNav.tsx new file mode 100644 index 0000000..f36a1f0 --- /dev/null +++ b/src/components/nostr/kinds/PatchSeriesNav.tsx @@ -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 ( +
+

+ + Patch Series ({series.length + (isRoot ? 0 : 1)}) +

+ +
+ {/* Root patch indicator (if we're not the root and have a rootEventId) */} + {!isRoot && rootEventId && ( + + )} + + {/* Series patches */} + {series.map((patch, idx) => { + const isCurrent = patch.id === event.id; + const subject = getPatchSubject(patch) || `Patch ${idx + 1}`; + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx index c523ea4..8d618b4 100644 --- a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx @@ -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( diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index 6466a7d..f3a5ab3 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -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( diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 4d38bc8..ebfa443 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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) diff --git a/src/hooks/useRepositoryRelays.ts b/src/hooks/useRepositoryRelays.ts new file mode 100644 index 0000000..e83c199 --- /dev/null +++ b/src/hooks/useRepositoryRelays.ts @@ -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 }; +} diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index 9eab3aa..028bc24 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -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) // ============================================================================