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 (
+ {currentStatus.content} +
+ )} ++ {event.content} +
+ )} + + {/* Embedded referenced issue/patch/PR */} + {eventPointer && ( +