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");
}