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 ? (
+
+ ) : (
+
+ (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)
// ============================================================================