feat: enhance post content parsing and rendering (#263)
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type TEmbeddedRenderer = {
|
||||
regex: RegExp
|
||||
render: (match: string, index: number) => JSX.Element
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -215,6 +215,7 @@ export default {
|
||||
'Post settings': 'إعدادات النشر',
|
||||
'Media upload service': 'خدمة تحميل الوسائط',
|
||||
'Choose a relay': 'اختر ريلاي',
|
||||
'no relays found': 'لم يتم العثور على ريلايات'
|
||||
'no relays found': 'لم يتم العثور على ريلايات',
|
||||
video: 'فيديو'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export default {
|
||||
'Post settings': 'Beitragseinstellungen',
|
||||
'Media upload service': 'Medien-Upload-Service',
|
||||
'Choose a relay': 'Wähle ein Relay',
|
||||
'no relays found': 'Keine Relays gefunden'
|
||||
'no relays found': 'Keine Relays gefunden',
|
||||
video: 'Video'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ export default {
|
||||
'Post settings': 'Post settings',
|
||||
'Media upload service': 'Media upload service',
|
||||
'Choose a relay': 'Choose a relay',
|
||||
'no relays found': 'no relays found'
|
||||
'no relays found': 'no relays found',
|
||||
video: 'video'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export default {
|
||||
'Post settings': 'Ajustes de publicación',
|
||||
'Media upload service': 'Servicio de carga de medios',
|
||||
'Choose a relay': 'Selecciona un relé',
|
||||
'no relays found': 'no se encontraron relés'
|
||||
'no relays found': 'no se encontraron relés',
|
||||
video: 'video'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export default {
|
||||
'Post settings': 'Paramètres de publication',
|
||||
'Media upload service': 'Service de téléchargement de médias',
|
||||
'Choose a relay': 'Choisir un relais',
|
||||
'no relays found': 'aucun relais trouvé'
|
||||
'no relays found': 'aucun relais trouvé',
|
||||
video: 'vidéo'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export default {
|
||||
'Post settings': 'Impostazioni post',
|
||||
'Media upload service': 'Servizio di caricamento media',
|
||||
'Choose a relay': 'Scegli un relay',
|
||||
'no relays found': 'Nessun relay trovato'
|
||||
'no relays found': 'Nessun relay trovato',
|
||||
video: 'video'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ export default {
|
||||
'Post settings': '投稿設定',
|
||||
'Media upload service': 'メディアアップロードサービス',
|
||||
'Choose a relay': 'リレイを選択',
|
||||
'no relays found': 'リレイが見つかりません'
|
||||
'no relays found': 'リレイが見つかりません',
|
||||
video: 'ビデオ'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ export default {
|
||||
'Post settings': 'Ustawienia publikacji',
|
||||
'Media upload service': 'Usługa przesyłania mediów',
|
||||
'Choose a relay': 'Wybierz transmiter',
|
||||
'no relays found': 'Nie znaleziono transmiterów'
|
||||
'no relays found': 'Nie znaleziono transmiterów',
|
||||
video: 'wideo'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,7 @@ export default {
|
||||
'Post settings': 'Ajustes de publicação',
|
||||
'Media upload service': 'Serviço de upload de mídia',
|
||||
'Choose a relay': 'Escolher um relé',
|
||||
'no relays found': 'nenhum relé encontrado'
|
||||
'no relays found': 'nenhum relé encontrado',
|
||||
video: 'vídeo'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export default {
|
||||
'Post settings': 'Configurações de Postagem',
|
||||
'Media upload service': 'Serviço de Upload de Mídia',
|
||||
'Choose a relay': 'Escolher um Relé',
|
||||
'no relays found': 'nenhum relé encontrado'
|
||||
'no relays found': 'nenhum relé encontrado',
|
||||
video: 'vídeo'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ export default {
|
||||
'Post settings': 'Настройки публикации',
|
||||
'Media upload service': 'Служба загрузки медиафайлов',
|
||||
'Choose a relay': 'Выберите ретранслятор',
|
||||
'no relays found': 'ретрансляторы не найдены'
|
||||
'no relays found': 'ретрансляторы не найдены',
|
||||
video: 'видео'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,7 @@ export default {
|
||||
'Post settings': '发布设置',
|
||||
'Media upload service': '媒体上传服务',
|
||||
'Choose a relay': '选择一个服务器',
|
||||
'no relays found': '未找到服务器'
|
||||
'no relays found': '未找到服务器',
|
||||
video: '视频'
|
||||
}
|
||||
}
|
||||
|
||||
193
src/lib/content-parser.ts
Normal file
193
src/lib/content-parser.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
export type TEmbeddedNodeType =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'images'
|
||||
| 'video'
|
||||
| 'event'
|
||||
| 'mention'
|
||||
| 'legacy-mention'
|
||||
| 'hashtag'
|
||||
| 'websocket-url'
|
||||
| 'url'
|
||||
|
||||
export type TEmbeddedNode =
|
||||
| {
|
||||
type: Exclude<TEmbeddedNodeType, 'images'>
|
||||
data: string
|
||||
}
|
||||
| {
|
||||
type: 'images'
|
||||
data: string[]
|
||||
}
|
||||
|
||||
type TContentParser = { type: Exclude<TEmbeddedNodeType, 'images'>; regex: RegExp }
|
||||
|
||||
export const EmbeddedHashtagParser: TContentParser = {
|
||||
type: 'hashtag',
|
||||
regex: /#[\p{L}\p{N}\p{M}_]+/gu
|
||||
}
|
||||
|
||||
export const EmbeddedMentionParser: TContentParser = {
|
||||
type: 'mention',
|
||||
regex: /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
|
||||
}
|
||||
|
||||
export const EmbeddedLegacyMentionParser: TContentParser = {
|
||||
type: 'legacy-mention',
|
||||
regex: /npub1[a-z0-9]{58}|nprofile1[a-z0-9]+/g
|
||||
}
|
||||
|
||||
export const EmbeddedEventParser: TContentParser = {
|
||||
type: 'event',
|
||||
regex: /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
}
|
||||
|
||||
export const EmbeddedImageParser: TContentParser = {
|
||||
type: 'image',
|
||||
regex:
|
||||
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+\.(jpg|jpeg|png|gif|webp|bmp|tiff|heic|svg)(\?[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)?/giu
|
||||
}
|
||||
|
||||
export const EmbeddedVideoParser: TContentParser = {
|
||||
type: 'video',
|
||||
regex:
|
||||
/https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+\.(mp4|webm|ogg|mov)(\?[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)?/giu
|
||||
}
|
||||
|
||||
export const EmbeddedWebsocketUrlParser: TContentParser = {
|
||||
type: 'websocket-url',
|
||||
regex: /wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+/gu
|
||||
}
|
||||
|
||||
export const EmbeddedNormalUrlParser: TContentParser = {
|
||||
type: 'url',
|
||||
regex: /https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+/gu
|
||||
}
|
||||
|
||||
export function parseContent(content: string, parsers: TContentParser[]) {
|
||||
let nodes: TEmbeddedNode[] = [{ type: 'text', data: content.trim() }]
|
||||
|
||||
parsers.forEach((parser) => {
|
||||
nodes = nodes
|
||||
.flatMap((node) => {
|
||||
if (node.type !== 'text') return [node]
|
||||
const matches = node.data.matchAll(parser.regex)
|
||||
const result: TEmbeddedNode[] = []
|
||||
let lastIndex = 0
|
||||
for (const match of matches) {
|
||||
const matchStart = match.index!
|
||||
// Add text before the match
|
||||
if (matchStart > lastIndex) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
data: node.data.slice(lastIndex, matchStart)
|
||||
})
|
||||
}
|
||||
|
||||
// Add the match as specific type
|
||||
result.push({
|
||||
type: parser.type,
|
||||
data: match[0] // The whole matched string
|
||||
})
|
||||
|
||||
lastIndex = matchStart + match[0].length
|
||||
}
|
||||
|
||||
// Add text after the last match
|
||||
if (lastIndex < node.data.length) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
data: node.data.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
.filter((n) => n.data !== '')
|
||||
})
|
||||
|
||||
nodes = mergeConsecutiveTextNodes(nodes)
|
||||
nodes = mergeConsecutiveImageNodes(nodes)
|
||||
nodes = removeExtraNewlines(nodes)
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
function mergeConsecutiveTextNodes(nodes: TEmbeddedNode[]) {
|
||||
const merged: TEmbeddedNode[] = []
|
||||
let currentText = ''
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === 'text') {
|
||||
currentText += node.data
|
||||
} else {
|
||||
if (currentText) {
|
||||
merged.push({ type: 'text', data: currentText })
|
||||
currentText = ''
|
||||
}
|
||||
merged.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
if (currentText) {
|
||||
merged.push({ type: 'text', data: currentText })
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeConsecutiveImageNodes(nodes: TEmbeddedNode[]) {
|
||||
const merged: TEmbeddedNode[] = []
|
||||
nodes.forEach((node, i) => {
|
||||
if (node.type === 'image') {
|
||||
const lastNode = merged[merged.length - 1]
|
||||
if (lastNode && lastNode.type === 'images') {
|
||||
lastNode.data.push(node.data)
|
||||
} else {
|
||||
merged.push({ type: 'images', data: [node.data] })
|
||||
}
|
||||
} else if (node.type === 'text' && node.data.trim() === '') {
|
||||
// Only remove whitespace-only text nodes if they are sandwiched between image nodes.
|
||||
const prev = merged[merged.length - 1]
|
||||
const next = nodes[i + 1]
|
||||
if (prev && prev.type === 'images' && next && next.type === 'image') {
|
||||
return // skip this whitespace node
|
||||
} else {
|
||||
merged.push(node)
|
||||
}
|
||||
} else {
|
||||
merged.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function removeExtraNewlines(nodes: TEmbeddedNode[]) {
|
||||
const isBlockNode = (node: TEmbeddedNode) => {
|
||||
return ['image', 'images', 'video', 'event'].includes(node.type)
|
||||
}
|
||||
|
||||
const newNodes: TEmbeddedNode[] = []
|
||||
nodes.forEach((node, i) => {
|
||||
if (isBlockNode(node)) {
|
||||
newNodes.push(node)
|
||||
return
|
||||
}
|
||||
|
||||
const prev = nodes[i - 1]
|
||||
const next = nodes[i + 1]
|
||||
let data = node.data as string
|
||||
if (prev && isBlockNode(prev)) {
|
||||
data = data.replace(/^[ ]*\n/, '')
|
||||
}
|
||||
if (next && isBlockNode(next)) {
|
||||
data = data.replace(/\n[ ]*$/, '')
|
||||
}
|
||||
newNodes.push({
|
||||
type: node.type as Exclude<TEmbeddedNodeType, 'images'>,
|
||||
data
|
||||
})
|
||||
})
|
||||
return newNodes
|
||||
}
|
||||
Reference in New Issue
Block a user