refactor: unify nostr markdown rendering

This commit is contained in:
Alejandro Gómez
2025-12-15 15:54:45 +01:00
parent 5dd885b888
commit 6ff877e8a5
2 changed files with 8 additions and 429 deletions

View File

@@ -1,13 +1,7 @@
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 { Tag, FolderGit2 } from "lucide-react";
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { MediaEmbed } from "../MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { MarkdownContent } from "../MarkdownContent";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import type { NostrEvent } from "@/types/nostr";
@@ -15,127 +9,23 @@ import {
getIssueTitle,
getIssueLabels,
getIssueRepositoryAddress,
} from "@/lib/nip34-helpers";
import {
getRepositoryName,
getRepositoryIdentifier,
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
/**
* 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 (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
const parsed = nip19.decode(cleanHref);
switch (parsed.type) {
case "npub":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data}
className="text-accent font-semibold"
/>
</span>
);
case "nprofile":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data.pubkey}
className="text-accent font-semibold"
/>
</span>
);
case "note":
return (
<EmbeddedEvent
eventId={parsed.data}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "nevent":
return (
<EmbeddedEvent
eventId={parsed.data.id}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "naddr":
return (
<EmbeddedEvent
addressPointer={parsed.data}
onOpen={(pointer) => {
addWindow(
"open",
pointer,
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
);
}}
/>
);
default:
return <span className="text-muted-foreground">{cleanHref}</span>;
}
} catch (error) {
// If parsing fails, just render as a regular link
console.error("Failed to parse nostr link:", href, error);
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
}
/**
* Detail renderer for Kind 1621 - Issue
* Displays full issue content with markdown rendering
* Detail renderer for Kind 1621 - Issue (NIP-34)
* Full view with repository context and markdown description
*/
export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const title = useMemo(() => getIssueTitle(event), [event]);
const labels = useMemo(() => getIssueLabels(event), [event]);
const repoAddress = useMemo(() => getIssueRepositoryAddress(event), [event]);
// Parse repository address
// Parse repository address if present
const repoPointer = useMemo(() => {
if (!repoAddress) return null;
try {
@@ -226,111 +116,7 @@ export function IssueDetailRenderer({ event }: { event: NostrEvent }) {
{/* Issue Body - Markdown */}
{event.content ? (
<article className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
skipHtml
urlTransform={(url) => {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}}
components={{
// Enable images with zoom
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
) : null,
// Handle nostr: links
a: ({ href, children, ...props }) => {
if (!href) return null;
// Render nostr: mentions inline
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
// Regular links
return (
<a
href={href}
className="text-accent underline decoration-dotted"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
// Style adjustments for dark theme
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...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 (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting
return (
<SyntaxHighlight
code={code}
language={language as any}
className="my-4"
/>
);
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: ({ ...props }) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}}
>
{event.content.replace(/\\n/g, "\n")}
</ReactMarkdown>
</article>
<MarkdownContent content={event.content} />
) : (
<p className="text-sm text-muted-foreground italic">
(No description provided)

View File

@@ -1,13 +1,7 @@
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 { GitBranch, FolderGit2, Tag, Copy, CopyCheck } from "lucide-react";
import { UserName } from "../UserName";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { MediaEmbed } from "../MediaEmbed";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { MarkdownContent } from "../MarkdownContent";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
@@ -27,106 +21,6 @@ import {
} from "@/lib/nip34-helpers";
import { Label } from "@/components/ui/Label";
/**
* Component to render nostr: mentions inline
*/
function NostrMention({ href }: { href: string }) {
const { addWindow } = useGrimoire();
try {
const cleanHref = href.replace(/^nostr:/, "").trim();
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
const parsed = nip19.decode(cleanHref);
switch (parsed.type) {
case "npub":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data}
className="text-accent font-semibold"
/>
</span>
);
case "nprofile":
return (
<span className="inline-flex items-center">
<UserName
pubkey={parsed.data.pubkey}
className="text-accent font-semibold"
/>
</span>
);
case "note":
return (
<EmbeddedEvent
eventId={parsed.data}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "nevent":
return (
<EmbeddedEvent
eventId={parsed.data.id}
onOpen={(id) => {
addWindow(
"open",
{ id: id as string },
`Event ${(id as string).slice(0, 8)}...`,
);
}}
/>
);
case "naddr":
return (
<EmbeddedEvent
addressPointer={parsed.data}
onOpen={(pointer) => {
addWindow(
"open",
pointer,
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
);
}}
/>
);
default:
return <span className="text-muted-foreground">{cleanHref}</span>;
}
} catch (error) {
console.error("Failed to parse nostr link:", href, error);
return (
<a
href={href}
className="text-accent underline decoration-dotted break-all"
target="_blank"
rel="noopener noreferrer"
>
{href}
</a>
);
}
}
/**
* Detail renderer for Kind 1618 - Pull Request
* Displays full PR content with markdown rendering
@@ -344,108 +238,7 @@ export function PullRequestDetailRenderer({ event }: { event: NostrEvent }) {
{/* PR Description - Markdown */}
{event.content ? (
<>
<article className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
skipHtml
urlTransform={(url) => {
if (url.startsWith("nostr:")) return url;
return defaultUrlTransform(url);
}}
components={{
img: ({ src, alt }) =>
src ? (
<MediaEmbed
url={src}
alt={alt}
preset="preview"
enableZoom
className="my-4"
/>
) : null,
a: ({ href, children, ...props }) => {
if (!href) return null;
if (href.startsWith("nostr:")) {
return <NostrMention href={href} />;
}
return (
<a
href={href}
className="text-accent underline decoration-dotted"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
h1: ({ ...props }) => (
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
),
h2: ({ ...props }) => (
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
),
h3: ({ ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
),
p: ({ ...props }) => (
<p className="text-sm leading-relaxed mb-4" {...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 (
<code
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
{...props}
>
{children}
</code>
);
}
// Block code with syntax highlighting
return (
<SyntaxHighlight
code={code}
language={language as any}
className="my-4"
/>
);
},
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
{...props}
/>
),
ul: ({ ...props }) => (
<ul
className="text-sm list-disc list-inside my-4 space-y-2"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="text-sm list-decimal list-inside my-4 space-y-2"
{...props}
/>
),
hr: () => <hr className="my-4" />,
}}
>
{event.content.replace(/\\n/g, "\n")}
</ReactMarkdown>
</article>
</>
<MarkdownContent content={event.content} />
) : (
<p className="text-sm text-muted-foreground italic">
(No description provided)