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

View File

@@ -215,6 +215,7 @@ export default {
'Post settings': 'إعدادات النشر',
'Media upload service': 'خدمة تحميل الوسائط',
'Choose a relay': 'اختر ريلاي',
'no relays found': 'لم يتم العثور على ريلايات'
'no relays found': 'لم يتم العثور على ريلايات',
video: 'فيديو'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -216,6 +216,7 @@ export default {
'Post settings': '投稿設定',
'Media upload service': 'メディアアップロードサービス',
'Choose a relay': 'リレイを選択',
'no relays found': 'リレイが見つかりません'
'no relays found': 'リレイが見つかりません',
video: 'ビデオ'
}
}

View File

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

View File

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

View File

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

View File

@@ -219,6 +219,7 @@ export default {
'Post settings': 'Настройки публикации',
'Media upload service': 'Служба загрузки медиафайлов',
'Choose a relay': 'Выберите ретранслятор',
'no relays found': 'ретрансляторы не найдены'
'no relays found': 'ретрансляторы не найдены',
video: 'видео'
}
}

View File

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