feat: long form articles
This commit is contained in:
30
src/components/Note/LongFormArticle/NostrNode.tsx
Normal file
30
src/components/Note/LongFormArticle/NostrNode.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { ComponentProps, useMemo } from 'react'
|
||||
import { Components } from './types'
|
||||
|
||||
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) {
|
||||
const { type, id } = useMemo(() => {
|
||||
if (!bech32Id) return { type: 'invalid', id: '' }
|
||||
console.log('NostrLink bech32Id:', bech32Id)
|
||||
try {
|
||||
const { type } = nip19.decode(bech32Id)
|
||||
if (type === 'npub') {
|
||||
return { type: 'mention', id: bech32Id }
|
||||
}
|
||||
if (type === 'nevent' || type === 'naddr' || type === 'note') {
|
||||
return { type: 'note', id: bech32Id }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid bech32 ID:', bech32Id, error)
|
||||
}
|
||||
return { type: 'invalid', id: '' }
|
||||
}, [bech32Id])
|
||||
|
||||
if (type === 'invalid') return rawText
|
||||
|
||||
if (type === 'mention') {
|
||||
return <EmbeddedMention userId={id} className="not-prose" />
|
||||
}
|
||||
return <EmbeddedNote noteId={id} className="not-prose" />
|
||||
}
|
||||
60
src/components/Note/LongFormArticle/index.tsx
Normal file
60
src/components/Note/LongFormArticle/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import ImageWithLightbox from '@/components/ImageWithLightbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import NostrNode from './NostrNode'
|
||||
import { remarkNostr } from './remarkNostr'
|
||||
import { Components } from './types'
|
||||
|
||||
export default function LongFormArticle({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
}) {
|
||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={`prose prose-zinc max-w-none dark:prose-invert ${className || ''}`}>
|
||||
<h1>{metadata.title}</h1>
|
||||
{metadata.summary && (
|
||||
<blockquote>
|
||||
<p>{metadata.summary}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
{metadata.tags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{metadata.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metadata.image && (
|
||||
<ImageWithLightbox
|
||||
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||
className="w-full aspect-[3/1] object-cover rounded-lg"
|
||||
/>
|
||||
)}
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkNostr]}
|
||||
components={
|
||||
{
|
||||
nostr: (props) => <NostrNode {...props} />,
|
||||
img: ({ src, ...props }) => (
|
||||
<ImageWithLightbox image={{ url: src ?? '', pubkey: event.pubkey }} {...props} />
|
||||
),
|
||||
a: (props) => <a {...props} target="_blank" rel="noreferrer noopener" />
|
||||
} as Components
|
||||
}
|
||||
>
|
||||
{event.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
src/components/Note/LongFormArticle/remarkNostr.ts
Normal file
90
src/components/Note/LongFormArticle/remarkNostr.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { PhrasingContent, Root, Text } from 'mdast'
|
||||
import type { Plugin } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { NostrNode } from './types'
|
||||
|
||||
const NOSTR_REGEX =
|
||||
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
const NOSTR_REFERENCE_REGEX =
|
||||
/\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g
|
||||
|
||||
export const remarkNostr: Plugin<[], Root> = () => {
|
||||
return (tree) => {
|
||||
visit(tree, 'text', (node: Text, index, parent) => {
|
||||
if (!parent || typeof index !== 'number') return
|
||||
|
||||
const text = node.value
|
||||
|
||||
// First, handle reference-style nostr links [text][nostr:...]
|
||||
const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX))
|
||||
// Then, handle direct nostr links that are not part of reference links
|
||||
const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => {
|
||||
return !refMatches.some(
|
||||
(refMatch) =>
|
||||
directMatch.index! >= refMatch.index! &&
|
||||
directMatch.index! < refMatch.index! + refMatch[0].length
|
||||
)
|
||||
})
|
||||
|
||||
// Combine and sort matches by position
|
||||
const allMatches = [
|
||||
...refMatches.map((match) => ({
|
||||
...match,
|
||||
type: 'reference' as const,
|
||||
bech32Id: match[2],
|
||||
rawText: match[0]
|
||||
})),
|
||||
...directMatches.map((match) => ({
|
||||
...match,
|
||||
type: 'direct' as const,
|
||||
bech32Id: match[1],
|
||||
rawText: match[0]
|
||||
}))
|
||||
].sort((a, b) => a.index! - b.index!)
|
||||
|
||||
if (allMatches.length === 0) return
|
||||
|
||||
const children: (Text | NostrNode)[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
allMatches.forEach((match) => {
|
||||
const matchStart = match.index!
|
||||
const matchEnd = matchStart + match[0].length
|
||||
|
||||
// Add text before the match
|
||||
if (matchStart > lastIndex) {
|
||||
children.push({
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex, matchStart)
|
||||
})
|
||||
}
|
||||
|
||||
// Create custom nostr node with type information
|
||||
const nostrNode: NostrNode = {
|
||||
type: 'nostr',
|
||||
data: {
|
||||
hName: 'nostr',
|
||||
hProperties: {
|
||||
bech32Id: match.bech32Id,
|
||||
rawText: match.rawText
|
||||
}
|
||||
}
|
||||
}
|
||||
children.push(nostrNode)
|
||||
|
||||
lastIndex = matchEnd
|
||||
})
|
||||
|
||||
// Add remaining text after the last match
|
||||
if (lastIndex < text.length) {
|
||||
children.push({
|
||||
type: 'text',
|
||||
value: text.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// Type assertion to tell TypeScript these are valid AST nodes
|
||||
parent.children.splice(index, 1, ...(children as PhrasingContent[]))
|
||||
})
|
||||
}
|
||||
}
|
||||
19
src/components/Note/LongFormArticle/types.ts
Normal file
19
src/components/Note/LongFormArticle/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ComponentProps } from 'react'
|
||||
import type { Components as RmComponents } from 'react-markdown'
|
||||
import type { Data, Node } from 'unist'
|
||||
|
||||
// Extend the Components interface to include your custom component
|
||||
export interface Components extends RmComponents {
|
||||
nostr: React.ComponentType<{
|
||||
rawText: string
|
||||
bech32Id?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrNode extends Node {
|
||||
type: 'nostr'
|
||||
data: Data & {
|
||||
hName: string
|
||||
hProperties: ComponentProps<Components['nostr']>
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import ClientSelect from '../ClientSelect'
|
||||
import Image from '../Image'
|
||||
|
||||
export default function LongFormArticle({
|
||||
export default function LongFormArticlePreview({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
@@ -46,7 +45,6 @@ export default function LongFormArticle({
|
||||
{titleComponent}
|
||||
{summaryComponent}
|
||||
{tagsComponent}
|
||||
<ClientSelect className="w-full mt-2" event={event} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -68,7 +66,6 @@ export default function LongFormArticle({
|
||||
{tagsComponent}
|
||||
</div>
|
||||
</div>
|
||||
<ClientSelect className="w-full mt-2" event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import Highlight from './Highlight'
|
||||
import IValue from './IValue'
|
||||
import LiveEvent from './LiveEvent'
|
||||
import LongFormArticle from './LongFormArticle'
|
||||
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||
import MutedNote from './MutedNote'
|
||||
import NsfwNote from './NsfwNote'
|
||||
import Poll from './Poll'
|
||||
@@ -38,13 +39,15 @@ export default function Note({
|
||||
originalNoteId,
|
||||
size = 'normal',
|
||||
className,
|
||||
hideParentNotePreview = false
|
||||
hideParentNotePreview = false,
|
||||
showFull = false
|
||||
}: {
|
||||
event: Event
|
||||
originalNoteId?: string
|
||||
size?: 'normal' | 'small'
|
||||
className?: string
|
||||
hideParentNotePreview?: boolean
|
||||
showFull?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
@@ -85,7 +88,11 @@ export default function Note({
|
||||
} else if (event.kind === kinds.Highlights) {
|
||||
content = <Highlight className="mt-2" event={event} />
|
||||
} else if (event.kind === kinds.LongFormArticle) {
|
||||
content = <LongFormArticle className="mt-2" event={event} />
|
||||
content = showFull ? (
|
||||
<LongFormArticle className="mt-2" event={event} />
|
||||
) : (
|
||||
<LongFormArticlePreview className="mt-2" event={event} />
|
||||
)
|
||||
} else if (event.kind === kinds.LiveEvent) {
|
||||
content = <LiveEvent className="mt-2" event={event} />
|
||||
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||
|
||||
Reference in New Issue
Block a user