feat: enhance post content parsing and rendering (#263)

This commit is contained in:
Cody Tseng
2025-04-10 23:06:28 +08:00
committed by GitHub
parent e9f8b2166e
commit 0569a1dd26
27 changed files with 441 additions and 296 deletions

View File

@@ -1,19 +1,25 @@
import { URL_REGEX } from '@/constants'
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
import {
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedImageParser,
EmbeddedMentionParser,
EmbeddedNormalUrlParser,
EmbeddedVideoParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { isNsfwEvent } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag'
import { isImage, isVideo } from '@/lib/url'
import { cn } from '@/lib/utils'
import { TImageInfo } from '@/types'
import { Event } from 'nostr-tools'
import { memo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
EmbeddedHashtag,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedNote,
embeddedWebsocketUrlRenderer
EmbeddedWebsocketUrl
} from '../Embedded'
import ImageGallery from '../ImageGallery'
import VideoPlayer from '../VideoPlayer'
@@ -29,115 +35,90 @@ const Content = memo(
className?: string
size?: 'normal' | 'small'
}) => {
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event)
const isNsfw = isNsfwEvent(event)
const nodes = embedded(content, [
embeddedNormalUrlRenderer,
embeddedWebsocketUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
const nodes = parseContent(event.content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser
])
// Add images
if (images.length) {
nodes.push(
<ImageGallery
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={`image-gallery-${event.id}`}
images={images}
isNsfw={isNsfw}
size={size}
/>
)
}
const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[]
// Add videos
if (videos.length) {
videos.forEach((src, index) => {
nodes.push(
<VideoPlayer
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const imageUrls = Array.isArray(node.data) ? node.data : [node.data]
const images = imageUrls.map(
(url) => imageInfos.find((image) => image.url === url) ?? { url }
)
return (
<ImageGallery
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
key={index}
images={images}
isNsfw={isNsfwEvent(event)}
size={size}
/>
)
}
if (node.type === 'video') {
return (
<VideoPlayer
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={index}
src={node.data}
isNsfw={isNsfwEvent(event)}
size={size}
/>
)
}
if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return (
<EmbeddedNote
key={index}
noteId={id}
className={size === 'small' ? 'mt-1' : 'mt-2'}
/>
)
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
return null
})}
{lastNormalUrl && (
<WebPreview
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`video-${index}-${src}`}
src={src}
isNsfw={isNsfw}
url={lastNormalUrl}
size={size}
/>
)
})
}
// Add website preview
if (lastNonMediaUrl) {
nodes.push(
<WebPreview
className={size === 'small' ? 'mt-1' : 'mt-2'}
key={`web-preview-${event.id}`}
url={lastNonMediaUrl}
size={size}
/>
)
}
// Add embedded notes
if (embeddedNotes.length) {
embeddedNotes.forEach((note, index) => {
const id = note.split(':')[1]
nodes.push(
<EmbeddedNote
key={`embedded-event-${index}`}
noteId={id}
className={size === 'small' ? 'mt-1' : 'mt-2'}
/>
)
})
}
return <div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{nodes}</div>
)}
</div>
)
}
)
Content.displayName = 'Content'
export default Content
function preprocess(event: Event) {
const content = event.content
const urls = content.match(URL_REGEX) || []
let lastNonMediaUrl: string | undefined
let c = content
const imageUrls: string[] = []
const videos: string[] = []
urls.forEach((url) => {
if (isImage(url)) {
c = c.replace(url, '').trim()
imageUrls.push(url)
} else if (isVideo(url)) {
c = c.replace(url, '').trim()
videos.push(url)
} else {
lastNonMediaUrl = url
}
})
const imageInfos = event.tags
.map((tag) => extractImageInfoFromTag(tag))
.filter(Boolean) as TImageInfo[]
const images = isPictureEvent(event)
? imageInfos
: imageUrls.map((url) => {
const imageInfo = imageInfos.find((info) => info.url === url)
return imageInfo ?? { url }
})
const embeddedNotes: string[] = []
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
c = c.replace(note, '').trim()
embeddedNotes.push(note)
})
c = c.replace(/\n{3,}/g, '\n\n').trim()
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
}

View File

@@ -1,13 +1,15 @@
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
import {
EmbeddedEventParser,
EmbeddedImageParser,
EmbeddedMentionParser,
EmbeddedVideoParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
embedded,
embeddedNostrNpubTextRenderer,
embeddedNostrProfileTextRenderer
} from '../Embedded'
import { EmbeddedMentionText } from '../Embedded'
export default function ContentPreview({
event,
@@ -17,24 +19,36 @@ export default function ContentPreview({
className?: string
}) {
const { t } = useTranslation()
const content = useMemo(() => {
if (!event) return `[${t('Not found the note')}]`
const { contentWithoutEmbeddedNotes, embeddedNotes } = extractEmbeddedNotesFromContent(
event.content
)
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
const contents = [contentWithoutImages]
if (images?.length) {
contents.push(`[${t('image')}]`)
}
if (embeddedNotes.length) {
contents.push(`[${t('note')}]`)
}
return embedded(contents.join(' '), [
embeddedNostrProfileTextRenderer,
embeddedNostrNpubTextRenderer
const nodes = useMemo(() => {
if (!event) return [{ type: 'text', data: `[${t('Not found the note')}]` }]
return parseContent(event.content, [
EmbeddedImageParser,
EmbeddedVideoParser,
EmbeddedEventParser,
EmbeddedMentionParser
])
}, [event])
return <div className={cn('pointer-events-none', className)}>{content}</div>
return (
<div className={cn('pointer-events-none', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('image')}]` : `[${t('image')}]`
}
if (node.type === 'video') {
return index > 0 ? ` [${t('video')}]` : `[${t('video')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('note')}]` : `[${t('note')}]`
}
if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
})}
</div>
)
}

View File

@@ -1,6 +1,5 @@
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { TEmbeddedRenderer } from './types'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
@@ -9,14 +8,7 @@ export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
to={toNoteList({ hashtag })}
onClick={(e) => e.stopPropagation()}
>
#{hashtag}
{hashtag}
</SecondaryPageLink>
)
}
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
regex: /#([\p{L}\p{N}\p{M}_]+)/gu,
render: (hashtag: string, index: number) => {
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
}
}

View File

@@ -1,5 +1,4 @@
import Username, { SimpleUsername } from '../Username'
import { TEmbeddedRenderer } from './types'
export function EmbeddedMention({ userId }: { userId: string }) {
return (
@@ -10,47 +9,3 @@ export function EmbeddedMention({ userId }: { userId: string }) {
export function EmbeddedMentionText({ userId }: { userId: string }) {
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
}
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-npub-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return <EmbeddedMention key={`embedded-nostr-profile-${index}-${nprofile}`} userId={nprofile} />
}
}
export const embeddedNpubRenderer: TEmbeddedRenderer = {
regex: /(npub1[a-z0-9]{58})/g,
render: (npub1: string, index: number) => {
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrNpubTextRenderer: TEmbeddedRenderer = {
regex: /(nostr:npub1[a-z0-9]{58})/g,
render: (id: string, index: number) => {
const npub1 = id.split(':')[1]
return <EmbeddedMentionText key={`embedded-nostr-npub-text-${index}-${npub1}`} userId={npub1} />
}
}
export const embeddedNostrProfileTextRenderer: TEmbeddedRenderer = {
regex: /(nostr:nprofile1[a-z0-9]+)/g,
render: (id: string, index: number) => {
const nprofile = id.split(':')[1]
return (
<EmbeddedMentionText
key={`embedded-nostr-profile-text-${index}-${nprofile}`}
userId={nprofile}
/>
)
}
}

View File

@@ -1,5 +1,3 @@
import { TEmbeddedRenderer } from './types'
export function EmbeddedNormalUrl({ url }: { url: string }) {
return (
<a
@@ -13,10 +11,3 @@ export function EmbeddedNormalUrl({ url }: { url: string }) {
</a>
)
}
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
regex: /(https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)/gu,
render: (url: string, index: number) => {
return <EmbeddedNormalUrl key={`normal-url-${index}-${url}`} url={url} />
}
}

View File

@@ -1,6 +1,5 @@
import { useSecondaryPage } from '@/PageManager'
import { toRelay } from '@/lib/link'
import { TEmbeddedRenderer } from './types'
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
const { push } = useSecondaryPage()
@@ -17,10 +16,3 @@ export function EmbeddedWebsocketUrl({ url }: { url: string }) {
</span>
)
}
export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
regex: /(wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)/gu,
render: (url: string, index: number) => {
return <EmbeddedWebsocketUrl key={`websocket-url-${index}-${url}`} url={url} />
}
}

View File

@@ -3,16 +3,3 @@ export * from './EmbeddedMention'
export * from './EmbeddedNormalUrl'
export * from './EmbeddedNote'
export * from './EmbeddedWebsocketUrl'
import reactStringReplace from 'react-string-replace'
import { TEmbeddedRenderer } from './types'
export function embedded(content: string, renderers: TEmbeddedRenderer[]) {
let nodes: React.ReactNode[] = [content]
renderers.forEach((renderer) => {
nodes = reactStringReplace(nodes, renderer.regex, renderer.render)
})
return nodes
}

View File

@@ -1,4 +0,0 @@
export type TEmbeddedRenderer = {
regex: RegExp
render: (match: string, index: number) => JSX.Element
}

View File

@@ -1,14 +1,19 @@
import {
EmbeddedHashtagParser,
EmbeddedMentionParser,
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { extractImageInfosFromEventTags, isNsfwEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { memo, ReactNode, useMemo } from 'react'
import { memo, useMemo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
embeddedWebsocketUrlRenderer
EmbeddedHashtag,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedWebsocketUrl
} from '../Embedded'
import { ImageCarousel } from '../ImageCarousel'
@@ -16,24 +21,35 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
const images = useMemo(() => extractImageInfosFromEventTags(event), [event])
const isNsfw = isNsfwEvent(event)
const nodes: ReactNode[] = [
<ImageCarousel key={`${event.id}-image-gallery`} images={images} isNsfw={isNsfw} />
]
nodes.push(
<div key={`${event.id}-content`} className="px-4">
{embedded(event.content, [
embeddedNormalUrlRenderer,
embeddedWebsocketUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
])}
</div>
)
const nodes = parseContent(event.content, [
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser,
EmbeddedMentionParser
])
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
{nodes}
<ImageCarousel images={images} isNsfw={isNsfw} />
<div className="px-4">
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'url') {
return <EmbeddedNormalUrl key={index} url={node.data} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl key={index} url={node.data} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag key={index} hashtag={node.data} />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
})}
</div>
</div>
)
})

View File

@@ -1,3 +1,4 @@
import { EmbeddedHashtagParser, EmbeddedMentionParser, parseContent } from '@/lib/content-parser'
import { extractImageInfosFromEventTags } from '@/lib/event'
import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag'
@@ -6,16 +7,11 @@ import { useSecondaryPage } from '@/PageManager'
import { Images } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
} from '../Embedded'
import { EmbeddedHashtag, EmbeddedMention } from '../Embedded'
import Image from '../Image'
import LikeButton from '../NoteStats/LikeButton'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import LikeButton from '../NoteStats/LikeButton'
export default function PictureNoteCard({
event,
@@ -27,12 +23,21 @@ export default function PictureNoteCard({
const { push } = useSecondaryPage()
const images = useMemo(() => extractImageInfosFromEventTags(event), [event])
const title = useMemo(() => {
const title = event.tags.find(tagNameEquals('title'))?.[1] ?? event.content
return embedded(title, [
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer,
embeddedHashtagRenderer
const nodes = parseContent(event.tags.find(tagNameEquals('title'))?.[1] ?? event.content, [
EmbeddedMentionParser,
EmbeddedHashtagParser
])
return nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag key={index} hashtag={node.data} />
}
})
}, [event])
if (!images.length) return null

View File

@@ -1,25 +1,46 @@
import {
EmbeddedHashtagParser,
EmbeddedMentionParser,
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { useMemo } from 'react'
import {
embedded,
embeddedHashtagRenderer,
embeddedNormalUrlRenderer,
embeddedNostrNpubRenderer,
embeddedNpubRenderer,
embeddedWebsocketUrlRenderer
EmbeddedHashtag,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedWebsocketUrl
} from '../Embedded'
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
const nodes = useMemo(() => {
return about
? embedded(about, [
embeddedWebsocketUrlRenderer,
embeddedNormalUrlRenderer,
embeddedHashtagRenderer,
embeddedNostrNpubRenderer,
embeddedNpubRenderer
])
: null
const aboutNodes = useMemo(() => {
if (!about) return null
const nodes = parseContent(about, [
EmbeddedWebsocketUrlParser,
EmbeddedNormalUrlParser,
EmbeddedHashtagParser,
EmbeddedMentionParser
])
return nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'url') {
return <EmbeddedNormalUrl key={index} url={node.data} />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl key={index} url={node.data} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag key={index} hashtag={node.data} />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
})
}, [about])
return <div className={className}>{nodes}</div>
return <div className={className}>{aboutNodes}</div>
}

View File

@@ -56,7 +56,7 @@ export default function WebPreview({
{image && (
<Image
image={{ url: image }}
className={`rounded-lg ${size === 'normal' ? 'h-44' : 'h-24'}`}
className={`rounded-lg aspect-[4/3] object-cover bg-foreground ${size === 'normal' ? 'h-44' : 'h-24'}`}
hideIfError
/>
)}