mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
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:
67
CLAUDE.md
67
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
|
||||
|
||||
|
||||
135
src/components/nostr/StatusIndicator.tsx
Normal file
135
src/components/nostr/StatusIndicator.tsx
Normal 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 };
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
149
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal file
149
src/components/nostr/kinds/IssueStatusDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal file
72
src/components/nostr/kinds/IssueStatusRenderer.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user