@@ -35,13 +120,19 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
- {/* Repository */}
- {repoAddress && (
-
- )}
+ {/* Status and Repository */}
+
+
+ {repoAddress && (
+ <>
+ in
+
+ >
+ )}
+
{/* Branch Name */}
{branchName && (
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index b926e65..8add925 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -17,6 +17,8 @@ import { Kind1337Renderer } from "./CodeSnippetRenderer";
import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer";
import { IssueRenderer } from "./IssueRenderer";
import { IssueDetailRenderer } from "./IssueDetailRenderer";
+import { IssueStatusRenderer } from "./IssueStatusRenderer";
+import { IssueStatusDetailRenderer } from "./IssueStatusDetailRenderer";
import { PatchRenderer } from "./PatchRenderer";
import { PatchDetailRenderer } from "./PatchDetailRenderer";
import { PullRequestRenderer } from "./PullRequestRenderer";
@@ -188,6 +190,10 @@ const kindRenderers: Record> = {
1617: PatchRenderer, // Patch (NIP-34)
1618: PullRequestRenderer, // Pull Request (NIP-34)
1621: IssueRenderer, // Issue (NIP-34)
+ 1630: IssueStatusRenderer, // Open Status (NIP-34)
+ 1631: IssueStatusRenderer, // Applied/Merged/Resolved Status (NIP-34)
+ 1632: IssueStatusRenderer, // Closed Status (NIP-34)
+ 1633: IssueStatusRenderer, // Draft Status (NIP-34)
1984: ReportRenderer, // Report (NIP-56)
9041: GoalRenderer, // Zap Goal (NIP-75)
9735: Kind9735Renderer, // Zap Receipt
@@ -298,6 +304,10 @@ const detailRenderers: Record<
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34)
1621: IssueDetailRenderer, // Issue Detail (NIP-34)
+ 1630: IssueStatusDetailRenderer, // Open Status Detail (NIP-34)
+ 1631: IssueStatusDetailRenderer, // Applied/Merged/Resolved Status Detail (NIP-34)
+ 1632: IssueStatusDetailRenderer, // Closed Status Detail (NIP-34)
+ 1633: IssueStatusDetailRenderer, // Draft Status Detail (NIP-34)
1984: ReportDetailRenderer, // Report Detail (NIP-56)
9041: GoalDetailRenderer, // Zap Goal Detail (NIP-75)
9802: Kind9802DetailRenderer, // Highlight Detail
diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts
index cdd0ce6..f7b7fce 100644
--- a/src/hooks/useLocale.ts
+++ b/src/hooks/useLocale.ts
@@ -50,11 +50,20 @@ export function useLocale(): LocaleConfig {
/**
* Format a timestamp according to locale preferences
* @param timestamp - Unix timestamp in seconds
- * @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only, 'time' for time only
+ * @param style - 'relative' for "2h ago", 'absolute' for full date/time, 'date' for date only,
+ * 'long' for full readable date (e.g., "January 15, 2025"), 'time' for time only,
+ * 'datetime' for date with time (e.g., "January 15, 2025, 2:30 PM")
+ * @param locale - Optional locale override (defaults to browser locale)
*/
export function formatTimestamp(
timestamp: number,
- style: "relative" | "absolute" | "date" | "time" = "relative",
+ style:
+ | "relative"
+ | "absolute"
+ | "date"
+ | "long"
+ | "time"
+ | "datetime" = "relative",
locale?: string,
): string {
const browserLocale = locale || navigator.language || "en-US";
@@ -102,6 +111,26 @@ export function formatTimestamp(
});
}
+ if (style === "long") {
+ // Human-readable long format: "January 15, 2025"
+ return date.toLocaleDateString(browserLocale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ }
+
+ if (style === "datetime") {
+ // Full date with time: "January 15, 2025, 2:30 PM"
+ return date.toLocaleString(browserLocale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ }
+
if (style === "time") {
return date.toLocaleTimeString(browserLocale, {
hour: "2-digit",
diff --git a/src/index.css b/src/index.css
index 611f2c8..a99bf46 100644
--- a/src/index.css
+++ b/src/index.css
@@ -115,16 +115,16 @@
--muted-foreground: 215 20.2% 70%;
--accent: 270 100% 70%;
--accent-foreground: 222.2 84% 4.9%;
- --destructive: 0 62.8% 30.6%;
+ --destructive: 0 75% 75%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Status colors */
- --success: 142 76% 36%;
- --warning: 45 93% 47%;
- --info: 199 89% 48%;
+ --success: 142 76% 46%;
+ --warning: 38 92% 60%;
+ --info: 199 89% 58%;
/* Nostr-specific colors */
--zap: 45 93% 58%;
diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts
index cac5ec7..9eab3aa 100644
--- a/src/lib/nip34-helpers.ts
+++ b/src/lib/nip34-helpers.ts
@@ -1,11 +1,35 @@
import type { NostrEvent } from "@/types/nostr";
-import { getTagValue } from "applesauce-core/helpers";
+import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
+import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
/**
* NIP-34 Helper Functions
* Utility functions for parsing NIP-34 git event tags
+ *
+ * All helper functions use applesauce's getOrComputeCachedValue to cache
+ * computed values on the event object itself. This means you don't need
+ * useMemo when calling these functions - they will return cached values
+ * on subsequent calls for the same event.
*/
+// Cache symbols for memoization
+const CloneUrlsSymbol = Symbol("cloneUrls");
+const WebUrlsSymbol = Symbol("webUrls");
+const MaintainersSymbol = Symbol("maintainers");
+const RepositoryRelaysSymbol = Symbol("repositoryRelays");
+const IssueLabelsSymbol = Symbol("issueLabels");
+const PatchSubjectSymbol = Symbol("patchSubject");
+const PatchCommitterSymbol = Symbol("patchCommitter");
+const IsPatchRootSymbol = Symbol("isPatchRoot");
+const IsPatchRootRevisionSymbol = Symbol("isPatchRootRevision");
+const PullRequestLabelsSymbol = Symbol("pullRequestLabels");
+const PullRequestCloneUrlsSymbol = Symbol("pullRequestCloneUrls");
+const RepositoryStateRefsSymbol = Symbol("repositoryStateRefs");
+const RepositoryStateBranchesSymbol = Symbol("repositoryStateBranches");
+const RepositoryStateTagsSymbol = Symbol("repositoryStateTags");
+const StatusRootEventIdSymbol = Symbol("statusRootEventId");
+const StatusRootRelayHintSymbol = Symbol("statusRootRelayHint");
+
// ============================================================================
// Repository Event Helpers (Kind 30617)
// ============================================================================
@@ -45,7 +69,9 @@ export function getRepositoryIdentifier(event: NostrEvent): string | undefined {
* @returns Array of clone URLs
*/
export function getCloneUrls(event: NostrEvent): string[] {
- return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
+ return getOrComputeCachedValue(event, CloneUrlsSymbol, () =>
+ event.tags.filter((t) => t[0] === "clone").map((t) => t[1]),
+ );
}
/**
@@ -54,7 +80,9 @@ export function getCloneUrls(event: NostrEvent): string[] {
* @returns Array of web URLs
*/
export function getWebUrls(event: NostrEvent): string[] {
- return event.tags.filter((t) => t[0] === "web").map((t) => t[1]);
+ return getOrComputeCachedValue(event, WebUrlsSymbol, () =>
+ event.tags.filter((t) => t[0] === "web").map((t) => t[1]),
+ );
}
/**
@@ -63,10 +91,12 @@ export function getWebUrls(event: NostrEvent): string[] {
* @returns Array of maintainer pubkeys
*/
export function getMaintainers(event: NostrEvent): string[] {
- return event.tags
- .filter((t) => t[0] === "maintainers")
- .map((t) => t[1])
- .filter((p: string) => p !== event.pubkey);
+ return getOrComputeCachedValue(event, MaintainersSymbol, () =>
+ event.tags
+ .filter((t) => t[0] === "maintainers")
+ .map((t) => t[1])
+ .filter((p: string) => p !== event.pubkey),
+ );
}
/**
@@ -75,10 +105,12 @@ export function getMaintainers(event: NostrEvent): string[] {
* @returns Array of relay URLs
*/
export function getRepositoryRelays(event: NostrEvent): string[] {
- const relaysTag = event.tags.find((t) => t[0] === "relays");
- if (!relaysTag) return [];
- const [, ...relays] = relaysTag;
- return relays;
+ return getOrComputeCachedValue(event, RepositoryRelaysSymbol, () => {
+ const relaysTag = event.tags.find((t) => t[0] === "relays");
+ if (!relaysTag) return [];
+ const [, ...relays] = relaysTag;
+ return relays;
+ });
}
// ============================================================================
@@ -100,7 +132,9 @@ export function getIssueTitle(event: NostrEvent): string | undefined {
* @returns Array of label strings
*/
export function getIssueLabels(event: NostrEvent): string[] {
- return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
+ return getOrComputeCachedValue(event, IssueLabelsSymbol, () =>
+ event.tags.filter((t) => t[0] === "t").map((t) => t[1]),
+ );
}
/**
@@ -133,18 +167,20 @@ export function getIssueRepositoryOwner(event: NostrEvent): string | undefined {
* @returns Patch subject/title or undefined
*/
export function getPatchSubject(event: NostrEvent): string | undefined {
- // Try subject tag first
- const subjectTag = getTagValue(event, "subject");
- if (subjectTag) return subjectTag;
+ return getOrComputeCachedValue(event, PatchSubjectSymbol, () => {
+ // Try subject tag first
+ const subjectTag = getTagValue(event, "subject");
+ if (subjectTag) return subjectTag;
- // Try to extract from content (first line or "Subject:" header from git format-patch)
- const content = event.content.trim();
- const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
- if (subjectMatch) return subjectMatch[1].trim();
+ // Try to extract from content (first line or "Subject:" header from git format-patch)
+ const content = event.content.trim();
+ const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
+ if (subjectMatch) return subjectMatch[1].trim();
- // Fallback to first line
- const firstLine = content.split("\n")[0];
- return firstLine?.length > 0 ? firstLine : undefined;
+ // Fallback to first line
+ const firstLine = content.split("\n")[0];
+ return firstLine?.length > 0 ? firstLine : undefined;
+ });
}
/**
@@ -175,11 +211,13 @@ export function getPatchCommitter(
):
| { name: string; email: string; timestamp: string; timezone: string }
| undefined {
- const committerTag = event.tags.find((t) => t[0] === "committer");
- if (!committerTag || committerTag.length < 5) return undefined;
+ return getOrComputeCachedValue(event, PatchCommitterSymbol, () => {
+ const committerTag = event.tags.find((t) => t[0] === "committer");
+ if (!committerTag || committerTag.length < 5) return undefined;
- const [, name, email, timestamp, timezone] = committerTag;
- return { name, email, timestamp, timezone };
+ const [, name, email, timestamp, timezone] = committerTag;
+ return { name, email, timestamp, timezone };
+ });
}
/**
@@ -199,7 +237,9 @@ export function getPatchRepositoryAddress(
* @returns True if this is a root patch
*/
export function isPatchRoot(event: NostrEvent): boolean {
- return event.tags.some((t) => t[0] === "t" && t[1] === "root");
+ return getOrComputeCachedValue(event, IsPatchRootSymbol, () =>
+ event.tags.some((t) => t[0] === "t" && t[1] === "root"),
+ );
}
/**
@@ -208,7 +248,9 @@ export function isPatchRoot(event: NostrEvent): boolean {
* @returns True if this is a root revision
*/
export function isPatchRootRevision(event: NostrEvent): boolean {
- return event.tags.some((t) => t[0] === "t" && t[1] === "root-revision");
+ return getOrComputeCachedValue(event, IsPatchRootRevisionSymbol, () =>
+ event.tags.some((t) => t[0] === "t" && t[1] === "root-revision"),
+ );
}
// ============================================================================
@@ -230,7 +272,9 @@ export function getPullRequestSubject(event: NostrEvent): string | undefined {
* @returns Array of label strings
*/
export function getPullRequestLabels(event: NostrEvent): string[] {
- return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
+ return getOrComputeCachedValue(event, PullRequestLabelsSymbol, () =>
+ event.tags.filter((t) => t[0] === "t").map((t) => t[1]),
+ );
}
/**
@@ -248,7 +292,9 @@ export function getPullRequestCommitId(event: NostrEvent): string | undefined {
* @returns Array of clone URLs
*/
export function getPullRequestCloneUrls(event: NostrEvent): string[] {
- return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
+ return getOrComputeCachedValue(event, PullRequestCloneUrlsSymbol, () =>
+ event.tags.filter((t) => t[0] === "clone").map((t) => t[1]),
+ );
}
/**
@@ -316,9 +362,11 @@ export function parseHeadBranch(
export function getRepositoryStateRefs(
event: NostrEvent,
): Array<{ ref: string; hash: string }> {
- return event.tags
- .filter((t) => t[0].startsWith("refs/"))
- .map((t) => ({ ref: t[0], hash: t[1] }));
+ return getOrComputeCachedValue(event, RepositoryStateRefsSymbol, () =>
+ event.tags
+ .filter((t) => t[0].startsWith("refs/"))
+ .map((t) => ({ ref: t[0], hash: t[1] })),
+ );
}
/**
@@ -347,12 +395,14 @@ export function getRepositoryStateHeadCommit(
export function getRepositoryStateBranches(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
- return event.tags
- .filter((t) => t[0].startsWith("refs/heads/"))
- .map((t) => ({
- name: t[0].replace("refs/heads/", ""),
- hash: t[1],
- }));
+ return getOrComputeCachedValue(event, RepositoryStateBranchesSymbol, () =>
+ event.tags
+ .filter((t) => t[0].startsWith("refs/heads/"))
+ .map((t) => ({
+ name: t[0].replace("refs/heads/", ""),
+ hash: t[1],
+ })),
+ );
}
/**
@@ -363,10 +413,176 @@ export function getRepositoryStateBranches(
export function getRepositoryStateTags(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
- return event.tags
- .filter((t) => t[0].startsWith("refs/tags/"))
- .map((t) => ({
- name: t[0].replace("refs/tags/", ""),
- hash: t[1],
- }));
+ return getOrComputeCachedValue(event, RepositoryStateTagsSymbol, () =>
+ event.tags
+ .filter((t) => t[0].startsWith("refs/tags/"))
+ .map((t) => ({
+ name: t[0].replace("refs/tags/", ""),
+ hash: t[1],
+ })),
+ );
+}
+
+// ============================================================================
+// Status Event Helpers (Kind 1630-1633)
+// ============================================================================
+
+/**
+ * Status types for NIP-34 status events
+ */
+export type IssueStatusType = "open" | "resolved" | "closed" | "draft";
+
+/**
+ * Map kind numbers to status types
+ */
+export const STATUS_KIND_MAP: Record = {
+ 1630: "open",
+ 1631: "resolved",
+ 1632: "closed",
+ 1633: "draft",
+};
+
+/**
+ * Get the status type from a status event kind
+ * @param kind Event kind (1630-1633)
+ * @returns Status type or undefined if not a status kind
+ */
+export function getStatusType(kind: number): IssueStatusType | undefined {
+ return STATUS_KIND_MAP[kind];
+}
+
+/**
+ * Get the root event ID being referenced by a status event
+ * The root is the original issue/patch/PR being marked with a status
+ * @param event Status event (kind 1630-1633)
+ * @returns Event ID or undefined
+ */
+export function getStatusRootEventId(event: NostrEvent): string | undefined {
+ return getOrComputeCachedValue(event, StatusRootEventIdSymbol, () => {
+ // Look for e tag with "root" marker
+ const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
+ if (rootTag) return rootTag[1];
+
+ // Fallback: first e tag without a marker or with empty marker
+ const firstETag = event.tags.find((t) => t[0] === "e");
+ return firstETag?.[1];
+ });
+}
+
+/**
+ * Get the relay hint for the root event
+ * @param event Status event (kind 1630-1633)
+ * @returns Relay URL or undefined
+ */
+export function getStatusRootRelayHint(event: NostrEvent): string | undefined {
+ return getOrComputeCachedValue(event, StatusRootRelayHintSymbol, () => {
+ const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
+ if (rootTag && rootTag[2]) return rootTag[2];
+
+ const firstETag = event.tags.find((t) => t[0] === "e");
+ return firstETag?.[2] || undefined;
+ });
+}
+
+/**
+ * Get the repository address from a status event
+ * @param event Status event (kind 1630-1633)
+ * @returns Repository address (a tag) or undefined
+ */
+export function getStatusRepositoryAddress(
+ event: NostrEvent,
+): string | undefined {
+ return getTagValue(event, "a");
+}
+
+/**
+ * Check if a kind is a status event kind
+ * @param kind Event kind
+ * @returns True if kind is 1630-1633
+ */
+export function isStatusKind(kind: number): boolean {
+ return kind >= 1630 && kind <= 1633;
+}
+
+/**
+ * Get human-readable status label
+ * @param kind Event kind (1630-1633)
+ * @param forIssue Whether this is for an issue (vs patch/PR)
+ * @returns Label string
+ */
+export function getStatusLabel(kind: number, forIssue = true): string {
+ switch (kind) {
+ case 1630:
+ return "opened";
+ case 1631:
+ return forIssue ? "resolved" : "merged";
+ case 1632:
+ return "closed";
+ case 1633:
+ return "marked as draft";
+ default:
+ return "updated";
+ }
+}
+
+/**
+ * Get all valid pubkeys that can set status for an issue/patch/PR
+ * Valid authors: event author, repository owner (from p tag), and all maintainers
+ * @param event Issue, patch, or PR event
+ * @param repositoryEvent Optional repository event to get maintainers from
+ * @returns Set of valid pubkeys
+ */
+export function getValidStatusAuthors(
+ event: NostrEvent,
+ repositoryEvent?: NostrEvent,
+): Set {
+ const validPubkeys = new Set();
+
+ // Event author can always set status
+ validPubkeys.add(event.pubkey);
+
+ // Repository owner from p tag
+ const repoOwner = getTagValue(event, "p");
+ if (repoOwner) validPubkeys.add(repoOwner);
+
+ // Parse repository address to get owner pubkey using applesauce helper
+ const repoAddress =
+ getIssueRepositoryAddress(event) ||
+ getPatchRepositoryAddress(event) ||
+ getPullRequestRepositoryAddress(event);
+ if (repoAddress) {
+ const parsedRepo = parseReplaceableAddress(repoAddress);
+ if (parsedRepo?.pubkey) validPubkeys.add(parsedRepo.pubkey);
+ }
+
+ // Add maintainers from repository event
+ if (repositoryEvent) {
+ const maintainers = getMaintainers(repositoryEvent);
+ maintainers.forEach((m) => validPubkeys.add(m));
+ }
+
+ return validPubkeys;
+}
+
+/**
+ * Find the most recent valid status event from a list of status events
+ * Valid = from event author, repository owner, or maintainers
+ * @param statusEvents Array of status events (kinds 1630-1633)
+ * @param validAuthors Set of valid pubkeys (from getValidStatusAuthors)
+ * @returns Most recent valid status event or null
+ */
+export function findCurrentStatus(
+ statusEvents: NostrEvent[],
+ validAuthors: Set,
+): NostrEvent | null {
+ if (statusEvents.length === 0) return null;
+
+ // Sort by created_at descending (most recent first)
+ const sorted = [...statusEvents].sort((a, b) => b.created_at - a.created_at);
+
+ // Find the most recent status from a valid author
+ const validStatus = sorted.find((s) => validAuthors.has(s.pubkey));
+
+ // Return valid status if found, otherwise most recent (may be invalid but show anyway)
+ return validStatus || sorted[0];
}