From 5dd885b888e50bacf604904f97b8e08b9c9c71ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Mon, 15 Dec 2025 15:42:11 +0100 Subject: [PATCH] feat: community NIPs --- src/components/EventDetailViewer.tsx | 3 + src/components/SyntaxHighlight.tsx | 2 +- src/components/WindowTitle.tsx | 3 +- src/components/nostr/MarkdownContent.tsx | 299 ++++++++++++++++++ .../nostr/kinds/ArticleDetailRenderer.tsx | 271 +--------------- .../kinds/CommunityNIPDetailRenderer.tsx | 54 ++++ .../nostr/kinds/CommunityNIPRenderer.tsx | 28 ++ src/components/nostr/kinds/IssueRenderer.tsx | 46 +-- .../nostr/kinds/PullRequestRenderer.tsx | 37 +-- src/components/nostr/kinds/index.tsx | 2 + src/components/ui/Label.tsx | 2 +- src/constants/kinds.ts | 7 + src/lib/nip34-helpers.ts | 4 +- 13 files changed, 452 insertions(+), 306 deletions(-) create mode 100644 src/components/nostr/MarkdownContent.tsx create mode 100644 src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/CommunityNIPRenderer.tsx diff --git a/src/components/EventDetailViewer.tsx b/src/components/EventDetailViewer.tsx index 10a075e..f832d03 100644 --- a/src/components/EventDetailViewer.tsx +++ b/src/components/EventDetailViewer.tsx @@ -11,6 +11,7 @@ import { Kind1337DetailRenderer } from "./nostr/kinds/CodeSnippetDetailRenderer" import { Kind9802DetailRenderer } from "./nostr/kinds/HighlightDetailRenderer"; import { Kind10002DetailRenderer } from "./nostr/kinds/RelayListDetailRenderer"; import { Kind30023DetailRenderer } from "./nostr/kinds/ArticleDetailRenderer"; +import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRenderer"; import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer"; import { JsonViewer } from "./JsonViewer"; import { RelayLink } from "./nostr/RelayLink"; @@ -284,6 +285,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) { ) : event.kind === kinds.LongFormArticle ? ( + ) : event.kind === 30817 ? ( + ) : event.kind === 30617 ? ( ) : ( diff --git a/src/components/SyntaxHighlight.tsx b/src/components/SyntaxHighlight.tsx index 9ca5f92..df2db8c 100644 --- a/src/components/SyntaxHighlight.tsx +++ b/src/components/SyntaxHighlight.tsx @@ -63,7 +63,7 @@ export function SyntaxHighlight({ return (
       
         {code}
diff --git a/src/components/WindowTitle.tsx b/src/components/WindowTitle.tsx
index c5c8a8f..3a34d61 100644
--- a/src/components/WindowTitle.tsx
+++ b/src/components/WindowTitle.tsx
@@ -25,7 +25,8 @@ export function WindowTile({
 
   // Convert title to string for MosaicWindow (which only accepts strings)
   // The actual title (with React elements) is rendered in the custom toolbar
-  const titleString = typeof title === "string" ? title : tooltip || window.title;
+  const titleString =
+    typeof title === "string" ? title : tooltip || window.title;
 
   // Custom toolbar renderer to include icon
   const renderToolbar = () => {
diff --git a/src/components/nostr/MarkdownContent.tsx b/src/components/nostr/MarkdownContent.tsx
new file mode 100644
index 0000000..f967a2d
--- /dev/null
+++ b/src/components/nostr/MarkdownContent.tsx
@@ -0,0 +1,299 @@
+import { useMemo } from "react";
+import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { remarkNostrMentions } from "applesauce-content/markdown";
+import { nip19 } from "nostr-tools";
+import { UserName } from "./UserName";
+import { EmbeddedEvent } from "./EmbeddedEvent";
+import { MediaEmbed } from "./MediaEmbed";
+import { SyntaxHighlight } from "@/components/SyntaxHighlight";
+import { CodeCopyButton } from "@/components/CodeCopyButton";
+import { useCopy } from "@/hooks/useCopy";
+import { useGrimoire } from "@/core/state";
+
+/**
+ * Component to render nostr: mentions inline
+ */
+function NostrMention({ href }: { href: string }) {
+  const { addWindow } = useGrimoire();
+
+  try {
+    // Remove nostr: prefix and any trailing characters
+    const cleanHref = href.replace(/^nostr:/, "").trim();
+
+    // If it doesn't look like a nostr identifier, just return the href as-is
+    if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
+      return (
+        
+          {href}
+        
+      );
+    }
+
+    const parsed = nip19.decode(cleanHref);
+
+    switch (parsed.type) {
+      case "npub":
+        return (
+          
+            
+          
+        );
+      case "nprofile":
+        return (
+          
+            
+          
+        );
+      case "note":
+        return (
+           {
+              addWindow(
+                "open",
+                { id: id as string },
+                `Event ${(id as string).slice(0, 8)}...`,
+              );
+            }}
+          />
+        );
+      case "nevent":
+        return (
+           {
+              addWindow(
+                "open",
+                { id: id as string },
+                `Event ${(id as string).slice(0, 8)}...`,
+              );
+            }}
+          />
+        );
+      case "naddr":
+        return (
+           {
+              addWindow(
+                "open",
+                pointer,
+                `${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
+              );
+            }}
+          />
+        );
+      default:
+        return {cleanHref};
+    }
+  } catch (error) {
+    // If parsing fails, just render as a regular link
+    console.error("Failed to parse nostr link:", href, error);
+    return (
+      
+        {href}
+      
+    );
+  }
+}
+
+/**
+ * Code block wrapper with copy button
+ * Renders syntax-highlighted code or plain code with a copy button
+ */
+function CodeBlock({
+  code,
+  language,
+}: {
+  code: string;
+  language: string | null;
+}) {
+  const { copy, copied } = useCopy();
+
+  return (
+    
+ {language ? ( + + ) : ( +
+          {code}
+        
+ )} + copy(code)} copied={copied} /> +
+ ); +} + +export interface MarkdownContentProps { + content: string; + canonicalUrl?: string | null; +} + +/** + * Shared markdown renderer for Nostr content (articles, NIPs, etc.) + * Handles nostr: mentions, syntax highlighting, media embeds, and relative URLs + */ +export function MarkdownContent({ + content, + canonicalUrl = null, +}: MarkdownContentProps) { + // Helper to resolve relative URLs using canonical URL as base + const resolveUrl = useMemo( + () => + (url: string): string | null => { + // If it's already absolute, return as-is + if (url.match(/^https?:\/\//)) { + return url; + } + + // If we have a canonical URL, try to resolve relative URLs + if (canonicalUrl) { + try { + return new URL(url, canonicalUrl).toString(); + } catch { + console.warn("Failed to resolve relative URL:", url); + return null; + } + } + + // No canonical URL and it's relative - can't resolve + return null; + }, + [canonicalUrl], + ); + + return ( +
+ { + if (url.startsWith("nostr:")) return url; + return defaultUrlTransform(url); + }} + components={{ + // Enable images with zoom + img: ({ src, alt }) => { + if (!src) return null; + + const resolvedUrl = resolveUrl(src); + if (!resolvedUrl) { + // Can't resolve URL - show fallback + return ( +
+

Media unavailable (relative URL without base)

+

{src}

+
+ ); + } + + return ( + + ); + }, + // Handle nostr: links + a: ({ href, children, ...props }) => { + if (!href) return null; + + // Render nostr: mentions inline + if (href.startsWith("nostr:")) { + return ; + } + + // Regular links + return ( + + {children} + + ); + }, + // Don't render pre wrapper when we have a CodeBlock (it has its own container) + pre: ({ children }) => <>{children}, + // Style adjustments for dark theme + h1: ({ ...props }) => ( +

+ ), + h2: ({ ...props }) => ( +

+ ), + h3: ({ ...props }) => ( +

+ ), + p: ({ ...props }) => ( +

+ ), + code: ({ className, children, ...props }: any) => { + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : null; + const code = String(children).replace(/\n$/, ""); + + // Inline code (no language) + if (!language) { + return ( + + {children} + + ); + } + + // Block code with syntax highlighting and copy button + return ; + }, + blockquote: ({ ...props }) => ( +

+ ), + ul: ({ ...props }) => ( +
    + ), + ol: ({ ...props }) => ( +
      + ), + hr: () =>
      , + }} + > + {content.replace(/\\n/g, "\n")} + +

+ ); +} diff --git a/src/components/nostr/kinds/ArticleDetailRenderer.tsx b/src/components/nostr/kinds/ArticleDetailRenderer.tsx index 6e512aa..6eeb0f2 100644 --- a/src/components/nostr/kinds/ArticleDetailRenderer.tsx +++ b/src/components/nostr/kinds/ArticleDetailRenderer.tsx @@ -1,8 +1,4 @@ import { useMemo } from "react"; -import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { remarkNostrMentions } from "applesauce-content/markdown"; -import { nip19 } from "nostr-tools"; import { getArticleTitle, getArticleSummary, @@ -10,115 +6,10 @@ import { getArticleImage, } from "applesauce-core/helpers/article"; import { UserName } from "../UserName"; -import { EmbeddedEvent } from "../EmbeddedEvent"; import { MediaEmbed } from "../MediaEmbed"; -import { SyntaxHighlight } from "@/components/SyntaxHighlight"; -import { useGrimoire } from "@/core/state"; +import { MarkdownContent } from "../MarkdownContent"; import type { NostrEvent } from "@/types/nostr"; -/** - * Component to render nostr: mentions inline - */ -function NostrMention({ href }: { href: string }) { - const { addWindow } = useGrimoire(); - - try { - // Remove nostr: prefix and any trailing characters - const cleanHref = href.replace(/^nostr:/, "").trim(); - - // If it doesn't look like a nostr identifier, just return the href as-is - if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) { - return ( - - {href} - - ); - } - - const parsed = nip19.decode(cleanHref); - - switch (parsed.type) { - case "npub": - return ( - - - - ); - case "nprofile": - return ( - - - - ); - case "note": - return ( - { - addWindow( - "open", - { id: id as string }, - `Event ${(id as string).slice(0, 8)}...`, - ); - }} - /> - ); - case "nevent": - return ( - { - addWindow( - "open", - { id: id as string }, - `Event ${(id as string).slice(0, 8)}...`, - ); - }} - /> - ); - case "naddr": - return ( - { - addWindow( - "open", - pointer, - `${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`, - ); - }} - /> - ); - default: - return {cleanHref}; - } - } catch (error) { - // If parsing fails, just render as a regular link - console.error("Failed to parse nostr link:", href, error); - return ( - - {href} - - ); - } -} - /** * Detail renderer for Kind 30023 - Long-form Article * Displays full markdown content with metadata @@ -135,27 +26,6 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) { return rTag?.[1] || null; }, [event]); - // Helper to resolve relative URLs using canonical URL as base - const resolveUrl = (url: string): string | null => { - // If it's already absolute, return as-is - if (url.match(/^https?:\/\//)) { - return url; - } - - // If we have a canonical URL, try to resolve relative URLs - if (canonicalUrl) { - try { - return new URL(url, canonicalUrl).toString(); - } catch { - console.warn("Failed to resolve relative URL:", url); - return null; - } - } - - // No canonical URL and it's relative - can't resolve - return null; - }; - // Format published date const publishedDate = published ? new Date(published * 1000).toLocaleDateString("en-US", { @@ -166,7 +36,18 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) { : null; // Resolve article image URL - const resolvedImageUrl = imageUrl ? resolveUrl(imageUrl) : null; + const resolvedImageUrl = useMemo(() => { + if (!imageUrl) return null; + if (imageUrl.match(/^https?:\/\//)) return imageUrl; + if (canonicalUrl) { + try { + return new URL(imageUrl, canonicalUrl).toString(); + } catch { + return null; + } + } + return null; + }, [imageUrl, canonicalUrl]); return (
@@ -204,131 +85,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) { {/* Article Content - Markdown */} -
- { - if (url.startsWith("nostr:")) return url; - return defaultUrlTransform(url); - }} - components={{ - // Enable images with zoom - img: ({ src, alt }) => { - if (!src) return null; - - const resolvedUrl = resolveUrl(src); - if (!resolvedUrl) { - // Can't resolve URL - show fallback - return ( -
-

Media unavailable (relative URL without base)

-

{src}

-
- ); - } - - return ( - - ); - }, - // Handle nostr: links - a: ({ href, children, ...props }) => { - if (!href) return null; - - // Render nostr: mentions inline - if (href.startsWith("nostr:")) { - return ; - } - - // Regular links - return ( - - {children} - - ); - }, - // Make pre elements display inline - pre: ({ children, ...props }) => ( - - {children} - - ), - // Style adjustments for dark theme - h1: ({ ...props }) => ( -

- ), - h2: ({ ...props }) => ( -

- ), - h3: ({ ...props }) => ( -

- ), - p: ({ ...props }) => ( -

- ), - code: ({ className, children, ...props }: any) => { - const match = /language-(\w+)/.exec(className || ""); - const language = match ? match[1] : null; - const code = String(children).replace(/\n$/, ""); - - // Inline code (no language) - if (!language) { - return ( - - {children} - - ); - } - - // Block code with syntax highlighting - return ( - - ); - }, - blockquote: ({ ...props }) => ( -

- ), - ul: ({ ...props }) => ( -
    - ), - ol: ({ ...props }) => ( -
      - ), - hr: () =>
      , - }} - > - {event.content.replace(/\\n/g, '\n')} - -

+
); } diff --git a/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx new file mode 100644 index 0000000..1a7c736 --- /dev/null +++ b/src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { getTagValue } from "applesauce-core/helpers"; +import { UserName } from "../UserName"; +import { MarkdownContent } from "../MarkdownContent"; +import type { NostrEvent } from "@/types/nostr"; + +/** + * Detail renderer for Kind 30817 - Community NIP + * Displays full markdown content with NIP-specific metadata + */ +export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) { + const title = useMemo( + () => getTagValue(event, "title") || "Untitled NIP", + [event], + ); + + // Get canonical URL from "r" tag to resolve relative URLs + const canonicalUrl = useMemo(() => { + 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", + }, + ); + + return ( +
+ {/* NIP Header */} +
+ {/* Title */} +

{title}

+ + {/* Metadata */} +
+
+ Proposed by + +
+ + +
+
+ + {/* NIP Content - Markdown */} + +
+ ); +} diff --git a/src/components/nostr/kinds/CommunityNIPRenderer.tsx b/src/components/nostr/kinds/CommunityNIPRenderer.tsx new file mode 100644 index 0000000..c10c2fd --- /dev/null +++ b/src/components/nostr/kinds/CommunityNIPRenderer.tsx @@ -0,0 +1,28 @@ +import { + BaseEventContainer, + BaseEventProps, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { getArticleTitle } from "applesauce-core/helpers"; + +/** + * Renderer for Kind 30817 - Community NIP + * Displays NIP identifier, title and summary in feed + */ +export function CommunityNIPRenderer({ event }: BaseEventProps) { + const title = getArticleTitle(event); + + return ( + +
+ + {title} + +
+
+ ); +} diff --git a/src/components/nostr/kinds/IssueRenderer.tsx b/src/components/nostr/kinds/IssueRenderer.tsx index 53fa086..a4a4652 100644 --- a/src/components/nostr/kinds/IssueRenderer.tsx +++ b/src/components/nostr/kinds/IssueRenderer.tsx @@ -15,7 +15,6 @@ import { getRepositoryName, getRepositoryIdentifier, } from "@/lib/nip34-helpers"; -import { UserName } from "../UserName"; import { Label } from "@/components/ui/Label"; /** @@ -70,37 +69,38 @@ export function IssueRenderer({ event }: BaseEventProps) { return (
- {/* Issue Title */} - - {title || "Untitled Issue"} - +
+ {/* Issue Title */} + + {title || "Untitled Issue"} + - {/* Repository Reference */} - {repoAddress && repoPointer && ( -
- in -
+
- - {repoName} + > + + {repoName} +
- by - -
- )} + )} +
{/* Labels */} {labels.length > 0 && (
{labels.map((label, idx) => ( diff --git a/src/components/nostr/kinds/PullRequestRenderer.tsx b/src/components/nostr/kinds/PullRequestRenderer.tsx index c6ba33f..0d37622 100644 --- a/src/components/nostr/kinds/PullRequestRenderer.tsx +++ b/src/components/nostr/kinds/PullRequestRenderer.tsx @@ -3,7 +3,7 @@ import { type BaseEventProps, ClickableEventTitle, } from "./BaseEventRenderer"; -import { FolderGit2 } from "lucide-react"; +import { FolderGit2, GitBranch } from "lucide-react"; import { useGrimoire } from "@/core/state"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { @@ -73,38 +73,31 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
{/* PR Title */} -
- - {subject || "Untitled Pull Request"} - -
+ + {subject || "Untitled Pull Request"} + - {/* Metadata */} -
- in +
{/* Repository */} {repoAddress && repoPointer && (
- + {repoName}
)} - {/* Branch Name */} {branchName && ( - <> - - - {branchName} - - +
+ + {branchName} +
)}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index a7e8767..f357020 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -16,6 +16,7 @@ import { Kind9735Renderer } from "./ZapReceiptRenderer"; import { Kind9802Renderer } from "./HighlightRenderer"; import { Kind10002Renderer } from "./RelayListRenderer"; import { Kind30023Renderer } from "./ArticleRenderer"; +import { CommunityNIPRenderer } from "./CommunityNIPRenderer"; import { RepositoryRenderer } from "./RepositoryRenderer"; import { Kind39701Renderer } from "./BookmarkRenderer"; import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; @@ -52,6 +53,7 @@ const kindRenderers: Record> = { 10050: GenericRelayListRenderer, // DM Relay List (NIP-51) 30002: GenericRelayListRenderer, // Relay Sets (NIP-51) 30023: Kind30023Renderer, // Long-form Article + 30817: CommunityNIPRenderer, // Community NIP 30617: RepositoryRenderer, // Repository (NIP-34) 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx index c775e90..226b95c 100644 --- a/src/components/ui/Label.tsx +++ b/src/components/ui/Label.tsx @@ -19,7 +19,7 @@ export function Label({ children, className, size = "sm" }: LabelProps) { return ( = { nip: "34", icon: FolderGit2, }, + 30817: { + kind: 30817, + name: "Community NIP", + description: "Community-published NIP document", + nip: "", + icon: FileText, + }, 30818: { kind: 30818, name: "Wiki", diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index 8ac4885..97aa0e7 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -256,7 +256,9 @@ export function getPullRequestCloneUrls(event: NostrEvent): string[] { * @param event PR event (kind 1618) * @returns Branch name or undefined */ -export function getPullRequestBranchName(event: NostrEvent): string | undefined { +export function getPullRequestBranchName( + event: NostrEvent, +): string | undefined { return getTagValue(event, "branch-name"); }