feat: long form articles

This commit is contained in:
codytseng
2025-08-07 23:10:04 +08:00
parent 0f16ed8d46
commit 3950cbd9e6
13 changed files with 1706 additions and 22 deletions

1426
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
@@ -66,7 +67,9 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",

View File

@@ -1,9 +1,14 @@
import { cn } from '@/lib/utils'
import Username, { SimpleUsername } from '../Username'
export function EmbeddedMention({ userId }: { userId: string }) {
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
return (
<Username userId={userId} showAt className="text-primary font-normal inline" withoutSkeleton />
<Username
userId={userId}
showAt
className={cn('text-primary font-normal inline', className)}
withoutSkeleton
/>
)
}

View File

@@ -0,0 +1,70 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import modalManager from '@/services/modal-manager.service'
import { TImageInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
export default function ImageWithLightbox({
image,
className
}: {
image: TImageInfo
className?: string
}) {
const id = useMemo(() => `image-with-lightbox-${randomString()}`, [])
const [index, setIndex] = useState(-1)
useEffect(() => {
if (index >= 0) {
modalManager.register(id, () => {
setIndex(-1)
})
} else {
modalManager.unregister(id)
}
}, [index])
const handlePhotoClick = (event: React.MouseEvent) => {
event.stopPropagation()
event.preventDefault()
setIndex(0)
}
return (
<div className="w-fit max-w-full">
<Image
key={0}
className={cn('rounded-lg max-h-[80vh] sm:max-h-[50vh] border cursor-zoom-in', className)}
classNames={{
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
onClick={(e) => handlePhotoClick(e)}
/>
{index >= 0 &&
createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={index}
slides={[{ src: image.url }]}
plugins={[Zoom]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/>
</div>,
document.body
)}
</div>
)
}

View 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" />
}

View 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>
)
}

View 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[]))
})
}
}

View 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']>
}
}

View File

@@ -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>
)
}

View File

@@ -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) {

View File

@@ -9,7 +9,7 @@ export default function ProfileCard({ pubkey }: { pubkey: string }) {
const { username, about } = profile || {}
return (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex flex-col gap-2 not-prose">
<div className="flex space-x-2 w-full items-start justify-between">
<SimpleUserAvatar userId={pubkey} className="w-12 h-12" />
<FollowButton pubkey={pubkey} />

View File

@@ -84,6 +84,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
className="select-text"
hideParentNotePreview
originalNoteId={id}
showFull
/>
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
</div>

View File

@@ -55,5 +55,5 @@ export default {
}
}
},
plugins: [require('tailwindcss-animate')]
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')]
}