mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
feat: NIP-34 status events (#209)
* feat(nip34): Add NIP-34 issue status renderers and locale-aware formatting - Add IssueStatusRenderer for feed view (kinds 1630-1633: Open/Resolved/Closed/Draft) - Add IssueStatusDetailRenderer for detail view with status badge and embedded issue - Update IssueRenderer/IssueDetailRenderer to fetch and display current issue status - Status validation respects issue author, repo owner, and maintainers - Add status helper functions to nip34-helpers.ts (getStatusType, findCurrentStatus, etc.) - Use parseReplaceableAddress from applesauce-core for coordinate parsing - Expand formatTimestamp utility with 'long' and 'datetime' styles - Fix locale-aware date formatting across all detail renderers - Update CLAUDE.md with useLocale hook and formatTimestamp documentation https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * refactor(nip34): Use theme semantic colors for issue status Replace hardcoded colors with theme semantic colors: - Resolved/merged: accent (positive) - Closed: destructive (negative) - Draft: muted - Open: neutral foreground Also fixes import placement in nip34-helpers.ts. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Use repository relays instead of AGGREGATOR_RELAYS Status events for issues are now fetched from the relays configured in the repository definition, not from hardcoded aggregator relays. This respects the relay hints provided by repository maintainers for better decentralization and reliability. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * perf(nip34): Add memoization caching to helper functions Use getOrComputeCachedValue from applesauce-core to cache computed values on event objects. This prevents redundant computation when helpers are called multiple times for the same event. Also added documentation in CLAUDE.md about best practices for writing helper libraries that compute data from Nostr events. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Add relay fallback chain for status event fetching Status events now use a fallback chain for relay selection: 1. Repository configured relays (from "relays" tag) 2. Repo author's outbox relays (from kind:10002) 3. AGGREGATOR_RELAYS as final fallback This ensures status events can be fetched even when repository doesn't have relays configured. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * feat(nip34): Add status rendering to Patch and PR renderers - PatchRenderer and PatchDetailRenderer now show merge/closed/draft status - PullRequestRenderer and PullRequestDetailRenderer now show merge/closed/draft status - Status events fetched from repository relays with author outbox fallback - For patches and PRs, kind 1631 displays as "merged" instead of "resolved" - Fixed destructive color contrast in dark theme (30.6% -> 50% lightness) https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * refactor(nip34): Extract StatusIndicator component, improve UI layout - Create reusable StatusIndicator component for issues/patches/PRs - Move status icon next to status text in feed renderers (not title) - Place status badge below title in detail renderers - Fix dark theme destructive color contrast (0 90% 65%) - Remove duplicate getStatusIcon/getStatusColorClass functions https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Make status badge width fit content https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(theme): Improve destructive color contrast on dark theme Increase lightness from 65% to 70% for better readability. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(theme): Use lighter coral red for destructive on dark theme Changed to 0 75% 75% (~#E89090) for better contrast against #020817 background. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * docs: Fix applesauce helper documentation in CLAUDE.md - Fix parseCoordinate -> parseReplaceableAddress (correct function name) - Clarify getTagValue (applesauce) vs getTagValues (custom Grimoire) - Add getOrComputeCachedValue to helpers list - Improve code example with proper imports and patterns https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Render status event content as rich text Use MarkdownContent component for status event content in Issue, Patch, and PR detail renderers. https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Smaller status indicators, improve issue feed layout - Use shared StatusIndicator in IssueStatusRenderer (smaller size) - Render status event content as markdown - Put status on its own line between title and repo in IssueRenderer https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 * fix(nip34): Use warning color for closed status instead of destructive - Change closed status from red (destructive) to orange (warning) - Improve dark theme status colors contrast (warning: 38 92% 60%) - Less aggressive visual for closed issues/patches/PRs https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
135
src/components/nostr/StatusIndicator.tsx
Normal file
135
src/components/nostr/StatusIndicator.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { getStatusType } from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Get the icon component for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for a status kind
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusColorClass(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "text-foreground";
|
||||
case 1631: // Resolved/Merged - positive
|
||||
return "text-accent";
|
||||
case 1632: // Closed - warning (less aggressive than destructive)
|
||||
return "text-warning";
|
||||
case 1633: // Draft - muted
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the background/border classes for a status badge
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
case 1631: // Resolved/Merged - positive
|
||||
return "bg-accent/20 text-accent border-accent/30";
|
||||
case 1632: // Closed - warning (less aggressive than destructive)
|
||||
return "bg-warning/20 text-warning border-warning/30";
|
||||
case 1633: // Draft - muted
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
}
|
||||
}
|
||||
|
||||
export interface StatusIndicatorProps {
|
||||
/** The status event kind (1630-1633) or undefined for default "open" */
|
||||
statusKind?: number;
|
||||
/** Whether status is loading */
|
||||
loading?: boolean;
|
||||
/** Event type for appropriate labeling (affects "resolved" vs "merged") */
|
||||
eventType?: "issue" | "patch" | "pr";
|
||||
/** Display variant */
|
||||
variant?: "inline" | "badge";
|
||||
/** Optional custom class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable status indicator for NIP-34 events (issues, patches, PRs)
|
||||
* Displays status icon and text with appropriate styling
|
||||
*/
|
||||
export function StatusIndicator({
|
||||
statusKind,
|
||||
loading = false,
|
||||
eventType = "issue",
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: StatusIndicatorProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-sm text-muted-foreground ${className}`}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to "open" if no status
|
||||
const effectiveKind = statusKind ?? 1630;
|
||||
|
||||
// For patches/PRs, kind 1631 means "merged" not "resolved"
|
||||
const statusText =
|
||||
effectiveKind === 1631 && (eventType === "patch" || eventType === "pr")
|
||||
? "merged"
|
||||
: getStatusType(effectiveKind) || "open";
|
||||
|
||||
const StatusIcon = getStatusIcon(effectiveKind);
|
||||
|
||||
if (variant === "badge") {
|
||||
const badgeClasses = getStatusBadgeClasses(effectiveKind);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex w-fit items-center gap-1.5 px-2 py-1 text-xs font-medium border rounded-sm ${badgeClasses} ${className}`}
|
||||
>
|
||||
<StatusIcon className="size-3.5" />
|
||||
<span className="capitalize">{statusText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline variant (default)
|
||||
const colorClass = getStatusColorClass(effectiveKind);
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${className}`}>
|
||||
<StatusIcon className={`size-3 ${colorClass}`} />
|
||||
<span className={colorClass}>{statusText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export utilities for use in feed renderers that need just the icon/color
|
||||
export { getStatusIcon, getStatusColorClass, getStatusBadgeClasses };
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { UserName } from "../UserName";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
@@ -26,14 +27,8 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return rTag?.[1] || null;
|
||||
}, [event]);
|
||||
|
||||
// Format published date
|
||||
const publishedDate = published
|
||||
? new Date(published * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
// Format published date using locale utility
|
||||
const publishedDate = published ? formatTimestamp(published, "long") : null;
|
||||
|
||||
// Resolve article image URL
|
||||
const resolvedImageUrl = useMemo(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { toast } from "sonner";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
@@ -23,15 +24,8 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return getTagValue(event, "r");
|
||||
}, [event]);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
// Copy functionality
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { UserName } from "../UserName";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { RichText } from "../RichText";
|
||||
|
||||
/**
|
||||
@@ -29,15 +30,8 @@ export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const eventPointer = getHighlightSourceEventPointer(event);
|
||||
const addressPointer = getHighlightSourceAddressPointer(event);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
// Create synthetic event for comment rendering (preserves emoji tags)
|
||||
const commentEvent = comment
|
||||
|
||||
@@ -7,35 +7,123 @@ 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 { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1621 - Issue (NIP-34)
|
||||
* Full view with repository context and markdown description
|
||||
*/
|
||||
export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const title = useMemo(() => getIssueTitle(event), [event]);
|
||||
const labels = useMemo(() => getIssueLabels(event), [event]);
|
||||
const repoAddress = useMemo(() => getIssueRepositoryAddress(event), [event]);
|
||||
const title = getIssueTitle(event);
|
||||
const labels = getIssueLabels(event);
|
||||
const repoAddress = getIssueRepositoryAddress(event);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks:
|
||||
// 1. Repository configured relays
|
||||
// 2. Repo author's outbox (write) relays
|
||||
// 3. AGGREGATOR_RELAYS as final fallback
|
||||
const statusRelays = useMemo(() => {
|
||||
// Try repository relays first
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
|
||||
// Try repo author's outbox relays
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events that reference this issue
|
||||
// Status events use e tag with root marker to reference the issue
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents, loading: statusLoading } = useTimeline(
|
||||
`issue-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 20 },
|
||||
);
|
||||
|
||||
// Get valid status authors (issue author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Issue Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
<header className="flex flex-col gap-3 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{title || "Untitled Issue"}</h1>
|
||||
<h1 className="text-2xl font-bold">{title || "Untitled Issue"}</h1>
|
||||
|
||||
{/* Status Badge (below title) */}
|
||||
<StatusIndicator
|
||||
statusKind={currentStatus?.kind}
|
||||
loading={statusLoading}
|
||||
eventType="issue"
|
||||
variant="badge"
|
||||
/>
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
@@ -80,6 +168,30 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
(No description provided)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status History (if there are status events) */}
|
||||
{currentStatus && (
|
||||
<section className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Last Status Update
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserName pubkey={currentStatus.pubkey} />
|
||||
<span className="text-muted-foreground">
|
||||
{getStatusType(currentStatus.kind) || "updated"} this issue
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<time className="text-muted-foreground">
|
||||
{formatTimestamp(currentStatus.created_at, "date")}
|
||||
</time>
|
||||
</div>
|
||||
{currentStatus.content && (
|
||||
<div className="text-sm mt-1">
|
||||
<MarkdownContent content={currentStatus.content} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
@@ -7,47 +8,128 @@ 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";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1621 - Issue
|
||||
* Displays as a compact issue card in feed view
|
||||
* Displays as a compact issue card in feed view with status
|
||||
*/
|
||||
export function IssueRenderer({ event }: BaseEventProps) {
|
||||
const title = getIssueTitle(event);
|
||||
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]);
|
||||
|
||||
// Fetch status events that reference this issue
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents } = useTimeline(
|
||||
`issue-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
// Get valid status authors (issue author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Issue Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</ClickableEventTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && (
|
||||
<div className="text-xs line-clamp-1">
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Status */}
|
||||
<StatusIndicator statusKind={currentStatus?.kind} eventType="issue" />
|
||||
|
||||
{/* Repository */}
|
||||
{repoAddress && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div
|
||||
className="flex
|
||||
flex-wrap
|
||||
line-clamp-2
|
||||
items-center gap-1 overflow-x-scroll my-1"
|
||||
>
|
||||
<div className="flex flex-wrap line-clamp-2 items-center gap-1 overflow-x-scroll mt-1">
|
||||
{labels.map((label, idx) => (
|
||||
<Label key={idx}>{label}</Label>
|
||||
))}
|
||||
|
||||
149
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal file
149
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { CircleDot, CheckCircle2, XCircle, FileEdit } from "lucide-react";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import {
|
||||
getStatusRootEventId,
|
||||
getStatusRootRelayHint,
|
||||
getStatusRepositoryAddress,
|
||||
getStatusLabel,
|
||||
getStatusType,
|
||||
} from "@/lib/nip34-helpers";
|
||||
|
||||
/**
|
||||
* Get the icon for a status kind
|
||||
*/
|
||||
function getStatusIcon(kind: number) {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return CircleDot;
|
||||
case 1631:
|
||||
return CheckCircle2;
|
||||
case 1632:
|
||||
return XCircle;
|
||||
case 1633:
|
||||
return FileEdit;
|
||||
default:
|
||||
return CircleDot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color classes for a status badge
|
||||
* Uses theme semantic colors
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open - neutral
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
case 1631: // Resolved/Merged - positive
|
||||
return "bg-accent/20 text-accent border-accent/30";
|
||||
case 1632: // Closed - negative
|
||||
return "bg-destructive/20 text-destructive border-destructive/30";
|
||||
case 1633: // Draft - muted
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted/50 text-foreground border-border";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1630-1633 - Issue/Patch/PR Status Events
|
||||
* Full view with status info, referenced event, and optional comment
|
||||
*/
|
||||
export function IssueStatusDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const rootEventId = getStatusRootEventId(event);
|
||||
const relayHint = getStatusRootRelayHint(event);
|
||||
const repoAddress = getStatusRepositoryAddress(event);
|
||||
const statusLabel = getStatusLabel(event.kind);
|
||||
const statusType = getStatusType(event.kind);
|
||||
|
||||
const StatusIcon = getStatusIcon(event.kind);
|
||||
const badgeClasses = getStatusBadgeClasses(event.kind);
|
||||
|
||||
// Build event pointer with relay hint if available
|
||||
const eventPointer: EventPointer | undefined = rootEventId
|
||||
? {
|
||||
id: rootEventId,
|
||||
relays: relayHint ? [relayHint] : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "datetime");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Status Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium border ${badgeClasses}`}
|
||||
>
|
||||
<StatusIcon className="size-4" />
|
||||
<span className="capitalize">{statusType || statusLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold">Status 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>
|
||||
|
||||
{/* Comment/Reason (if any) */}
|
||||
{event.content && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Comment</h2>
|
||||
<MarkdownContent content={event.content} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Referenced Event */}
|
||||
{eventPointer && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">Referenced Event</h2>
|
||||
<EmbeddedEvent
|
||||
eventPointer={eventPointer}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
className="border border-muted rounded overflow-hidden"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal file
72
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { StatusIndicator } from "../StatusIndicator";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
getStatusRootEventId,
|
||||
getStatusRootRelayHint,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1630-1633 - Issue/Patch/PR Status Events
|
||||
* Displays status action with embedded reference to the issue/patch/PR
|
||||
*/
|
||||
export function IssueStatusRenderer({ event }: BaseEventProps) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
const rootEventId = getStatusRootEventId(event);
|
||||
const relayHint = getStatusRootRelayHint(event);
|
||||
|
||||
// Build event pointer with relay hint if available
|
||||
const eventPointer: EventPointer | undefined = rootEventId
|
||||
? {
|
||||
id: rootEventId,
|
||||
relays: relayHint ? [relayHint] : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Status action header */}
|
||||
<ClickableEventTitle event={event}>
|
||||
<StatusIndicator statusKind={event.kind} eventType="issue" />
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Optional comment from the status event */}
|
||||
{event.content && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
<MarkdownContent content={event.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedded referenced issue/patch/PR */}
|
||||
{eventPointer && (
|
||||
<EmbeddedEvent
|
||||
eventPointer={eventPointer}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
className="border border-muted rounded overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Export aliases for each status kind
|
||||
export { IssueStatusRenderer as Kind1630Renderer };
|
||||
export { IssueStatusRenderer as Kind1631Renderer };
|
||||
export { IssueStatusRenderer as Kind1632Renderer };
|
||||
export { IssueStatusRenderer as Kind1633Renderer };
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { GitCommit, User, Copy, CopyCheck } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
@@ -13,56 +15,130 @@ 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";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1617 - Patch
|
||||
* Displays full patch metadata and content
|
||||
* Displays full patch metadata and content with status
|
||||
*/
|
||||
export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
const subject = useMemo(() => getPatchSubject(event), [event]);
|
||||
const commitId = useMemo(() => getPatchCommitId(event), [event]);
|
||||
const parentCommit = useMemo(() => getPatchParentCommit(event), [event]);
|
||||
const committer = useMemo(() => getPatchCommitter(event), [event]);
|
||||
const repoAddress = useMemo(() => getPatchRepositoryAddress(event), [event]);
|
||||
const isRoot = useMemo(() => isPatchRoot(event), [event]);
|
||||
const isRootRevision = useMemo(() => isPatchRootRevision(event), [event]);
|
||||
const subject = getPatchSubject(event);
|
||||
const commitId = getPatchCommitId(event);
|
||||
const parentCommit = getPatchParentCommit(event);
|
||||
const committer = getPatchCommitter(event);
|
||||
const repoAddress = getPatchRepositoryAddress(event);
|
||||
const isRoot = isPatchRoot(event);
|
||||
const isRootRevision = isPatchRootRevision(event);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks
|
||||
const statusRelays = useMemo(() => {
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents, loading: statusLoading } = useTimeline(
|
||||
`patch-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 20 },
|
||||
);
|
||||
|
||||
// Get valid status authors
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* Patch Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
<header className="flex flex-col gap-3 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{subject || "Untitled Patch"}</h1>
|
||||
<h1 className="text-2xl font-bold">{subject || "Untitled Patch"}</h1>
|
||||
|
||||
{/* Status Badges */}
|
||||
{(isRoot || isRootRevision) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isRoot && (
|
||||
<span className="px-3 py-1 bg-accent/20 text-accent text-sm border border-accent/30">
|
||||
Root Patch
|
||||
</span>
|
||||
)}
|
||||
{isRootRevision && (
|
||||
<span className="px-3 py-1 bg-primary/20 text-primary text-sm border border-primary/30">
|
||||
Root Revision
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Status and Root badges (below title) */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StatusIndicator
|
||||
statusKind={currentStatus?.kind}
|
||||
loading={statusLoading}
|
||||
eventType="patch"
|
||||
variant="badge"
|
||||
/>
|
||||
{isRoot && (
|
||||
<span className="px-2 py-1 bg-accent/20 text-accent text-xs border border-accent/30 rounded-sm">
|
||||
Root Patch
|
||||
</span>
|
||||
)}
|
||||
{isRootRevision && (
|
||||
<span className="px-2 py-1 bg-primary/20 text-primary text-xs border border-primary/30 rounded-sm">
|
||||
Root Revision
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
@@ -175,6 +251,33 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Status History */}
|
||||
{currentStatus && (
|
||||
<section className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Last Status Update
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserName pubkey={currentStatus.pubkey} />
|
||||
<span className="text-muted-foreground">
|
||||
{currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind) || "updated"}{" "}
|
||||
this patch
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<time className="text-muted-foreground">
|
||||
{formatTimestamp(currentStatus.created_at, "date")}
|
||||
</time>
|
||||
</div>
|
||||
{currentStatus.content && (
|
||||
<div className="text-sm mt-1">
|
||||
<MarkdownContent content={currentStatus.content} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
@@ -7,12 +8,21 @@ 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";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1617 - Patch
|
||||
* Displays as a compact patch card in feed view
|
||||
* Displays as a compact patch card in feed view with status
|
||||
*/
|
||||
export function PatchRenderer({ event }: BaseEventProps) {
|
||||
const subject = getPatchSubject(event);
|
||||
@@ -22,10 +32,85 @@ export function PatchRenderer({ event }: BaseEventProps) {
|
||||
// Shorten commit ID for display
|
||||
const shortCommitId = commitId ? commitId.slice(0, 7) : undefined;
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks:
|
||||
// 1. Repository configured relays
|
||||
// 2. Repo author's outbox (write) relays
|
||||
// 3. AGGREGATOR_RELAYS as final fallback
|
||||
const statusRelays = useMemo(() => {
|
||||
// Try repository relays first
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
|
||||
// Try repo author's outbox relays
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events that reference this patch
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents } = useTimeline(
|
||||
`patch-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
// Get valid status authors (patch author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Patch Subject */}
|
||||
{/* Subject/Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
@@ -33,11 +118,15 @@ export function PatchRenderer({ event }: BaseEventProps) {
|
||||
{subject || "Untitled Patch"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Metadata */}
|
||||
{/* Status and Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>in</span>
|
||||
{/* Repository */}
|
||||
{repoAddress && <RepositoryLink repoAddress={repoAddress} />}
|
||||
<StatusIndicator statusKind={currentStatus?.kind} eventType="patch" />
|
||||
{repoAddress && (
|
||||
<>
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Commit ID */}
|
||||
{shortCommitId && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GitBranch, Tag, Copy, CopyCheck } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getPullRequestSubject,
|
||||
@@ -12,47 +13,122 @@ 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";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 1618 - Pull Request
|
||||
* Displays full PR content with markdown rendering
|
||||
* Displays full PR content with markdown rendering and status
|
||||
*/
|
||||
export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
const subject = useMemo(() => getPullRequestSubject(event), [event]);
|
||||
const labels = useMemo(() => getPullRequestLabels(event), [event]);
|
||||
const commitId = useMemo(() => getPullRequestCommitId(event), [event]);
|
||||
const branchName = useMemo(() => getPullRequestBranchName(event), [event]);
|
||||
const cloneUrls = useMemo(() => getPullRequestCloneUrls(event), [event]);
|
||||
const mergeBase = useMemo(() => getPullRequestMergeBase(event), [event]);
|
||||
const repoAddress = useMemo(
|
||||
() => getPullRequestRepositoryAddress(event),
|
||||
[event],
|
||||
const subject = getPullRequestSubject(event);
|
||||
const labels = getPullRequestLabels(event);
|
||||
const commitId = getPullRequestCommitId(event);
|
||||
const branchName = getPullRequestBranchName(event);
|
||||
const cloneUrls = getPullRequestCloneUrls(event);
|
||||
const mergeBase = getPullRequestMergeBase(event);
|
||||
const repoAddress = getPullRequestRepositoryAddress(event);
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks
|
||||
const statusRelays = useMemo(() => {
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents, loading: statusLoading } = useTimeline(
|
||||
`pr-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 20 },
|
||||
);
|
||||
|
||||
// Get valid status authors
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
{/* PR Header */}
|
||||
<header className="flex flex-col gap-4 pb-4 border-b border-border">
|
||||
<header className="flex flex-col gap-3 pb-4 border-b border-border">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{subject || "Untitled Pull Request"}
|
||||
</h1>
|
||||
|
||||
{/* Status Badge (below title) */}
|
||||
<StatusIndicator
|
||||
statusKind={currentStatus?.kind}
|
||||
loading={statusLoading}
|
||||
eventType="pr"
|
||||
variant="badge"
|
||||
/>
|
||||
|
||||
{/* Repository Link */}
|
||||
{repoAddress && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
@@ -201,6 +277,33 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
(No description provided)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Status History */}
|
||||
{currentStatus && (
|
||||
<section className="flex flex-col gap-2 pt-4 border-t border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Last Status Update
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<UserName pubkey={currentStatus.pubkey} />
|
||||
<span className="text-muted-foreground">
|
||||
{currentStatus.kind === 1631
|
||||
? "merged"
|
||||
: getStatusType(currentStatus.kind) || "updated"}{" "}
|
||||
this pull request
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<time className="text-muted-foreground">
|
||||
{formatTimestamp(currentStatus.created_at, "date")}
|
||||
</time>
|
||||
</div>
|
||||
{currentStatus.content && (
|
||||
<div className="text-sm mt-1">
|
||||
<MarkdownContent content={currentStatus.content} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import {
|
||||
getPullRequestSubject,
|
||||
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";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 1618 - Pull Request
|
||||
* Displays as a compact PR card in feed view
|
||||
* Displays as a compact PR card in feed view with status
|
||||
*/
|
||||
export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
const subject = getPullRequestSubject(event);
|
||||
@@ -23,6 +33,81 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
const branchName = getPullRequestBranchName(event);
|
||||
const repoAddress = getPullRequestRepositoryAddress(event);
|
||||
|
||||
// Parse repository address for fetching repo event
|
||||
const parsedRepo = useMemo(
|
||||
() => (repoAddress ? parseReplaceableAddress(repoAddress) : null),
|
||||
[repoAddress],
|
||||
);
|
||||
|
||||
// Fetch repository event to get maintainers list
|
||||
const repoPointer = useMemo(() => {
|
||||
if (!parsedRepo) return undefined;
|
||||
return {
|
||||
kind: parsedRepo.kind,
|
||||
pubkey: parsedRepo.pubkey,
|
||||
identifier: parsedRepo.identifier,
|
||||
};
|
||||
}, [parsedRepo]);
|
||||
|
||||
const repositoryEvent = useNostrEvent(repoPointer);
|
||||
|
||||
// Fetch repo author's relay list for fallback
|
||||
const repoAuthorRelayListPointer = useMemo(() => {
|
||||
if (!parsedRepo?.pubkey) return undefined;
|
||||
return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" };
|
||||
}, [parsedRepo?.pubkey]);
|
||||
|
||||
const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer);
|
||||
|
||||
// Build relay list with fallbacks:
|
||||
// 1. Repository configured relays
|
||||
// 2. Repo author's outbox (write) relays
|
||||
// 3. AGGREGATOR_RELAYS as final fallback
|
||||
const statusRelays = useMemo(() => {
|
||||
// Try repository relays first
|
||||
if (repositoryEvent) {
|
||||
const repoRelays = getRepositoryRelays(repositoryEvent);
|
||||
if (repoRelays.length > 0) return repoRelays;
|
||||
}
|
||||
|
||||
// Try repo author's outbox relays
|
||||
if (repoAuthorRelayList) {
|
||||
const authorOutbox = getOutboxes(repoAuthorRelayList);
|
||||
if (authorOutbox.length > 0) return authorOutbox;
|
||||
}
|
||||
|
||||
// Fallback to aggregator relays
|
||||
return AGGREGATOR_RELAYS;
|
||||
}, [repositoryEvent, repoAuthorRelayList]);
|
||||
|
||||
// Fetch status events that reference this PR
|
||||
const statusFilter = useMemo(
|
||||
() => ({
|
||||
kinds: [1630, 1631, 1632, 1633],
|
||||
"#e": [event.id],
|
||||
}),
|
||||
[event.id],
|
||||
);
|
||||
|
||||
const { events: statusEvents } = useTimeline(
|
||||
`pr-status-${event.id}`,
|
||||
statusFilter,
|
||||
statusRelays,
|
||||
{ limit: 10 },
|
||||
);
|
||||
|
||||
// Get valid status authors (PR author + repo owner + maintainers)
|
||||
const validAuthors = useMemo(
|
||||
() => getValidStatusAuthors(event, repositoryEvent),
|
||||
[event, repositoryEvent],
|
||||
);
|
||||
|
||||
// Get the most recent valid status event
|
||||
const currentStatus = useMemo(
|
||||
() => findCurrentStatus(statusEvents, validAuthors),
|
||||
[statusEvents, validAuthors],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -35,13 +120,19 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
</ClickableEventTitle>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Repository */}
|
||||
{repoAddress && (
|
||||
<RepositoryLink
|
||||
repoAddress={repoAddress}
|
||||
className="truncate line-clamp-1 text-xs"
|
||||
/>
|
||||
)}
|
||||
{/* Status and Repository */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<StatusIndicator statusKind={currentStatus?.kind} eventType="pr" />
|
||||
{repoAddress && (
|
||||
<>
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink
|
||||
repoAddress={repoAddress}
|
||||
className="truncate line-clamp-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Branch Name */}
|
||||
{branchName && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Kind1337Renderer } from "./CodeSnippetRenderer";
|
||||
import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer";
|
||||
import { IssueRenderer } from "./IssueRenderer";
|
||||
import { IssueDetailRenderer } from "./IssueDetailRenderer";
|
||||
import { IssueStatusRenderer } from "./IssueStatusRenderer";
|
||||
import { IssueStatusDetailRenderer } from "./IssueStatusDetailRenderer";
|
||||
import { PatchRenderer } from "./PatchRenderer";
|
||||
import { PatchDetailRenderer } from "./PatchDetailRenderer";
|
||||
import { PullRequestRenderer } from "./PullRequestRenderer";
|
||||
@@ -188,6 +190,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
1617: PatchRenderer, // Patch (NIP-34)
|
||||
1618: PullRequestRenderer, // Pull Request (NIP-34)
|
||||
1621: IssueRenderer, // Issue (NIP-34)
|
||||
1630: IssueStatusRenderer, // Open Status (NIP-34)
|
||||
1631: IssueStatusRenderer, // Applied/Merged/Resolved Status (NIP-34)
|
||||
1632: IssueStatusRenderer, // Closed Status (NIP-34)
|
||||
1633: IssueStatusRenderer, // Draft Status (NIP-34)
|
||||
1984: ReportRenderer, // Report (NIP-56)
|
||||
9041: GoalRenderer, // Zap Goal (NIP-75)
|
||||
9735: Kind9735Renderer, // Zap Receipt
|
||||
@@ -298,6 +304,10 @@ const detailRenderers: Record<
|
||||
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
|
||||
1618: PullRequestDetailRenderer, // Pull Request 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)
|
||||
1632: IssueStatusDetailRenderer, // Closed Status Detail (NIP-34)
|
||||
1633: IssueStatusDetailRenderer, // Draft Status Detail (NIP-34)
|
||||
1984: ReportDetailRenderer, // Report Detail (NIP-56)
|
||||
9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75)
|
||||
9802: Kind9802DetailRenderer, // Highlight Detail
|
||||
|
||||
Reference in New Issue
Block a user