feat: long form articles
This commit is contained in:
1426
package-lock.json
generated
1426
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
70
src/components/ImageWithLightbox/index.tsx
Normal file
70
src/components/ImageWithLightbox/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -55,5 +55,5 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')]
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user