mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 17:19:27 +02:00
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
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -237,6 +237,18 @@ if (canSign) {
|
||||
- **Path Alias**: `@/` = `./src/`
|
||||
- **Styling**: Tailwind + HSL CSS variables (theme tokens defined in `index.css`)
|
||||
- **Types**: Prefer types from `applesauce-core`, extend in `src/types/` when needed
|
||||
- **Locale-Aware Formatting** (`src/hooks/useLocale.ts`): All date, time, number, and currency formatting MUST use the user's locale:
|
||||
- **`useLocale()` hook**: Returns `{ locale, language, region, timezone, timeFormat }` - use in components that need locale config
|
||||
- **`formatTimestamp(timestamp, style)`**: Preferred utility for all timestamp formatting:
|
||||
- `"relative"` → "2h ago", "3d ago"
|
||||
- `"long"` → "January 15, 2025" (human-readable date)
|
||||
- `"date"` → "01/15/2025" (short date)
|
||||
- `"datetime"` → "January 15, 2025, 2:30 PM" (date with time)
|
||||
- `"absolute"` → "2025-01-15 14:30" (ISO-8601 style)
|
||||
- `"time"` → "14:30"
|
||||
- Use `Intl.NumberFormat` for numbers and currencies
|
||||
- NEVER hardcode locale strings like "en-US" or date formats like "MM/DD/YYYY"
|
||||
- Example: `formatTimestamp(event.created_at, "long")` instead of manual `toLocaleDateString()`
|
||||
- **File Organization**: By domain (`nostr/`, `ui/`, `services/`, `hooks/`, `lib/`)
|
||||
- **State Logic**: All UI state mutations go through `src/core/logic.ts` pure functions
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tag } from "lucide-react";
|
||||
import {
|
||||
Tag,
|
||||
CircleDot,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
FileEdit,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
@@ -7,33 +14,143 @@ import {
|
||||
getIssueTitle,
|
||||
getIssueLabels,
|
||||
getIssueRepositoryAddress,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open
|
||||
return "bg-green-500/20 text-green-500 border-green-500/30";
|
||||
case 1631: // Resolved/Merged
|
||||
return "bg-purple-500/20 text-purple-500 border-purple-500/30";
|
||||
case 1632: // Closed
|
||||
return "bg-red-500/20 text-red-500 border-red-500/30";
|
||||
case 1633: // Draft
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
AGGREGATOR_RELAYS,
|
||||
{ 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");
|
||||
|
||||
// Get status display info
|
||||
const statusType = currentStatus ? getStatusType(currentStatus.kind) : null;
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusBadgeClasses = currentStatus
|
||||
? getStatusBadgeClasses(currentStatus.kind)
|
||||
: "bg-green-500/20 text-green-500 border-green-500/30";
|
||||
|
||||
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">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
{statusLoading ? (
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading status...</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium border ${statusBadgeClasses}`}
|
||||
>
|
||||
<StatusIcon className="size-4" />
|
||||
<span className="capitalize">{statusType || "open"}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{title || "Untitled Issue"}</h1>
|
||||
|
||||
@@ -80,6 +197,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 && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentStatus.content}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { CircleDot, CheckCircle2, XCircle, FileEdit } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
@@ -7,37 +9,144 @@ import {
|
||||
getIssueTitle,
|
||||
getIssueLabels,
|
||||
getIssueRepositoryAddress,
|
||||
getStatusType,
|
||||
getValidStatusAuthors,
|
||||
findCurrentStatus,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RepositoryLink } from "../RepositoryLink";
|
||||
import { useTimeline } from "@/hooks/useTimeline";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
|
||||
/**
|
||||
* 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 class for a status kind
|
||||
*/
|
||||
function getStatusColorClass(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open
|
||||
return "text-green-500";
|
||||
case 1631: // Resolved/Merged
|
||||
return "text-purple-500";
|
||||
case 1632: // Closed
|
||||
return "text-red-500";
|
||||
case 1633: // Draft
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-green-500";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
AGGREGATOR_RELAYS,
|
||||
{ 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],
|
||||
);
|
||||
|
||||
// Status display
|
||||
const statusType = currentStatus ? getStatusType(currentStatus.kind) : "open";
|
||||
const StatusIcon = currentStatus
|
||||
? getStatusIcon(currentStatus.kind)
|
||||
: CircleDot;
|
||||
const statusColorClass = currentStatus
|
||||
? getStatusColorClass(currentStatus.kind)
|
||||
: "text-green-500";
|
||||
|
||||
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>
|
||||
{/* Status and Title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon
|
||||
className={`size-4 flex-shrink-0 ${statusColorClass}`}
|
||||
/>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && (
|
||||
<div className="text-xs line-clamp-1">
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</div>
|
||||
)}
|
||||
{/* Status label (compact) */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={statusColorClass}>{statusType}</span>
|
||||
{repoAddress && (
|
||||
<>
|
||||
<span className="text-muted-foreground">in</span>
|
||||
<RepositoryLink repoAddress={repoAddress} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
|
||||
148
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal file
148
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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
|
||||
*/
|
||||
function getStatusBadgeClasses(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open
|
||||
return "bg-green-500/20 text-green-500 border-green-500/30";
|
||||
case 1631: // Resolved/Merged
|
||||
return "bg-purple-500/20 text-purple-500 border-purple-500/30";
|
||||
case 1632: // Closed
|
||||
return "bg-red-500/20 text-red-500 border-red-500/30";
|
||||
case 1633: // Draft
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-muted-foreground/30";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
115
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal file
115
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { CircleDot, CheckCircle2, XCircle, FileEdit } from "lucide-react";
|
||||
import {
|
||||
BaseEventContainer,
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
getStatusRootEventId,
|
||||
getStatusRootRelayHint,
|
||||
getStatusLabel,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import type { EventPointer } from "nostr-tools/nip19";
|
||||
|
||||
/**
|
||||
* 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 kind
|
||||
*/
|
||||
function getStatusColorClass(kind: number): string {
|
||||
switch (kind) {
|
||||
case 1630: // Open
|
||||
return "text-green-500";
|
||||
case 1631: // Resolved/Merged
|
||||
return "text-purple-500";
|
||||
case 1632: // Closed
|
||||
return "text-red-500";
|
||||
case 1633: // Draft
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const statusLabel = getStatusLabel(event.kind);
|
||||
|
||||
const StatusIcon = getStatusIcon(event.kind);
|
||||
const colorClass = getStatusColorClass(event.kind);
|
||||
|
||||
// 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-3">
|
||||
{/* Status action header */}
|
||||
<div className={`flex items-center gap-2 text-sm ${colorClass}`}>
|
||||
<StatusIcon className="size-4" />
|
||||
<ClickableEventTitle event={event}>
|
||||
<span>{statusLabel}</span>
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
|
||||
{/* Optional comment from the status event */}
|
||||
{event.content && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{event.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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 };
|
||||
@@ -3,6 +3,7 @@ import { GitCommit, User, Copy, CopyCheck } from "lucide-react";
|
||||
import { UserName } from "../UserName";
|
||||
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 {
|
||||
@@ -31,15 +32,8 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const isRoot = useMemo(() => isPatchRoot(event), [event]);
|
||||
const isRootRevision = useMemo(() => isPatchRootRevision(event), [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");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
|
||||
@@ -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,
|
||||
@@ -34,15 +35,8 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
[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");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
|
||||
|
||||
@@ -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";
|
||||
@@ -187,6 +189,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)
|
||||
9041: GoalRenderer, // Zap Goal (NIP-75)
|
||||
9735: Kind9735Renderer, // Zap Receipt
|
||||
9802: Kind9802Renderer, // Highlight
|
||||
@@ -296,6 +302,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)
|
||||
9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75)
|
||||
9802: Kind9802DetailRenderer, // Highlight Detail
|
||||
8000: AddUserDetailRenderer, // Add User Detail (NIP-43)
|
||||
|
||||
@@ -50,11 +50,20 @@ export function useLocale(): LocaleConfig {
|
||||
/**
|
||||
* Format a timestamp according to locale preferences
|
||||
* @param timestamp - Unix timestamp in seconds
|
||||
* @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only, 'time' for time only
|
||||
* @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only,
|
||||
* 'long' for full readable date (e.g., "January 15, 2025"), 'time' for time only,
|
||||
* 'datetime' for date with time (e.g., "January 15, 2025, 2:30 PM")
|
||||
* @param locale - Optional locale override (defaults to browser locale)
|
||||
*/
|
||||
export function formatTimestamp(
|
||||
timestamp: number,
|
||||
style: "relative" | "absolute" | "date" | "time" = "relative",
|
||||
style:
|
||||
| "relative"
|
||||
| "absolute"
|
||||
| "date"
|
||||
| "long"
|
||||
| "time"
|
||||
| "datetime" = "relative",
|
||||
locale?: string,
|
||||
): string {
|
||||
const browserLocale = locale || navigator.language || "en-US";
|
||||
@@ -102,6 +111,26 @@ export function formatTimestamp(
|
||||
});
|
||||
}
|
||||
|
||||
if (style === "long") {
|
||||
// Human-readable long format: "January 15, 2025"
|
||||
return date.toLocaleDateString(browserLocale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
if (style === "datetime") {
|
||||
// Full date with time: "January 15, 2025, 2:30 PM"
|
||||
return date.toLocaleString(browserLocale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
if (style === "time") {
|
||||
return date.toLocaleTimeString(browserLocale, {
|
||||
hour: "2-digit",
|
||||
|
||||
@@ -370,3 +370,167 @@ export function getRepositoryStateTags(
|
||||
hash: t[1],
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status Event Helpers (Kind 1630-1633)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Status types for NIP-34 status events
|
||||
*/
|
||||
export type IssueStatusType = "open" | "resolved" | "closed" | "draft";
|
||||
|
||||
/**
|
||||
* Map kind numbers to status types
|
||||
*/
|
||||
export const STATUS_KIND_MAP: Record<number, IssueStatusType> = {
|
||||
1630: "open",
|
||||
1631: "resolved",
|
||||
1632: "closed",
|
||||
1633: "draft",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the status type from a status event kind
|
||||
* @param kind Event kind (1630-1633)
|
||||
* @returns Status type or undefined if not a status kind
|
||||
*/
|
||||
export function getStatusType(kind: number): IssueStatusType | undefined {
|
||||
return STATUS_KIND_MAP[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root event ID being referenced by a status event
|
||||
* The root is the original issue/patch/PR being marked with a status
|
||||
* @param event Status event (kind 1630-1633)
|
||||
* @returns Event ID or undefined
|
||||
*/
|
||||
export function getStatusRootEventId(event: NostrEvent): string | undefined {
|
||||
// Look for e tag with "root" marker
|
||||
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
|
||||
if (rootTag) return rootTag[1];
|
||||
|
||||
// Fallback: first e tag without a marker or with empty marker
|
||||
const firstETag = event.tags.find((t) => t[0] === "e");
|
||||
return firstETag?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relay hint for the root event
|
||||
* @param event Status event (kind 1630-1633)
|
||||
* @returns Relay URL or undefined
|
||||
*/
|
||||
export function getStatusRootRelayHint(event: NostrEvent): string | undefined {
|
||||
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
|
||||
if (rootTag && rootTag[2]) return rootTag[2];
|
||||
|
||||
const firstETag = event.tags.find((t) => t[0] === "e");
|
||||
return firstETag?.[2] || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository address from a status event
|
||||
* @param event Status event (kind 1630-1633)
|
||||
* @returns Repository address (a tag) or undefined
|
||||
*/
|
||||
export function getStatusRepositoryAddress(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "a");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a kind is a status event kind
|
||||
* @param kind Event kind
|
||||
* @returns True if kind is 1630-1633
|
||||
*/
|
||||
export function isStatusKind(kind: number): boolean {
|
||||
return kind >= 1630 && kind <= 1633;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status label
|
||||
* @param kind Event kind (1630-1633)
|
||||
* @param forIssue Whether this is for an issue (vs patch/PR)
|
||||
* @returns Label string
|
||||
*/
|
||||
export function getStatusLabel(kind: number, forIssue = true): string {
|
||||
switch (kind) {
|
||||
case 1630:
|
||||
return "opened";
|
||||
case 1631:
|
||||
return forIssue ? "resolved" : "merged";
|
||||
case 1632:
|
||||
return "closed";
|
||||
case 1633:
|
||||
return "marked as draft";
|
||||
default:
|
||||
return "updated";
|
||||
}
|
||||
}
|
||||
|
||||
// Import parseReplaceableAddress from applesauce-core for address parsing
|
||||
// This parses "kind:pubkey:identifier" format strings into AddressPointer objects
|
||||
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
|
||||
|
||||
/**
|
||||
* Get all valid pubkeys that can set status for an issue/patch/PR
|
||||
* Valid authors: event author, repository owner (from p tag), and all maintainers
|
||||
* @param event Issue, patch, or PR event
|
||||
* @param repositoryEvent Optional repository event to get maintainers from
|
||||
* @returns Set of valid pubkeys
|
||||
*/
|
||||
export function getValidStatusAuthors(
|
||||
event: NostrEvent,
|
||||
repositoryEvent?: NostrEvent,
|
||||
): Set<string> {
|
||||
const validPubkeys = new Set<string>();
|
||||
|
||||
// Event author can always set status
|
||||
validPubkeys.add(event.pubkey);
|
||||
|
||||
// Repository owner from p tag
|
||||
const repoOwner = getTagValue(event, "p");
|
||||
if (repoOwner) validPubkeys.add(repoOwner);
|
||||
|
||||
// Parse repository address to get owner pubkey using applesauce helper
|
||||
const repoAddress =
|
||||
getIssueRepositoryAddress(event) ||
|
||||
getPatchRepositoryAddress(event) ||
|
||||
getPullRequestRepositoryAddress(event);
|
||||
if (repoAddress) {
|
||||
const parsedRepo = parseReplaceableAddress(repoAddress);
|
||||
if (parsedRepo?.pubkey) validPubkeys.add(parsedRepo.pubkey);
|
||||
}
|
||||
|
||||
// Add maintainers from repository event
|
||||
if (repositoryEvent) {
|
||||
const maintainers = getMaintainers(repositoryEvent);
|
||||
maintainers.forEach((m) => validPubkeys.add(m));
|
||||
}
|
||||
|
||||
return validPubkeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent valid status event from a list of status events
|
||||
* Valid = from event author, repository owner, or maintainers
|
||||
* @param statusEvents Array of status events (kinds 1630-1633)
|
||||
* @param validAuthors Set of valid pubkeys (from getValidStatusAuthors)
|
||||
* @returns Most recent valid status event or null
|
||||
*/
|
||||
export function findCurrentStatus(
|
||||
statusEvents: NostrEvent[],
|
||||
validAuthors: Set<string>,
|
||||
): NostrEvent | null {
|
||||
if (statusEvents.length === 0) return null;
|
||||
|
||||
// Sort by created_at descending (most recent first)
|
||||
const sorted = [...statusEvents].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
// Find the most recent status from a valid author
|
||||
const validStatus = sorted.find((s) => validAuthors.has(s.pubkey));
|
||||
|
||||
// Return valid status if found, otherwise most recent (may be invalid but show anyway)
|
||||
return validStatus || sorted[0];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user