mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 15:07:10 +02:00
refactor: unify nostr markdown rendering
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user