diff --git a/package-lock.json b/package-lock.json index 4c7d313..228b91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,6 @@ "react-dom": "^18.3.1", "react-i18next": "^15.2.0", "react-simple-pull-to-refresh": "^1.3.3", - "react-string-replace": "^1.1.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", @@ -7705,14 +7704,6 @@ "react-dom": "^16.10.2 || ^17.0.0 || ^18.0.0" } }, - "node_modules/react-string-replace": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz", - "integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/package.json b/package.json index 366dba7..6568ee7 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "react-dom": "^18.3.1", "react-i18next": "^15.2.0", "react-simple-pull-to-refresh": "^1.3.3", - "react-string-replace": "^1.1.1", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 2ae7600..b7b7be4 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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( - - ) - } + const imageInfos = event.tags + .map((tag) => extractImageInfoFromTag(tag)) + .filter(Boolean) as TImageInfo[] - // Add videos - if (videos.length) { - videos.forEach((src, index) => { - nodes.push( - - ) - }) - } + const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') + const lastNormalUrl = + typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined - // Add website preview - if (lastNonMediaUrl) { - nodes.push( - - ) - } - - // Add embedded notes - if (embeddedNotes.length) { - embeddedNotes.forEach((note, index) => { - const id = note.split(':')[1] - nodes.push( - + {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 ( + + ) + } + if (node.type === 'video') { + return ( + + ) + } + if (node.type === 'url') { + return + } + if (node.type === 'websocket-url') { + return + } + if (node.type === 'event') { + const id = node.data.split(':')[1] + return ( + + ) + } + if (node.type === 'mention') { + return + } + if (node.type === 'hashtag') { + return + } + return null + })} + {lastNormalUrl && ( + - ) - }) - } - - return
{nodes}
+ )} + + ) } ) 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 } -} diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 7f3d8d9..ee04ca0 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -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
{content}
+ return ( +
+ {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 - } -} diff --git a/src/components/Embedded/EmbeddedMention.tsx b/src/components/Embedded/EmbeddedMention.tsx index 886244c..eb5eb39 100644 --- a/src/components/Embedded/EmbeddedMention.tsx +++ b/src/components/Embedded/EmbeddedMention.tsx @@ -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 } - -export const embeddedNostrNpubRenderer: TEmbeddedRenderer = { - regex: /(nostr:npub1[a-z0-9]{58})/g, - render: (id: string, index: number) => { - const npub1 = id.split(':')[1] - return - } -} - -export const embeddedNostrProfileRenderer: TEmbeddedRenderer = { - regex: /(nostr:nprofile1[a-z0-9]+)/g, - render: (id: string, index: number) => { - const nprofile = id.split(':')[1] - return - } -} - -export const embeddedNpubRenderer: TEmbeddedRenderer = { - regex: /(npub1[a-z0-9]{58})/g, - render: (npub1: string, index: number) => { - return - } -} - -export const embeddedNostrNpubTextRenderer: TEmbeddedRenderer = { - regex: /(nostr:npub1[a-z0-9]{58})/g, - render: (id: string, index: number) => { - const npub1 = id.split(':')[1] - return - } -} - -export const embeddedNostrProfileTextRenderer: TEmbeddedRenderer = { - regex: /(nostr:nprofile1[a-z0-9]+)/g, - render: (id: string, index: number) => { - const nprofile = id.split(':')[1] - return ( - - ) - } -} diff --git a/src/components/Embedded/EmbeddedNormalUrl.tsx b/src/components/Embedded/EmbeddedNormalUrl.tsx index a357048..7d43b82 100644 --- a/src/components/Embedded/EmbeddedNormalUrl.tsx +++ b/src/components/Embedded/EmbeddedNormalUrl.tsx @@ -1,5 +1,3 @@ -import { TEmbeddedRenderer } from './types' - export function EmbeddedNormalUrl({ url }: { url: string }) { 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 +}