diff --git a/CLAUDE.md b/CLAUDE.md index b68749b..c02ed43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,11 +114,12 @@ const text = getHighlightText(event); **Available Helpers** (split between packages in applesauce v5): *From `applesauce-core/helpers` (protocol-level):* -- **Tags**: `getTagValue(event, name)` - get single tag value (searches hidden tags first) +- **Tags**: `getTagValue(event, name)` - get single tag value (returns first match) - **Profile**: `getProfileContent(event)`, `getDisplayName(metadata, fallback)` -- **Pointers**: `parseCoordinate(aTag)`, `getEventPointerFromETag`, `getAddressPointerFromATag`, `getProfilePointerFromPTag` +- **Pointers**: `parseReplaceableAddress(address)` (from `applesauce-core/helpers/pointers`), `getEventPointerFromETag`, `getAddressPointerFromATag`, `getProfilePointerFromPTag` - **Filters**: `isFilterEqual(a, b)`, `matchFilter(filter, event)`, `mergeFilters(...filters)` - **Relays**: `getSeenRelays`, `mergeRelaySets`, `getInboxes`, `getOutboxes` +- **Caching**: `getOrComputeCachedValue(event, symbol, compute)` - cache computed values on event objects - **URL**: `normalizeURL` *From `applesauce-common/helpers` (social/NIP-specific):* @@ -131,18 +132,62 @@ const text = getHighlightText(event); - **Lists**: `getRelaysFromList` **Custom Grimoire Helpers** (not in applesauce): -- `getTagValues(event, name)` - plural version to get array of tag values (src/lib/nostr-utils.ts) +- `getTagValues(event, name)` - get ALL values for a tag name as array (applesauce only has singular `getTagValue`) - `resolveFilterAliases(filter, pubkey, contacts)` - resolves `$me`/`$contacts` aliases (src/lib/nostr-utils.ts) - `getDisplayName(pubkey, metadata)` - enhanced version with pubkey fallback (src/lib/nostr-utils.ts) -- NIP-34 git helpers (src/lib/nip34-helpers.ts) - wraps `getTagValue` for repository, issue, patch metadata +- NIP-34 git helpers (src/lib/nip34-helpers.ts) - uses `getOrComputeCachedValue` for repository, issue, patch metadata - NIP-C0 code snippet helpers (src/lib/nip-c0-helpers.ts) - wraps `getTagValue` for code metadata +**Important**: `getTagValue` vs `getTagValues`: +- `getTagValue(event, "t")` → returns first "t" tag value (string or undefined) - FROM APPLESAUCE +- `getTagValues(event, "t")` → returns ALL "t" tag values (string[]) - GRIMOIRE CUSTOM (src/lib/nostr-utils.ts) + **When to use `useMemo`**: - ✅ Complex transformations not using applesauce helpers (sorting, filtering, mapping) - ✅ Creating objects/arrays for dependency tracking (options, configurations) - ✅ Expensive computations that don't call applesauce helpers - ❌ Direct calls to applesauce helpers (they cache internally) -- ❌ Grimoire helpers that wrap `getTagValue` (caching propagates) +- ❌ Grimoire helpers that use `getOrComputeCachedValue` (they cache internally) + +### Writing Helper Libraries for Nostr Events + +When creating helper functions that compute derived values from Nostr events, **always use `getOrComputeCachedValue`** from applesauce-core to cache results on the event object: + +```typescript +import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "nostr-tools"; + +// Define a unique symbol for caching at module scope +const MyComputedValueSymbol = Symbol("myComputedValue"); + +export function getMyComputedValue(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, MyComputedValueSymbol, () => { + // Expensive computation that iterates over tags, parses content, etc. + return event.tags + .filter((t) => t[0] === "myTag" && t[1]) + .map((t) => t[1]); + }); +} + +// For simple single-value extraction, just use getTagValue (no caching wrapper needed) +export function getMyTitle(event: NostrEvent): string | undefined { + return getTagValue(event, "title"); +} +``` + +**Why this matters**: +- Event objects are often accessed multiple times during rendering +- Without caching, the same computation runs repeatedly (e.g., on every re-render) +- `getOrComputeCachedValue` stores the result on the event object using the symbol as a key +- Subsequent calls return the cached value instantly without recomputation +- Components don't need `useMemo` when calling these helpers + +**Best practices for helper libraries**: +1. Use `getOrComputeCachedValue` for any function that iterates tags, parses content, or does regex matching +2. Define symbols at module scope (not inside functions) for proper caching +3. Simple `getTagValue()` calls don't need additional caching - just call directly +4. For getting ALL values of a tag, use the custom `getTagValues` from `src/lib/nostr-utils.ts` +5. Group related helpers in NIP-specific files (e.g., `nip34-helpers.ts`, `nip88-helpers.ts`) ## Major Hooks @@ -237,6 +282,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/StatusIndicator.tsx b/src/components/nostr/StatusIndicator.tsx new file mode 100644 index 0000000..089fc19 --- /dev/null +++ b/src/components/nostr/StatusIndicator.tsx @@ -0,0 +1,135 @@ +import { + CircleDot, + CheckCircle2, + XCircle, + FileEdit, + Loader2, +} from "lucide-react"; +import { getStatusType } from "@/lib/nip34-helpers"; + +/** + * Get the icon component for a status kind + */ +function getStatusIcon(kind: number) { + switch (kind) { + case 1630: + return CircleDot; + case 1631: + return CheckCircle2; + case 1632: + return XCircle; + case 1633: + return FileEdit; + default: + return CircleDot; + } +} + +/** + * Get the color class for a status kind + * Uses theme semantic colors + */ +function getStatusColorClass(kind: number): string { + switch (kind) { + case 1630: // Open - neutral + return "text-foreground"; + case 1631: // Resolved/Merged - positive + return "text-accent"; + case 1632: // Closed - warning (less aggressive than destructive) + return "text-warning"; + case 1633: // Draft - muted + return "text-muted-foreground"; + default: + return "text-foreground"; + } +} + +/** + * Get the background/border classes for a status badge + * Uses theme semantic colors + */ +function getStatusBadgeClasses(kind: number): string { + switch (kind) { + case 1630: // Open - neutral + return "bg-muted/50 text-foreground border-border"; + case 1631: // Resolved/Merged - positive + return "bg-accent/20 text-accent border-accent/30"; + case 1632: // Closed - warning (less aggressive than destructive) + return "bg-warning/20 text-warning border-warning/30"; + case 1633: // Draft - muted + return "bg-muted text-muted-foreground border-muted-foreground/30"; + default: + return "bg-muted/50 text-foreground border-border"; + } +} + +export interface StatusIndicatorProps { + /** The status event kind (1630-1633) or undefined for default "open" */ + statusKind?: number; + /** Whether status is loading */ + loading?: boolean; + /** Event type for appropriate labeling (affects "resolved" vs "merged") */ + eventType?: "issue" | "patch" | "pr"; + /** Display variant */ + variant?: "inline" | "badge"; + /** Optional custom class */ + className?: string; +} + +/** + * Reusable status indicator for NIP-34 events (issues, patches, PRs) + * Displays status icon and text with appropriate styling + */ +export function StatusIndicator({ + statusKind, + loading = false, + eventType = "issue", + variant = "inline", + className = "", +}: StatusIndicatorProps) { + if (loading) { + return ( + + + Loading... + + ); + } + + // Default to "open" if no status + const effectiveKind = statusKind ?? 1630; + + // For patches/PRs, kind 1631 means "merged" not "resolved" + const statusText = + effectiveKind === 1631 && (eventType === "patch" || eventType === "pr") + ? "merged" + : getStatusType(effectiveKind) || "open"; + + const StatusIcon = getStatusIcon(effectiveKind); + + if (variant === "badge") { + const badgeClasses = getStatusBadgeClasses(effectiveKind); + return ( + + + {statusText} + + ); + } + + // Inline variant (default) + const colorClass = getStatusColorClass(effectiveKind); + return ( + + + {statusText} + + ); +} + +// Re-export utilities for use in feed renderers that need just the icon/color +export { getStatusIcon, getStatusColorClass, getStatusBadgeClasses }; 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..9225f74 100644 --- a/src/components/nostr/kinds/IssueDetailRenderer.tsx +++ b/src/components/nostr/kinds/IssueDetailRenderer.tsx @@ -7,35 +7,123 @@ import { getIssueTitle, getIssueLabels, getIssueRepositoryAddress, + getRepositoryRelays, + getStatusType, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { Label } from "@/components/ui/label"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Detail renderer for Kind 1621 - Issue (NIP-34) * Full view with repository context and markdown description */ export function IssueDetailRenderer({ event }: { event: NostrEvent }) { - const title = useMemo(() => getIssueTitle(event), [event]); - const labels = useMemo(() => getIssueLabels(event), [event]); - const repoAddress = useMemo(() => getIssueRepositoryAddress(event), [event]); + const title = getIssueTitle(event); + const labels = getIssueLabels(event); + const repoAddress = getIssueRepositoryAddress(event); - // Format created date - const createdDate = new Date(event.created_at * 1000).toLocaleDateString( - "en-US", - { - year: "numeric", - month: "long", - day: "numeric", - }, + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], ); + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks: + // 1. Repository configured relays + // 2. Repo author's outbox (write) relays + // 3. AGGREGATOR_RELAYS as final fallback + const statusRelays = useMemo(() => { + // Try repository relays first + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + + // Try repo author's outbox relays + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + + // Fallback to aggregator relays + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events that reference this issue + // Status events use e tag with root marker to reference the issue + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], + ); + + const { events: statusEvents, loading: statusLoading } = useTimeline( + `issue-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 20 }, + ); + + // Get valid status authors (issue author + repo owner + maintainers) + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + + // Format created date using locale utility + const createdDate = formatTimestamp(event.created_at, "long"); + return (
{/* Issue Header */} -
+
{/* Title */} -

{title || "Untitled Issue"}

+

{title || "Untitled Issue"}

+ + {/* Status Badge (below title) */} + {/* Repository Link */} {repoAddress && ( @@ -80,6 +168,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 && ( +
+ +
+ )} +
+ )}
); } diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index cb173ca..6bbeee0 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { BaseEventContainer, type BaseEventProps, @@ -7,47 +8,128 @@ import { getIssueTitle, getIssueLabels, getIssueRepositoryAddress, + getRepositoryRelays, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { Label } from "@/components/ui/label"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Renderer for Kind 1621 - Issue - * Displays as a compact issue card in feed view + * Displays as a compact issue card in feed view with status */ export function IssueRenderer({ event }: BaseEventProps) { const title = getIssueTitle(event); const labels = getIssueLabels(event); const repoAddress = getIssueRepositoryAddress(event); + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], + ); + + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks: + // 1. Repository configured relays + // 2. Repo author's outbox (write) relays + // 3. AGGREGATOR_RELAYS as final fallback + const statusRelays = useMemo(() => { + // Try repository relays first + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + + // Try repo author's outbox relays + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + + // Fallback to aggregator relays + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events that reference this issue + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], + ); + + const { events: statusEvents } = useTimeline( + `issue-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 10 }, + ); + + // Get valid status authors (issue author + repo owner + maintainers) + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + return ( -
-
- {/* Issue Title */} - - {title || "Untitled Issue"} - +
+ {/* Title */} + + {title || "Untitled Issue"} + - {/* Repository Reference */} - {repoAddress && ( -
- -
- )} -
+ {/* Status */} + + + {/* Repository */} + {repoAddress && ( +
+ in + +
+ )} {/* Labels */} {labels.length > 0 && ( -
+
{labels.map((label, idx) => ( ))} diff --git a/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx b/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx new file mode 100644 index 0000000..06b6b52 --- /dev/null +++ b/src/components/nostr/kinds/IssueStatusDetailRenderer.tsx @@ -0,0 +1,149 @@ +import { CircleDot, CheckCircle2, XCircle, FileEdit } from "lucide-react"; +import type { NostrEvent } from "@/types/nostr"; +import type { EventPointer } from "nostr-tools/nip19"; +import { UserName } from "../UserName"; +import { MarkdownContent } from "../MarkdownContent"; +import { EmbeddedEvent } from "../EmbeddedEvent"; +import { RepositoryLink } from "../RepositoryLink"; +import { useGrimoire } from "@/core/state"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { + getStatusRootEventId, + getStatusRootRelayHint, + getStatusRepositoryAddress, + getStatusLabel, + getStatusType, +} from "@/lib/nip34-helpers"; + +/** + * Get the icon for a status kind + */ +function getStatusIcon(kind: number) { + switch (kind) { + case 1630: + return CircleDot; + case 1631: + return CheckCircle2; + case 1632: + return XCircle; + case 1633: + return FileEdit; + default: + return CircleDot; + } +} + +/** + * Get the color classes for a status badge + * Uses theme semantic colors + */ +function getStatusBadgeClasses(kind: number): string { + switch (kind) { + case 1630: // Open - neutral + return "bg-muted/50 text-foreground border-border"; + case 1631: // Resolved/Merged - positive + return "bg-accent/20 text-accent border-accent/30"; + case 1632: // Closed - negative + return "bg-destructive/20 text-destructive border-destructive/30"; + case 1633: // Draft - muted + return "bg-muted text-muted-foreground border-muted-foreground/30"; + default: + return "bg-muted/50 text-foreground border-border"; + } +} + +/** + * Detail renderer for Kind 1630-1633 - Issue/Patch/PR Status Events + * Full view with status info, referenced event, and optional comment + */ +export function IssueStatusDetailRenderer({ event }: { event: NostrEvent }) { + const { addWindow } = useGrimoire(); + + const rootEventId = getStatusRootEventId(event); + const relayHint = getStatusRootRelayHint(event); + const repoAddress = getStatusRepositoryAddress(event); + const statusLabel = getStatusLabel(event.kind); + const statusType = getStatusType(event.kind); + + const StatusIcon = getStatusIcon(event.kind); + const badgeClasses = getStatusBadgeClasses(event.kind); + + // Build event pointer with relay hint if available + const eventPointer: EventPointer | undefined = rootEventId + ? { + id: rootEventId, + relays: relayHint ? [relayHint] : undefined, + } + : undefined; + + // Format created date using locale utility + const createdDate = formatTimestamp(event.created_at, "datetime"); + + return ( +
+ {/* 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..40e1aeb --- /dev/null +++ b/src/components/nostr/kinds/IssueStatusRenderer.tsx @@ -0,0 +1,72 @@ +import { + BaseEventContainer, + type BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { EmbeddedEvent } from "../EmbeddedEvent"; +import { StatusIndicator } from "../StatusIndicator"; +import { MarkdownContent } from "../MarkdownContent"; +import { useGrimoire } from "@/core/state"; +import { + getStatusRootEventId, + getStatusRootRelayHint, +} from "@/lib/nip34-helpers"; +import type { EventPointer } from "nostr-tools/nip19"; + +/** + * Renderer for Kind 1630-1633 - Issue/Patch/PR Status Events + * Displays status action with embedded reference to the issue/patch/PR + */ +export function IssueStatusRenderer({ event }: BaseEventProps) { + const { addWindow } = useGrimoire(); + + const rootEventId = getStatusRootEventId(event); + const relayHint = getStatusRootRelayHint(event); + + // Build event pointer with relay hint if available + const eventPointer: EventPointer | undefined = rootEventId + ? { + id: rootEventId, + relays: relayHint ? [relayHint] : undefined, + } + : undefined; + + return ( + +
+ {/* Status action header */} + + + + + {/* Optional comment from the status event */} + {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..3d37ca9 100644 --- a/src/components/nostr/kinds/PatchDetailRenderer.tsx +++ b/src/components/nostr/kinds/PatchDetailRenderer.tsx @@ -1,8 +1,10 @@ import { useMemo } from "react"; import { GitCommit, User, Copy, CopyCheck } from "lucide-react"; import { UserName } from "../UserName"; +import { MarkdownContent } from "../MarkdownContent"; import { CodeCopyButton } from "@/components/CodeCopyButton"; import { useCopy } from "@/hooks/useCopy"; +import { formatTimestamp } from "@/hooks/useLocale"; import { SyntaxHighlight } from "@/components/SyntaxHighlight"; import type { NostrEvent } from "@/types/nostr"; import { @@ -13,56 +15,130 @@ import { getPatchRepositoryAddress, isPatchRoot, isPatchRootRevision, + getRepositoryRelays, + getStatusType, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Detail renderer for Kind 1617 - Patch - * Displays full patch metadata and content + * Displays full patch metadata and content with status */ export function PatchDetailRenderer({ event }: { event: NostrEvent }) { const { copy, copied } = useCopy(); - const subject = useMemo(() => getPatchSubject(event), [event]); - const commitId = useMemo(() => getPatchCommitId(event), [event]); - const parentCommit = useMemo(() => getPatchParentCommit(event), [event]); - const committer = useMemo(() => getPatchCommitter(event), [event]); - const repoAddress = useMemo(() => getPatchRepositoryAddress(event), [event]); - const isRoot = useMemo(() => isPatchRoot(event), [event]); - const isRootRevision = useMemo(() => isPatchRootRevision(event), [event]); + const subject = getPatchSubject(event); + const commitId = getPatchCommitId(event); + const parentCommit = getPatchParentCommit(event); + const committer = getPatchCommitter(event); + const repoAddress = getPatchRepositoryAddress(event); + const isRoot = isPatchRoot(event); + const isRootRevision = isPatchRootRevision(event); - // Format created date - const createdDate = new Date(event.created_at * 1000).toLocaleDateString( - "en-US", - { - year: "numeric", - month: "long", - day: "numeric", - }, + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], ); + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks + const statusRelays = useMemo(() => { + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], + ); + + const { events: statusEvents, loading: statusLoading } = useTimeline( + `patch-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 20 }, + ); + + // Get valid status authors + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + + // Format created date using locale utility + const createdDate = formatTimestamp(event.created_at, "long"); + return (
{/* Patch Header */} -
+
{/* Title */} -

{subject || "Untitled Patch"}

+

{subject || "Untitled Patch"}

- {/* Status Badges */} - {(isRoot || isRootRevision) && ( -
- {isRoot && ( - - Root Patch - - )} - {isRootRevision && ( - - Root Revision - - )} -
- )} + {/* Status and Root badges (below title) */} +
+ + {isRoot && ( + + Root Patch + + )} + {isRootRevision && ( + + Root Revision + + )} +
{/* Repository Link */} {repoAddress && ( @@ -175,6 +251,33 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
)} + + {/* Status History */} + {currentStatus && ( +
+

+ Last Status Update +

+
+ + + {currentStatus.kind === 1631 + ? "merged" + : getStatusType(currentStatus.kind) || "updated"}{" "} + this patch + + + +
+ {currentStatus.content && ( +
+ +
+ )} +
+ )}
); } diff --git a/src/components/nostr/kinds/PatchRenderer.tsx b/src/components/nostr/kinds/PatchRenderer.tsx index 3f4620d..60649a1 100644 --- a/src/components/nostr/kinds/PatchRenderer.tsx +++ b/src/components/nostr/kinds/PatchRenderer.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { BaseEventContainer, type BaseEventProps, @@ -7,12 +8,21 @@ import { getPatchSubject, getPatchCommitId, getPatchRepositoryAddress, + getRepositoryRelays, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Renderer for Kind 1617 - Patch - * Displays as a compact patch card in feed view + * Displays as a compact patch card in feed view with status */ export function PatchRenderer({ event }: BaseEventProps) { const subject = getPatchSubject(event); @@ -22,10 +32,85 @@ export function PatchRenderer({ event }: BaseEventProps) { // Shorten commit ID for display const shortCommitId = commitId ? commitId.slice(0, 7) : undefined; + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], + ); + + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks: + // 1. Repository configured relays + // 2. Repo author's outbox (write) relays + // 3. AGGREGATOR_RELAYS as final fallback + const statusRelays = useMemo(() => { + // Try repository relays first + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + + // Try repo author's outbox relays + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + + // Fallback to aggregator relays + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events that reference this patch + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], + ); + + const { events: statusEvents } = useTimeline( + `patch-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 10 }, + ); + + // Get valid status authors (patch author + repo owner + maintainers) + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + return (
- {/* Patch Subject */} + {/* Subject/Title */} - {/* Metadata */} + {/* Status and Metadata */}
- in - {/* Repository */} - {repoAddress && } + + {repoAddress && ( + <> + in + + + )} {/* Commit ID */} {shortCommitId && ( diff --git a/src/components/nostr/kinds/PullRequestDetailRenderer.tsx b/src/components/nostr/kinds/PullRequestDetailRenderer.tsx index 5322573..9221b1c 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, @@ -12,47 +13,122 @@ import { getPullRequestCloneUrls, getPullRequestMergeBase, getPullRequestRepositoryAddress, + getRepositoryRelays, + getStatusType, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { Label } from "@/components/ui/label"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Detail renderer for Kind 1618 - Pull Request - * Displays full PR content with markdown rendering + * Displays full PR content with markdown rendering and status */ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) { const { copy, copied } = useCopy(); - const subject = useMemo(() => getPullRequestSubject(event), [event]); - const labels = useMemo(() => getPullRequestLabels(event), [event]); - const commitId = useMemo(() => getPullRequestCommitId(event), [event]); - const branchName = useMemo(() => getPullRequestBranchName(event), [event]); - const cloneUrls = useMemo(() => getPullRequestCloneUrls(event), [event]); - const mergeBase = useMemo(() => getPullRequestMergeBase(event), [event]); - const repoAddress = useMemo( - () => getPullRequestRepositoryAddress(event), - [event], + const subject = getPullRequestSubject(event); + const labels = getPullRequestLabels(event); + const commitId = getPullRequestCommitId(event); + const branchName = getPullRequestBranchName(event); + const cloneUrls = getPullRequestCloneUrls(event); + const mergeBase = getPullRequestMergeBase(event); + const repoAddress = getPullRequestRepositoryAddress(event); + + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], ); - // Format created date - const createdDate = new Date(event.created_at * 1000).toLocaleDateString( - "en-US", - { - year: "numeric", - month: "long", - day: "numeric", - }, + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks + const statusRelays = useMemo(() => { + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], ); + const { events: statusEvents, loading: statusLoading } = useTimeline( + `pr-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 20 }, + ); + + // Get valid status authors + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + + // Format created date using locale utility + const createdDate = formatTimestamp(event.created_at, "long"); + return (
{/* PR Header */} -
+
{/* Title */} -

+

{subject || "Untitled Pull Request"}

+ {/* Status Badge (below title) */} + + {/* Repository Link */} {repoAddress && (
@@ -201,6 +277,33 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) { (No description provided)

)} + + {/* Status History */} + {currentStatus && ( +
+

+ Last Status Update +

+
+ + + {currentStatus.kind === 1631 + ? "merged" + : getStatusType(currentStatus.kind) || "updated"}{" "} + this pull request + + + +
+ {currentStatus.content && ( +
+ +
+ )} +
+ )}
); } diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index 97ab93e..6466a7d 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -1,21 +1,31 @@ +import { useMemo } from "react"; +import { GitBranch } from "lucide-react"; import { BaseEventContainer, type BaseEventProps, ClickableEventTitle, } from "./BaseEventRenderer"; -import { GitBranch } from "lucide-react"; import { getPullRequestSubject, getPullRequestLabels, getPullRequestBranchName, getPullRequestRepositoryAddress, + getRepositoryRelays, + getValidStatusAuthors, + findCurrentStatus, } from "@/lib/nip34-helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; +import { getOutboxes } from "applesauce-core/helpers"; import { Label } from "@/components/ui/label"; import { RepositoryLink } from "../RepositoryLink"; +import { StatusIndicator } from "../StatusIndicator"; +import { useTimeline } from "@/hooks/useTimeline"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; /** * Renderer for Kind 1618 - Pull Request - * Displays as a compact PR card in feed view + * Displays as a compact PR card in feed view with status */ export function PullRequestRenderer({ event }: BaseEventProps) { const subject = getPullRequestSubject(event); @@ -23,6 +33,81 @@ export function PullRequestRenderer({ event }: BaseEventProps) { const branchName = getPullRequestBranchName(event); const repoAddress = getPullRequestRepositoryAddress(event); + // Parse repository address for fetching repo event + const parsedRepo = useMemo( + () => (repoAddress ? parseReplaceableAddress(repoAddress) : null), + [repoAddress], + ); + + // Fetch repository event to get maintainers list + const repoPointer = useMemo(() => { + if (!parsedRepo) return undefined; + return { + kind: parsedRepo.kind, + pubkey: parsedRepo.pubkey, + identifier: parsedRepo.identifier, + }; + }, [parsedRepo]); + + const repositoryEvent = useNostrEvent(repoPointer); + + // Fetch repo author's relay list for fallback + const repoAuthorRelayListPointer = useMemo(() => { + if (!parsedRepo?.pubkey) return undefined; + return { kind: 10002, pubkey: parsedRepo.pubkey, identifier: "" }; + }, [parsedRepo?.pubkey]); + + const repoAuthorRelayList = useNostrEvent(repoAuthorRelayListPointer); + + // Build relay list with fallbacks: + // 1. Repository configured relays + // 2. Repo author's outbox (write) relays + // 3. AGGREGATOR_RELAYS as final fallback + const statusRelays = useMemo(() => { + // Try repository relays first + if (repositoryEvent) { + const repoRelays = getRepositoryRelays(repositoryEvent); + if (repoRelays.length > 0) return repoRelays; + } + + // Try repo author's outbox relays + if (repoAuthorRelayList) { + const authorOutbox = getOutboxes(repoAuthorRelayList); + if (authorOutbox.length > 0) return authorOutbox; + } + + // Fallback to aggregator relays + return AGGREGATOR_RELAYS; + }, [repositoryEvent, repoAuthorRelayList]); + + // Fetch status events that reference this PR + const statusFilter = useMemo( + () => ({ + kinds: [1630, 1631, 1632, 1633], + "#e": [event.id], + }), + [event.id], + ); + + const { events: statusEvents } = useTimeline( + `pr-status-${event.id}`, + statusFilter, + statusRelays, + { limit: 10 }, + ); + + // Get valid status authors (PR author + repo owner + maintainers) + const validAuthors = useMemo( + () => getValidStatusAuthors(event, repositoryEvent), + [event, repositoryEvent], + ); + + // Get the most recent valid status event + const currentStatus = useMemo( + () => findCurrentStatus(statusEvents, validAuthors), + [statusEvents, validAuthors], + ); + return (
@@ -35,13 +120,19 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
- {/* Repository */} - {repoAddress && ( - - )} + {/* Status and Repository */} +
+ + {repoAddress && ( + <> + in + + + )} +
{/* Branch Name */} {branchName && (
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index b926e65..8add925 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"; @@ -188,6 +190,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) 1984: ReportRenderer, // Report (NIP-56) 9041: GoalRenderer, // Zap Goal (NIP-75) 9735: Kind9735Renderer, // Zap Receipt @@ -298,6 +304,10 @@ const detailRenderers: Record< 1617: PatchDetailRenderer, // Patch Detail (NIP-34) 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) 1621: IssueDetailRenderer, // Issue Detail (NIP-34) + 1630: IssueStatusDetailRenderer, // Open Status Detail (NIP-34) + 1631: IssueStatusDetailRenderer, // Applied/Merged/Resolved Status Detail (NIP-34) + 1632: IssueStatusDetailRenderer, // Closed Status Detail (NIP-34) + 1633: IssueStatusDetailRenderer, // Draft Status Detail (NIP-34) 1984: ReportDetailRenderer, // Report Detail (NIP-56) 9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75) 9802: Kind9802DetailRenderer, // Highlight Detail 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/index.css b/src/index.css index 611f2c8..a99bf46 100644 --- a/src/index.css +++ b/src/index.css @@ -115,16 +115,16 @@ --muted-foreground: 215 20.2% 70%; --accent: 270 100% 70%; --accent-foreground: 222.2 84% 4.9%; - --destructive: 0 62.8% 30.6%; + --destructive: 0 75% 75%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; /* Status colors */ - --success: 142 76% 36%; - --warning: 45 93% 47%; - --info: 199 89% 48%; + --success: 142 76% 46%; + --warning: 38 92% 60%; + --info: 199 89% 58%; /* Nostr-specific colors */ --zap: 45 93% 58%; diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index cac5ec7..9eab3aa 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -1,11 +1,35 @@ import type { NostrEvent } from "@/types/nostr"; -import { getTagValue } from "applesauce-core/helpers"; +import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers"; +import { parseReplaceableAddress } from "applesauce-core/helpers/pointers"; /** * NIP-34 Helper Functions * Utility functions for parsing NIP-34 git event tags + * + * All helper functions use applesauce's getOrComputeCachedValue to cache + * computed values on the event object itself. This means you don't need + * useMemo when calling these functions - they will return cached values + * on subsequent calls for the same event. */ +// Cache symbols for memoization +const CloneUrlsSymbol = Symbol("cloneUrls"); +const WebUrlsSymbol = Symbol("webUrls"); +const MaintainersSymbol = Symbol("maintainers"); +const RepositoryRelaysSymbol = Symbol("repositoryRelays"); +const IssueLabelsSymbol = Symbol("issueLabels"); +const PatchSubjectSymbol = Symbol("patchSubject"); +const PatchCommitterSymbol = Symbol("patchCommitter"); +const IsPatchRootSymbol = Symbol("isPatchRoot"); +const IsPatchRootRevisionSymbol = Symbol("isPatchRootRevision"); +const PullRequestLabelsSymbol = Symbol("pullRequestLabels"); +const PullRequestCloneUrlsSymbol = Symbol("pullRequestCloneUrls"); +const RepositoryStateRefsSymbol = Symbol("repositoryStateRefs"); +const RepositoryStateBranchesSymbol = Symbol("repositoryStateBranches"); +const RepositoryStateTagsSymbol = Symbol("repositoryStateTags"); +const StatusRootEventIdSymbol = Symbol("statusRootEventId"); +const StatusRootRelayHintSymbol = Symbol("statusRootRelayHint"); + // ============================================================================ // Repository Event Helpers (Kind 30617) // ============================================================================ @@ -45,7 +69,9 @@ export function getRepositoryIdentifier(event: NostrEvent): string | undefined { * @returns Array of clone URLs */ export function getCloneUrls(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]); + return getOrComputeCachedValue(event, CloneUrlsSymbol, () => + event.tags.filter((t) => t[0] === "clone").map((t) => t[1]), + ); } /** @@ -54,7 +80,9 @@ export function getCloneUrls(event: NostrEvent): string[] { * @returns Array of web URLs */ export function getWebUrls(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "web").map((t) => t[1]); + return getOrComputeCachedValue(event, WebUrlsSymbol, () => + event.tags.filter((t) => t[0] === "web").map((t) => t[1]), + ); } /** @@ -63,10 +91,12 @@ export function getWebUrls(event: NostrEvent): string[] { * @returns Array of maintainer pubkeys */ export function getMaintainers(event: NostrEvent): string[] { - return event.tags - .filter((t) => t[0] === "maintainers") - .map((t) => t[1]) - .filter((p: string) => p !== event.pubkey); + return getOrComputeCachedValue(event, MaintainersSymbol, () => + event.tags + .filter((t) => t[0] === "maintainers") + .map((t) => t[1]) + .filter((p: string) => p !== event.pubkey), + ); } /** @@ -75,10 +105,12 @@ export function getMaintainers(event: NostrEvent): string[] { * @returns Array of relay URLs */ export function getRepositoryRelays(event: NostrEvent): string[] { - const relaysTag = event.tags.find((t) => t[0] === "relays"); - if (!relaysTag) return []; - const [, ...relays] = relaysTag; - return relays; + return getOrComputeCachedValue(event, RepositoryRelaysSymbol, () => { + const relaysTag = event.tags.find((t) => t[0] === "relays"); + if (!relaysTag) return []; + const [, ...relays] = relaysTag; + return relays; + }); } // ============================================================================ @@ -100,7 +132,9 @@ export function getIssueTitle(event: NostrEvent): string | undefined { * @returns Array of label strings */ export function getIssueLabels(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + return getOrComputeCachedValue(event, IssueLabelsSymbol, () => + event.tags.filter((t) => t[0] === "t").map((t) => t[1]), + ); } /** @@ -133,18 +167,20 @@ export function getIssueRepositoryOwner(event: NostrEvent): string | undefined { * @returns Patch subject/title or undefined */ export function getPatchSubject(event: NostrEvent): string | undefined { - // Try subject tag first - const subjectTag = getTagValue(event, "subject"); - if (subjectTag) return subjectTag; + return getOrComputeCachedValue(event, PatchSubjectSymbol, () => { + // Try subject tag first + const subjectTag = getTagValue(event, "subject"); + if (subjectTag) return subjectTag; - // Try to extract from content (first line or "Subject:" header from git format-patch) - const content = event.content.trim(); - const subjectMatch = content.match(/^Subject:\s*(.+?)$/m); - if (subjectMatch) return subjectMatch[1].trim(); + // Try to extract from content (first line or "Subject:" header from git format-patch) + const content = event.content.trim(); + const subjectMatch = content.match(/^Subject:\s*(.+?)$/m); + if (subjectMatch) return subjectMatch[1].trim(); - // Fallback to first line - const firstLine = content.split("\n")[0]; - return firstLine?.length > 0 ? firstLine : undefined; + // Fallback to first line + const firstLine = content.split("\n")[0]; + return firstLine?.length > 0 ? firstLine : undefined; + }); } /** @@ -175,11 +211,13 @@ export function getPatchCommitter( ): | { name: string; email: string; timestamp: string; timezone: string } | undefined { - const committerTag = event.tags.find((t) => t[0] === "committer"); - if (!committerTag || committerTag.length < 5) return undefined; + return getOrComputeCachedValue(event, PatchCommitterSymbol, () => { + const committerTag = event.tags.find((t) => t[0] === "committer"); + if (!committerTag || committerTag.length < 5) return undefined; - const [, name, email, timestamp, timezone] = committerTag; - return { name, email, timestamp, timezone }; + const [, name, email, timestamp, timezone] = committerTag; + return { name, email, timestamp, timezone }; + }); } /** @@ -199,7 +237,9 @@ export function getPatchRepositoryAddress( * @returns True if this is a root patch */ export function isPatchRoot(event: NostrEvent): boolean { - return event.tags.some((t) => t[0] === "t" && t[1] === "root"); + return getOrComputeCachedValue(event, IsPatchRootSymbol, () => + event.tags.some((t) => t[0] === "t" && t[1] === "root"), + ); } /** @@ -208,7 +248,9 @@ export function isPatchRoot(event: NostrEvent): boolean { * @returns True if this is a root revision */ export function isPatchRootRevision(event: NostrEvent): boolean { - return event.tags.some((t) => t[0] === "t" && t[1] === "root-revision"); + return getOrComputeCachedValue(event, IsPatchRootRevisionSymbol, () => + event.tags.some((t) => t[0] === "t" && t[1] === "root-revision"), + ); } // ============================================================================ @@ -230,7 +272,9 @@ export function getPullRequestSubject(event: NostrEvent): string | undefined { * @returns Array of label strings */ export function getPullRequestLabels(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "t").map((t) => t[1]); + return getOrComputeCachedValue(event, PullRequestLabelsSymbol, () => + event.tags.filter((t) => t[0] === "t").map((t) => t[1]), + ); } /** @@ -248,7 +292,9 @@ export function getPullRequestCommitId(event: NostrEvent): string | undefined { * @returns Array of clone URLs */ export function getPullRequestCloneUrls(event: NostrEvent): string[] { - return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]); + return getOrComputeCachedValue(event, PullRequestCloneUrlsSymbol, () => + event.tags.filter((t) => t[0] === "clone").map((t) => t[1]), + ); } /** @@ -316,9 +362,11 @@ export function parseHeadBranch( export function getRepositoryStateRefs( event: NostrEvent, ): Array<{ ref: string; hash: string }> { - return event.tags - .filter((t) => t[0].startsWith("refs/")) - .map((t) => ({ ref: t[0], hash: t[1] })); + return getOrComputeCachedValue(event, RepositoryStateRefsSymbol, () => + event.tags + .filter((t) => t[0].startsWith("refs/")) + .map((t) => ({ ref: t[0], hash: t[1] })), + ); } /** @@ -347,12 +395,14 @@ export function getRepositoryStateHeadCommit( export function getRepositoryStateBranches( event: NostrEvent, ): Array<{ name: string; hash: string }> { - return event.tags - .filter((t) => t[0].startsWith("refs/heads/")) - .map((t) => ({ - name: t[0].replace("refs/heads/", ""), - hash: t[1], - })); + return getOrComputeCachedValue(event, RepositoryStateBranchesSymbol, () => + event.tags + .filter((t) => t[0].startsWith("refs/heads/")) + .map((t) => ({ + name: t[0].replace("refs/heads/", ""), + hash: t[1], + })), + ); } /** @@ -363,10 +413,176 @@ export function getRepositoryStateBranches( export function getRepositoryStateTags( event: NostrEvent, ): Array<{ name: string; hash: string }> { - return event.tags - .filter((t) => t[0].startsWith("refs/tags/")) - .map((t) => ({ - name: t[0].replace("refs/tags/", ""), - hash: t[1], - })); + return getOrComputeCachedValue(event, RepositoryStateTagsSymbol, () => + event.tags + .filter((t) => t[0].startsWith("refs/tags/")) + .map((t) => ({ + name: t[0].replace("refs/tags/", ""), + 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 { + return getOrComputeCachedValue(event, StatusRootEventIdSymbol, () => { + // 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 { + return getOrComputeCachedValue(event, StatusRootRelayHintSymbol, () => { + 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"; + } +} + +/** + * 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]; }