mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 02:01:22 +02:00
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:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
153
src/components/nostr/kinds/PRUpdateDetailRenderer.tsx
Normal file
153
src/components/nostr/kinds/PRUpdateDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/nostr/kinds/PRUpdateRenderer.tsx
Normal file
101
src/components/nostr/kinds/PRUpdateRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
135
src/components/nostr/kinds/PatchSeriesNav.tsx
Normal file
135
src/components/nostr/kinds/PatchSeriesNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
58
src/hooks/useRepositoryRelays.ts
Normal file
58
src/hooks/useRepositoryRelays.ts
Normal 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 };
|
||||
}
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user