diff --git a/CLAUDE.md b/CLAUDE.md index b68749b..8c0449a 100644 --- a/CLAUDE.md +++ b/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 diff --git a/src/components/nostr/kinds/ArticleDetailRenderer.tsx b/src/components/nostr/kinds/ArticleDetailRenderer.tsx index 3e0a705..7211524 100644 --- a/src/components/nostr/kinds/ArticleDetailRenderer.tsx +++ b/src/components/nostr/kinds/ArticleDetailRenderer.tsx @@ -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(() => { diff --git a/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx index 036ce81..9073007 100644 --- a/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx +++ b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx @@ -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(); diff --git a/src/components/nostr/kinds/HighlightDetailRenderer.tsx b/src/components/nostr/kinds/HighlightDetailRenderer.tsx index de5f454..b0a08e0 100644 --- a/src/components/nostr/kinds/HighlightDetailRenderer.tsx +++ b/src/components/nostr/kinds/HighlightDetailRenderer.tsx @@ -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 diff --git a/src/components/nostr/kinds/IssueDetailRenderer.tsx b/src/components/nostr/kinds/IssueDetailRenderer.tsx index e55d205..2879205 100644 --- a/src/components/nostr/kinds/IssueDetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -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 (
{/* Issue Header */}
+ {/* Status Badge */} +
+ {statusLoading ? ( + + + Loading status... + + ) : ( + + + {statusType || "open"} + + )} +
+ {/* Title */}

{title || "Untitled Issue"}

@@ -80,6 +197,30 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) { (No description provided)

)} + + {/* Status History (if there are status events) */} + {currentStatus && ( +
+

+ Last Status Update +

+
+ + + {getStatusType(currentStatus.kind) || "updated"} this issue + + + +
+ {currentStatus.content && ( +

+ {currentStatus.content} +

+ )} +
+ )}
); } diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index cb173ca..bf1eafc 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -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 (
- {/* Issue Title */} - - {title || "Untitled Issue"} - + {/* Status and Title */} +
+ + + {title || "Untitled Issue"} + +
- {/* Repository Reference */} - {repoAddress && ( -
- -
- )} + {/* Status label (compact) */} +
+ {statusType} + {repoAddress && ( + <> + in + + + )} +
{/* Labels */} diff --git a/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx b/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx new file mode 100644 index 0000000..7411496 --- /dev/null +++ b/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx @@ -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 ( +
+ {/* Status Header */} +
+ {/* Status Badge */} +
+ + + {statusType || statusLabel} + +
+ + {/* Title */} +

Status Update

+ + {/* Repository Link */} + {repoAddress && ( +
+ Repository: + +
+ )} + + {/* Metadata */} +
+
+ By + +
+ + +
+
+ + {/* Comment/Reason (if any) */} + {event.content && ( +
+

Comment

+ +
+ )} + + {/* Referenced Event */} + {eventPointer && ( +
+

Referenced Event

+ { + addWindow( + "open", + { id: id as string }, + `Event ${(id as string).slice(0, 8)}...`, + ); + }} + className="border border-muted rounded overflow-hidden" + /> +
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/IssueStatusRenderer.tsx b/src/components/nostr/kinds/IssueStatusRenderer.tsx new file mode 100644 index 0000000..0db5b3f --- /dev/null +++ b/src/components/nostr/kinds/IssueStatusRenderer.tsx @@ -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 ( + +
+ {/* Status action header */} +
+ + + {statusLabel} + +
+ + {/* Optional comment from the status event */} + {event.content && ( +

+ {event.content} +

+ )} + + {/* Embedded referenced issue/patch/PR */} + {eventPointer && ( + { + addWindow( + "open", + { id: id as string }, + `Event ${(id as string).slice(0, 8)}...`, + ); + }} + className="border border-muted rounded overflow-hidden" + /> + )} +
+
+ ); +} + +// Export aliases for each status kind +export { IssueStatusRenderer as Kind1630Renderer }; +export { IssueStatusRenderer as Kind1631Renderer }; +export { IssueStatusRenderer as Kind1632Renderer }; +export { IssueStatusRenderer as Kind1633Renderer }; diff --git a/src/components/nostr/kinds/PatchDetailRenderer.tsx b/src/components/nostr/kinds/PatchDetailRenderer.tsx index 80ca4b5..8e32196 100644 --- a/src/components/nostr/kinds/PatchDetailRenderer.tsx +++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx @@ -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 (
diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx index 5322573..89027b1 100644 --- a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx @@ -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 (
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 9ce8574..5c3cf4d 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -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> = { 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) diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts index cdd0ce6..f7b7fce 100644 --- a/src/hooks/useLocale.ts +++ b/src/hooks/useLocale.ts @@ -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", diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index cac5ec7..21824fe 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -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 = { + 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 { + const validPubkeys = new Set(); + + // 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, +): 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]; +}