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-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tiptap/extension-history": "^2.12.0",
|
"@tiptap/extension-history": "^2.12.0",
|
||||||
"@tiptap/extension-mention": "^2.12.0",
|
"@tiptap/extension-mention": "^2.12.0",
|
||||||
"@tiptap/extension-placeholder": "^2.12.0",
|
"@tiptap/extension-placeholder": "^2.12.0",
|
||||||
@@ -66,7 +67,9 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-simple-pull-to-refresh": "^1.3.3",
|
"react-simple-pull-to-refresh": "^1.3.3",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import Username, { SimpleUsername } from '../Username'
|
import Username, { SimpleUsername } from '../Username'
|
||||||
|
|
||||||
export function EmbeddedMention({ userId }: { userId: string }) {
|
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
|
||||||
return (
|
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 { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import ClientSelect from '../ClientSelect'
|
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
|
|
||||||
export default function LongFormArticle({
|
export default function LongFormArticlePreview({
|
||||||
event,
|
event,
|
||||||
className
|
className
|
||||||
}: {
|
}: {
|
||||||
@@ -46,7 +45,6 @@ export default function LongFormArticle({
|
|||||||
{titleComponent}
|
{titleComponent}
|
||||||
{summaryComponent}
|
{summaryComponent}
|
||||||
{tagsComponent}
|
{tagsComponent}
|
||||||
<ClientSelect className="w-full mt-2" event={event} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -68,7 +66,6 @@ export default function LongFormArticle({
|
|||||||
{tagsComponent}
|
{tagsComponent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ClientSelect className="w-full mt-2" event={event} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ import Highlight from './Highlight'
|
|||||||
import IValue from './IValue'
|
import IValue from './IValue'
|
||||||
import LiveEvent from './LiveEvent'
|
import LiveEvent from './LiveEvent'
|
||||||
import LongFormArticle from './LongFormArticle'
|
import LongFormArticle from './LongFormArticle'
|
||||||
|
import LongFormArticlePreview from './LongFormArticlePreview'
|
||||||
import MutedNote from './MutedNote'
|
import MutedNote from './MutedNote'
|
||||||
import NsfwNote from './NsfwNote'
|
import NsfwNote from './NsfwNote'
|
||||||
import Poll from './Poll'
|
import Poll from './Poll'
|
||||||
@@ -38,13 +39,15 @@ export default function Note({
|
|||||||
originalNoteId,
|
originalNoteId,
|
||||||
size = 'normal',
|
size = 'normal',
|
||||||
className,
|
className,
|
||||||
hideParentNotePreview = false
|
hideParentNotePreview = false,
|
||||||
|
showFull = false
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
originalNoteId?: string
|
originalNoteId?: string
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
className?: string
|
className?: string
|
||||||
hideParentNotePreview?: boolean
|
hideParentNotePreview?: boolean
|
||||||
|
showFull?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
@@ -85,7 +88,11 @@ export default function Note({
|
|||||||
} else if (event.kind === kinds.Highlights) {
|
} else if (event.kind === kinds.Highlights) {
|
||||||
content = <Highlight className="mt-2" event={event} />
|
content = <Highlight className="mt-2" event={event} />
|
||||||
} else if (event.kind === kinds.LongFormArticle) {
|
} 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) {
|
} else if (event.kind === kinds.LiveEvent) {
|
||||||
content = <LiveEvent className="mt-2" event={event} />
|
content = <LiveEvent className="mt-2" event={event} />
|
||||||
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
|
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
|||||||
const { username, about } = profile || {}
|
const { username, about } = profile || {}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex space-x-2 w-full items-start justify-between">
|
||||||
<SimpleUserAvatar userId={pubkey} className="w-12 h-12" />
|
<SimpleUserAvatar userId={pubkey} className="w-12 h-12" />
|
||||||
<FollowButton pubkey={pubkey} />
|
<FollowButton pubkey={pubkey} />
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
className="select-text"
|
className="select-text"
|
||||||
hideParentNotePreview
|
hideParentNotePreview
|
||||||
originalNoteId={id}
|
originalNoteId={id}
|
||||||
|
showFull
|
||||||
/>
|
/>
|
||||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</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