+ {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
+ }
+ })}
+
+ )
}
diff --git a/src/components/Embedded/EmbeddedHashtag.tsx b/src/components/Embedded/EmbeddedHashtag.tsx
index 4901779..b39055d 100644
--- a/src/components/Embedded/EmbeddedHashtag.tsx
+++ b/src/components/Embedded/EmbeddedHashtag.tsx
@@ -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}
)
}
-
-export const embeddedHashtagRenderer: TEmbeddedRenderer = {
- regex: /#([\p{L}\p{N}\p{M}_]+)/gu,
- render: (hashtag: string, index: number) => {
- return
)
}
-
-export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
- regex: /(https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)/gu,
- render: (url: string, index: number) => {
- return
- }
-}
diff --git a/src/components/Embedded/EmbeddedWebsocketUrl.tsx b/src/components/Embedded/EmbeddedWebsocketUrl.tsx
index 3fb4fbe..a184901 100644
--- a/src/components/Embedded/EmbeddedWebsocketUrl.tsx
+++ b/src/components/Embedded/EmbeddedWebsocketUrl.tsx
@@ -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 }) {
)
}
-
-export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
- regex: /(wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_:!~*]+)/gu,
- render: (url: string, index: number) => {
- return
- }
-}
diff --git a/src/components/Embedded/index.tsx b/src/components/Embedded/index.tsx
index d9d9ae1..7c634dd 100644
--- a/src/components/Embedded/index.tsx
+++ b/src/components/Embedded/index.tsx
@@ -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
-}
diff --git a/src/components/Embedded/types.tsx b/src/components/Embedded/types.tsx
deleted file mode 100644
index 086a5f7..0000000
--- a/src/components/Embedded/types.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-export type TEmbeddedRenderer = {
- regex: RegExp
- render: (match: string, index: number) => JSX.Element
-}
diff --git a/src/components/PictureContent/index.tsx b/src/components/PictureContent/index.tsx
index c547ac4..da30661 100644
--- a/src/components/PictureContent/index.tsx
+++ b/src/components/PictureContent/index.tsx
@@ -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[] = [
-
- ]
- nodes.push(
-
- {embedded(event.content, [
- embeddedNormalUrlRenderer,
- embeddedWebsocketUrlRenderer,
- embeddedHashtagRenderer,
- embeddedNostrNpubRenderer,
- embeddedNostrProfileRenderer
- ])}
-
- )
+ const nodes = parseContent(event.content, [
+ EmbeddedNormalUrlParser,
+ EmbeddedWebsocketUrlParser,
+ EmbeddedHashtagParser,
+ EmbeddedMentionParser
+ ])
return (
- {nodes}
+
+
+ {nodes.map((node, index) => {
+ if (node.type === 'text') {
+ return node.data
+ }
+ if (node.type === 'url') {
+ return
+ }
+ if (node.type === 'websocket-url') {
+ return
+ }
+ if (node.type === 'hashtag') {
+ return
+ }
+ if (node.type === 'mention') {
+ return
+ }
+ })}
+
)
})
diff --git a/src/components/PictureNoteCard/index.tsx b/src/components/PictureNoteCard/index.tsx
index 24214fe..f9a63bb 100644
--- a/src/components/PictureNoteCard/index.tsx
+++ b/src/components/PictureNoteCard/index.tsx
@@ -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
+ }
+ if (node.type === 'hashtag') {
+ return
+ }
+ })
}, [event])
if (!images.length) return null
diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx
index c12f532..b505c5b 100644
--- a/src/components/ProfileAbout/index.tsx
+++ b/src/components/ProfileAbout/index.tsx
@@ -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
+ }
+ if (node.type === 'websocket-url') {
+ return
+ }
+ if (node.type === 'hashtag') {
+ return
+ }
+ if (node.type === 'mention') {
+ return
+ }
+ })
}, [about])
- return {nodes}
+ return {aboutNodes}
}
diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx
index b584033..cc36da6 100644
--- a/src/components/WebPreview/index.tsx
+++ b/src/components/WebPreview/index.tsx
@@ -56,7 +56,7 @@ export default function WebPreview({
{image && (
)}
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts
index 9cf4fe4..4b89edf 100644
--- a/src/i18n/locales/ar.ts
+++ b/src/i18n/locales/ar.ts
@@ -215,6 +215,7 @@ export default {
'Post settings': 'إعدادات النشر',
'Media upload service': 'خدمة تحميل الوسائط',
'Choose a relay': 'اختر ريلاي',
- 'no relays found': 'لم يتم العثور على ريلايات'
+ 'no relays found': 'لم يتم العثور على ريلايات',
+ video: 'فيديو'
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 0aa0107..bb0f1af 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 5993ff9..9a87bac 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 176ae0d..d298c3a 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 26d7370..a8f073d 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts
index 4a39f87..4e0d0df 100644
--- a/src/i18n/locales/it.ts
+++ b/src/i18n/locales/it.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index 72ad76a..f20af6f 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -216,6 +216,7 @@ export default {
'Post settings': '投稿設定',
'Media upload service': 'メディアアップロードサービス',
'Choose a relay': 'リレイを選択',
- 'no relays found': 'リレイが見つかりません'
+ 'no relays found': 'リレイが見つかりません',
+ video: 'ビデオ'
}
}
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 9ee74f5..dc320f1 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts
index 12b3082..6980178 100644
--- a/src/i18n/locales/pt-BR.ts
+++ b/src/i18n/locales/pt-BR.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts
index a377415..7c2fb69 100644
--- a/src/i18n/locales/pt-PT.ts
+++ b/src/i18n/locales/pt-PT.ts
@@ -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'
}
}
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index da2a4ba..9bd851b 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -219,6 +219,7 @@ export default {
'Post settings': 'Настройки публикации',
'Media upload service': 'Служба загрузки медиафайлов',
'Choose a relay': 'Выберите ретранслятор',
- 'no relays found': 'ретрансляторы не найдены'
+ 'no relays found': 'ретрансляторы не найдены',
+ video: 'видео'
}
}
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 644f0e4..4c9c48b 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -216,6 +216,7 @@ export default {
'Post settings': '发布设置',
'Media upload service': '媒体上传服务',
'Choose a relay': '选择一个服务器',
- 'no relays found': '未找到服务器'
+ 'no relays found': '未找到服务器',
+ video: '视频'
}
}
diff --git a/src/lib/content-parser.ts b/src/lib/content-parser.ts
new file mode 100644
index 0000000..7c03b88
--- /dev/null
+++ b/src/lib/content-parser.ts
@@ -0,0 +1,193 @@
+export type TEmbeddedNodeType =
+ | 'text'
+ | 'image'
+ | 'images'
+ | 'video'
+ | 'event'
+ | 'mention'
+ | 'legacy-mention'
+ | 'hashtag'
+ | 'websocket-url'
+ | 'url'
+
+export type TEmbeddedNode =
+ | {
+ type: Exclude
+ data: string
+ }
+ | {
+ type: 'images'
+ data: string[]
+ }
+
+type TContentParser = { type: Exclude; 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,
+ data
+ })
+ })
+ return newNodes
+}