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:
Claude
2026-01-23 10:51:44 +00:00
parent 7838b0ab98
commit b0f62da8f2
13 changed files with 771 additions and 72 deletions

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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 */}

View 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>
);
}

View 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 };

View File

@@ -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">

View File

@@ -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">

View File

@@ -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)

View File

@@ -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",

View File

@@ -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];
}