feat: NIP-34 status events (#209)

* feat(nip34): Add NIP-34 issue status renderers and locale-aware formatting

- Add IssueStatusRenderer for feed view (kinds 1630-1633: Open/Resolved/Closed/Draft)
- Add IssueStatusDetailRenderer for detail view with status badge and embedded issue
- Update IssueRenderer/IssueDetailRenderer to fetch and display current issue status
- Status validation respects issue author, repo owner, and maintainers
- Add status helper functions to nip34-helpers.ts (getStatusType, findCurrentStatus, etc.)
- Use parseReplaceableAddress from applesauce-core for coordinate parsing
- Expand formatTimestamp utility with 'long' and 'datetime' styles
- Fix locale-aware date formatting across all detail renderers
- Update CLAUDE.md with useLocale hook and formatTimestamp documentation

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* refactor(nip34): Use theme semantic colors for issue status

Replace hardcoded colors with theme semantic colors:
- Resolved/merged: accent (positive)
- Closed: destructive (negative)
- Draft: muted
- Open: neutral foreground

Also fixes import placement in nip34-helpers.ts.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Use repository relays instead of AGGREGATOR_RELAYS

Status events for issues are now fetched from the relays configured
in the repository definition, not from hardcoded aggregator relays.

This respects the relay hints provided by repository maintainers for
better decentralization and reliability.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* perf(nip34): Add memoization caching to helper functions

Use getOrComputeCachedValue from applesauce-core to cache computed
values on event objects. This prevents redundant computation when
helpers are called multiple times for the same event.

Also added documentation in CLAUDE.md about best practices for
writing helper libraries that compute data from Nostr events.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Add relay fallback chain for status event fetching

Status events now use a fallback chain for relay selection:
1. Repository configured relays (from "relays" tag)
2. Repo author's outbox relays (from kind:10002)
3. AGGREGATOR_RELAYS as final fallback

This ensures status events can be fetched even when repository
doesn't have relays configured.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* feat(nip34): Add status rendering to Patch and PR renderers

- PatchRenderer and PatchDetailRenderer now show merge/closed/draft status
- PullRequestRenderer and PullRequestDetailRenderer now show merge/closed/draft status
- Status events fetched from repository relays with author outbox fallback
- For patches and PRs, kind 1631 displays as "merged" instead of "resolved"
- Fixed destructive color contrast in dark theme (30.6% -> 50% lightness)

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* refactor(nip34): Extract StatusIndicator component, improve UI layout

- Create reusable StatusIndicator component for issues/patches/PRs
- Move status icon next to status text in feed renderers (not title)
- Place status badge below title in detail renderers
- Fix dark theme destructive color contrast (0 90% 65%)
- Remove duplicate getStatusIcon/getStatusColorClass functions

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Make status badge width fit content

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(theme): Improve destructive color contrast on dark theme

Increase lightness from 65% to 70% for better readability.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(theme): Use lighter coral red for destructive on dark theme

Changed to 0 75% 75% (~#E89090) for better contrast against #020817 background.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* docs: Fix applesauce helper documentation in CLAUDE.md

- Fix parseCoordinate -> parseReplaceableAddress (correct function name)
- Clarify getTagValue (applesauce) vs getTagValues (custom Grimoire)
- Add getOrComputeCachedValue to helpers list
- Improve code example with proper imports and patterns

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Render status event content as rich text

Use MarkdownContent component for status event content in
Issue, Patch, and PR detail renderers.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Smaller status indicators, improve issue feed layout

- Use shared StatusIndicator in IssueStatusRenderer (smaller size)
- Render status event content as markdown
- Put status on its own line between title and repo in IssueRenderer

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

* fix(nip34): Use warning color for closed status instead of destructive

- Change closed status from red (destructive) to orange (warning)
- Improve dark theme status colors contrast (warning: 38 92% 60%)
- Less aggressive visual for closed issues/patches/PRs

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-23 15:31:20 +01:00
committed by GitHub
parent 85ab0a1587
commit e008d76021
17 changed files with 1417 additions and 186 deletions

View File

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

View File

@@ -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 (
<span
className={`inline-flex items-center gap-1.5 text-sm text-muted-foreground ${className}`}
>
<Loader2 className="size-3.5 animate-spin" />
<span>Loading...</span>
</span>
);
}
// 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 (
<span
className={`inline-flex w-fit items-center gap-1.5 px-2 py-1 text-xs font-medium border rounded-sm ${badgeClasses} ${className}`}
>
<StatusIcon className="size-3.5" />
<span className="capitalize">{statusText}</span>
</span>
);
}
// Inline variant (default)
const colorClass = getStatusColorClass(effectiveKind);
return (
<span className={`inline-flex items-center gap-1 text-xs ${className}`}>
<StatusIcon className={`size-3 ${colorClass}`} />
<span className={colorClass}>{statusText}</span>
</span>
);
}
// Re-export utilities for use in feed renderers that need just the icon/color
export { getStatusIcon, getStatusColorClass, getStatusBadgeClasses };

View File

@@ -8,6 +8,7 @@ import {
import { UserName } from "../UserName";
import { MediaEmbed } from "../MediaEmbed";
import { MarkdownContent } from "../MarkdownContent";
import { formatTimestamp } from "@/hooks/useLocale";
import type { NostrEvent } from "@/types/nostr";
/**
@@ -26,14 +27,8 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
return rTag?.[1] || null;
}, [event]);
// Format published date
const publishedDate = published
? new Date(published * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
// Format published date using locale utility
const publishedDate = published ? formatTimestamp(published, "long") : null;
// Resolve article image URL
const resolvedImageUrl = useMemo(() => {

View File

@@ -5,6 +5,7 @@ import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { Button } from "@/components/ui/button";
import { useCopy } from "@/hooks/useCopy";
import { formatTimestamp } from "@/hooks/useLocale";
import { toast } from "sonner";
import type { NostrEvent } from "@/types/nostr";
@@ -23,15 +24,8 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
return getTagValue(event, "r");
}, [event]);
// Format created date
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
);
// Format created date using locale utility
const createdDate = formatTimestamp(event.created_at, "long");
// Copy functionality
const { copy, copied } = useCopy();

View File

@@ -11,6 +11,7 @@ import {
import { EmbeddedEvent } from "../EmbeddedEvent";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";
import { formatTimestamp } from "@/hooks/useLocale";
import { RichText } from "../RichText";
/**
@@ -29,15 +30,8 @@ export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
// Format created date
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
);
// Format created date using locale utility
const createdDate = formatTimestamp(event.created_at, "long");
// Create synthetic event for comment rendering (preserves emoji tags)
const commentEvent = comment

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Issue Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
<header className="flex flex-col gap-3 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">{title || "Untitled Issue"}</h1>
<h1 className="text-2xl font-bold">{title || "Untitled Issue"}</h1>
{/* Status Badge (below title) */}
<StatusIndicator
statusKind={currentStatus?.kind}
loading={statusLoading}
eventType="issue"
variant="badge"
/>
{/* Repository Link */}
{repoAddress && (
@@ -80,6 +168,30 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
(No description provided)
</p>
)}
{/* Status History (if there are status events) */}
{currentStatus && (
<section className="flex flex-col gap-2 pt-4 border-t border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Last Status Update
</h2>
<div className="flex items-center gap-2 text-sm">
<UserName pubkey={currentStatus.pubkey} />
<span className="text-muted-foreground">
{getStatusType(currentStatus.kind) || "updated"} this issue
</span>
<span className="text-muted-foreground"></span>
<time className="text-muted-foreground">
{formatTimestamp(currentStatus.created_at, "date")}
</time>
</div>
{currentStatus.content && (
<div className="text-sm mt-1">
<MarkdownContent content={currentStatus.content} />
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
{/* Issue Title */}
<ClickableEventTitle
event={event}
className="font-semibold text-foreground"
>
{title || "Untitled Issue"}
</ClickableEventTitle>
<div className="flex flex-col gap-1">
{/* Title */}
<ClickableEventTitle
event={event}
className="font-semibold text-foreground"
>
{title || "Untitled Issue"}
</ClickableEventTitle>
{/* Repository Reference */}
{repoAddress && (
<div className="text-xs line-clamp-1">
<RepositoryLink repoAddress={repoAddress} />
</div>
)}
</div>
{/* Status */}
<StatusIndicator statusKind={currentStatus?.kind} eventType="issue" />
{/* Repository */}
{repoAddress && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">in</span>
<RepositoryLink repoAddress={repoAddress} />
</div>
)}
{/* Labels */}
{labels.length > 0 && (
<div
className="flex
flex-wrap
line-clamp-2
items-center gap-1 overflow-x-scroll my-1"
>
<div className="flex flex-wrap line-clamp-2 items-center gap-1 overflow-x-scroll mt-1">
{labels.map((label, idx) => (
<Label key={idx}>{label}</Label>
))}

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Status Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
{/* Status Badge */}
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium border ${badgeClasses}`}
>
<StatusIcon className="size-4" />
<span className="capitalize">{statusType || statusLabel}</span>
</span>
</div>
{/* Title */}
<h1 className="text-2xl font-bold">Status Update</h1>
{/* Repository Link */}
{repoAddress && (
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Repository:</span>
<RepositoryLink
repoAddress={repoAddress}
iconSize="size-4"
className="font-mono"
/>
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>By</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
<span></span>
<time>{createdDate}</time>
</div>
</header>
{/* Comment/Reason (if any) */}
{event.content && (
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Comment</h2>
<MarkdownContent content={event.content} />
</section>
)}
{/* Referenced Event */}
{eventPointer && (
<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Referenced Event</h2>
<EmbeddedEvent
eventPointer={eventPointer}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
className="border border-muted rounded overflow-hidden"
/>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Status action header */}
<ClickableEventTitle event={event}>
<StatusIndicator statusKind={event.kind} eventType="issue" />
</ClickableEventTitle>
{/* Optional comment from the status event */}
{event.content && (
<div className="text-xs text-muted-foreground line-clamp-2">
<MarkdownContent content={event.content} />
</div>
)}
{/* Embedded referenced issue/patch/PR */}
{eventPointer && (
<EmbeddedEvent
eventPointer={eventPointer}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
className="border border-muted rounded overflow-hidden"
/>
)}
</div>
</BaseEventContainer>
);
}
// Export aliases for each status kind
export { IssueStatusRenderer as Kind1630Renderer };
export { IssueStatusRenderer as Kind1631Renderer };
export { IssueStatusRenderer as Kind1632Renderer };
export { IssueStatusRenderer as Kind1633Renderer };

View File

@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* Patch Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
<header className="flex flex-col gap-3 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">{subject || "Untitled Patch"}</h1>
<h1 className="text-2xl font-bold">{subject || "Untitled Patch"}</h1>
{/* Status Badges */}
{(isRoot || isRootRevision) && (
<div className="flex flex-wrap items-center gap-2">
{isRoot && (
<span className="px-3 py-1 bg-accent/20 text-accent text-sm border border-accent/30">
Root Patch
</span>
)}
{isRootRevision && (
<span className="px-3 py-1 bg-primary/20 text-primary text-sm border border-primary/30">
Root Revision
</span>
)}
</div>
)}
{/* Status and Root badges (below title) */}
<div className="flex items-center gap-2 flex-wrap">
<StatusIndicator
statusKind={currentStatus?.kind}
loading={statusLoading}
eventType="patch"
variant="badge"
/>
{isRoot && (
<span className="px-2 py-1 bg-accent/20 text-accent text-xs border border-accent/30 rounded-sm">
Root Patch
</span>
)}
{isRootRevision && (
<span className="px-2 py-1 bg-primary/20 text-primary text-xs border border-primary/30 rounded-sm">
Root Revision
</span>
)}
</div>
{/* Repository Link */}
{repoAddress && (
@@ -175,6 +251,33 @@ export function PatchDetailRenderer({ event }: { event: NostrEvent }) {
</div>
</section>
)}
{/* Status History */}
{currentStatus && (
<section className="flex flex-col gap-2 pt-4 border-t border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Last Status Update
</h2>
<div className="flex items-center gap-2 text-sm">
<UserName pubkey={currentStatus.pubkey} />
<span className="text-muted-foreground">
{currentStatus.kind === 1631
? "merged"
: getStatusType(currentStatus.kind) || "updated"}{" "}
this patch
</span>
<span className="text-muted-foreground"></span>
<time className="text-muted-foreground">
{formatTimestamp(currentStatus.created_at, "date")}
</time>
</div>
{currentStatus.content && (
<div className="text-sm mt-1">
<MarkdownContent content={currentStatus.content} />
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Patch Subject */}
{/* Subject/Title */}
<ClickableEventTitle
event={event}
className="font-semibold text-foreground"
@@ -33,11 +118,15 @@ export function PatchRenderer({ event }: BaseEventProps) {
{subject || "Untitled Patch"}
</ClickableEventTitle>
{/* Metadata */}
{/* Status and Metadata */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>in</span>
{/* Repository */}
{repoAddress && <RepositoryLink repoAddress={repoAddress} />}
<StatusIndicator statusKind={currentStatus?.kind} eventType="patch" />
{repoAddress && (
<>
<span className="text-muted-foreground">in</span>
<RepositoryLink repoAddress={repoAddress} />
</>
)}
{/* Commit ID */}
{shortCommitId && (

View File

@@ -3,6 +3,7 @@ import { GitBranch, Tag, Copy, CopyCheck } from "lucide-react";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { useCopy } from "@/hooks/useCopy";
import { formatTimestamp } from "@/hooks/useLocale";
import type { NostrEvent } from "@/types/nostr";
import {
getPullRequestSubject,
@@ -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 (
<div className="flex flex-col gap-4 p-4 max-w-3xl mx-auto">
{/* PR Header */}
<header className="flex flex-col gap-4 pb-4 border-b border-border">
<header className="flex flex-col gap-3 pb-4 border-b border-border">
{/* Title */}
<h1 className="text-3xl font-bold">
<h1 className="text-2xl font-bold">
{subject || "Untitled Pull Request"}
</h1>
{/* Status Badge (below title) */}
<StatusIndicator
statusKind={currentStatus?.kind}
loading={statusLoading}
eventType="pr"
variant="badge"
/>
{/* Repository Link */}
{repoAddress && (
<div className="flex items-center gap-2 text-sm">
@@ -201,6 +277,33 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
(No description provided)
</p>
)}
{/* Status History */}
{currentStatus && (
<section className="flex flex-col gap-2 pt-4 border-t border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Last Status Update
</h2>
<div className="flex items-center gap-2 text-sm">
<UserName pubkey={currentStatus.pubkey} />
<span className="text-muted-foreground">
{currentStatus.kind === 1631
? "merged"
: getStatusType(currentStatus.kind) || "updated"}{" "}
this pull request
</span>
<span className="text-muted-foreground"></span>
<time className="text-muted-foreground">
{formatTimestamp(currentStatus.created_at, "date")}
</time>
</div>
{currentStatus.content && (
<div className="text-sm mt-1">
<MarkdownContent content={currentStatus.content} />
</div>
)}
</section>
)}
</div>
);
}

View File

@@ -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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
@@ -35,13 +120,19 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
</ClickableEventTitle>
<div className="flex flex-col gap-1">
{/* Repository */}
{repoAddress && (
<RepositoryLink
repoAddress={repoAddress}
className="truncate line-clamp-1 text-xs"
/>
)}
{/* Status and Repository */}
<div className="flex items-center gap-2 text-xs">
<StatusIndicator statusKind={currentStatus?.kind} eventType="pr" />
{repoAddress && (
<>
<span className="text-muted-foreground">in</span>
<RepositoryLink
repoAddress={repoAddress}
className="truncate line-clamp-1"
/>
</>
)}
</div>
{/* Branch Name */}
{branchName && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">

View File

@@ -17,6 +17,8 @@ import { Kind1337Renderer } from "./CodeSnippetRenderer";
import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer";
import { IssueRenderer } from "./IssueRenderer";
import { IssueDetailRenderer } from "./IssueDetailRenderer";
import { IssueStatusRenderer } from "./IssueStatusRenderer";
import { IssueStatusDetailRenderer } from "./IssueStatusDetailRenderer";
import { PatchRenderer } from "./PatchRenderer";
import { PatchDetailRenderer } from "./PatchDetailRenderer";
import { PullRequestRenderer } from "./PullRequestRenderer";
@@ -188,6 +190,10 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
1630: IssueStatusRenderer, // Open Status (NIP-34)
1631: IssueStatusRenderer, // Applied/Merged/Resolved Status (NIP-34)
1632: IssueStatusRenderer, // Closed Status (NIP-34)
1633: IssueStatusRenderer, // Draft Status (NIP-34)
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

View File

@@ -50,11 +50,20 @@ export function useLocale(): LocaleConfig {
/**
* Format a timestamp according to locale preferences
* @param timestamp - Unix timestamp in seconds
* @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only, 'time' for time only
* @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only,
* 'long' for full readable date (e.g., "January 15, 2025"), 'time' for time only,
* 'datetime' for date with time (e.g., "January 15, 2025, 2:30 PM")
* @param locale - Optional locale override (defaults to browser locale)
*/
export function formatTimestamp(
timestamp: number,
style: "relative" | "absolute" | "date" | "time" = "relative",
style:
| "relative"
| "absolute"
| "date"
| "long"
| "time"
| "datetime" = "relative",
locale?: string,
): string {
const browserLocale = locale || navigator.language || "en-US";
@@ -102,6 +111,26 @@ export function formatTimestamp(
});
}
if (style === "long") {
// Human-readable long format: "January 15, 2025"
return date.toLocaleDateString(browserLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
}
if (style === "datetime") {
// Full date with time: "January 15, 2025, 2:30 PM"
return date.toLocaleString(browserLocale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
if (style === "time") {
return date.toLocaleTimeString(browserLocale, {
hour: "2-digit",

View File

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

View File

@@ -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<number, IssueStatusType> = {
1630: "open",
1631: "resolved",
1632: "closed",
1633: "draft",
};
/**
* Get the status type from a status event kind
* @param kind Event kind (1630-1633)
* @returns Status type or undefined if not a status kind
*/
export function getStatusType(kind: number): IssueStatusType | undefined {
return STATUS_KIND_MAP[kind];
}
/**
* Get the root event ID being referenced by a status event
* The root is the original issue/patch/PR being marked with a status
* @param event Status event (kind 1630-1633)
* @returns Event ID or undefined
*/
export function getStatusRootEventId(event: NostrEvent): string | undefined {
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<string> {
const validPubkeys = new Set<string>();
// Event author can always set status
validPubkeys.add(event.pubkey);
// Repository owner from p tag
const repoOwner = getTagValue(event, "p");
if (repoOwner) validPubkeys.add(repoOwner);
// Parse repository address to get owner pubkey using applesauce helper
const repoAddress =
getIssueRepositoryAddress(event) ||
getPatchRepositoryAddress(event) ||
getPullRequestRepositoryAddress(event);
if (repoAddress) {
const parsedRepo = parseReplaceableAddress(repoAddress);
if (parsedRepo?.pubkey) validPubkeys.add(parsedRepo.pubkey);
}
// Add maintainers from repository event
if (repositoryEvent) {
const maintainers = getMaintainers(repositoryEvent);
maintainers.forEach((m) => validPubkeys.add(m));
}
return validPubkeys;
}
/**
* Find the most recent valid status event from a list of status events
* Valid = from event author, repository owner, or maintainers
* @param statusEvents Array of status events (kinds 1630-1633)
* @param validAuthors Set of valid pubkeys (from getValidStatusAuthors)
* @returns Most recent valid status event or null
*/
export function findCurrentStatus(
statusEvents: NostrEvent[],
validAuthors: Set<string>,
): NostrEvent | null {
if (statusEvents.length === 0) return null;
// Sort by created_at descending (most recent first)
const sorted = [...statusEvents].sort((a, b) => b.created_at - a.created_at);
// Find the most recent status from a valid author
const validStatus = sorted.find((s) => validAuthors.has(s.pubkey));
// Return valid status if found, otherwise most recent (may be invalid but show anyway)
return validStatus || sorted[0];
}