|
|
|
@ -3,13 +3,14 @@ import Image from '@/components/Image' |
|
|
|
import MediaPlayer from '@/components/MediaPlayer' |
|
|
|
import MediaPlayer from '@/components/MediaPlayer' |
|
|
|
import Wikilink from '@/components/UniversalContent/Wikilink' |
|
|
|
import Wikilink from '@/components/UniversalContent/Wikilink' |
|
|
|
import WebPreview from '@/components/WebPreview' |
|
|
|
import WebPreview from '@/components/WebPreview' |
|
|
|
|
|
|
|
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' |
|
|
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
|
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
|
|
import { toNoteList } from '@/lib/link' |
|
|
|
import { toNoteList } from '@/lib/link' |
|
|
|
import { useMediaExtraction } from '@/hooks' |
|
|
|
import { useMediaExtraction } from '@/hooks' |
|
|
|
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url' |
|
|
|
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url' |
|
|
|
import { getImetaInfosFromEvent } from '@/lib/event' |
|
|
|
import { getImetaInfosFromEvent } from '@/lib/event' |
|
|
|
import { Event, kinds } from 'nostr-tools' |
|
|
|
import { Event, kinds } from 'nostr-tools' |
|
|
|
import { ExtendedKind, WS_URL_REGEX } from '@/constants' |
|
|
|
import { ExtendedKind, WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants' |
|
|
|
import React, { useMemo, useState, useCallback } from 'react' |
|
|
|
import React, { useMemo, useState, useCallback } from 'react' |
|
|
|
import { createPortal } from 'react-dom' |
|
|
|
import { createPortal } from 'react-dom' |
|
|
|
import Lightbox from 'yet-another-react-lightbox' |
|
|
|
import Lightbox from 'yet-another-react-lightbox' |
|
|
|
@ -27,6 +28,17 @@ function truncateLinkText(text: string, maxLength: number = 200): string { |
|
|
|
return text.substring(0, maxLength) + '...' |
|
|
|
return text.substring(0, maxLength) + '...' |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
|
|
* Check if a URL is a YouTube URL |
|
|
|
|
|
|
|
*/ |
|
|
|
|
|
|
|
function isYouTubeUrl(url: string): boolean { |
|
|
|
|
|
|
|
// Create a new regex instance to avoid state issues with global regex
|
|
|
|
|
|
|
|
// Keep the 'i' flag for case-insensitivity but remove 'g' to avoid state issues
|
|
|
|
|
|
|
|
const flags = YOUTUBE_URL_REGEX.flags.replace('g', '') |
|
|
|
|
|
|
|
const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags) |
|
|
|
|
|
|
|
return regex.test(url) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
/** |
|
|
|
* Parse markdown content and render with post-processing for nostr: links and hashtags |
|
|
|
* Parse markdown content and render with post-processing for nostr: links and hashtags |
|
|
|
* Post-processes: |
|
|
|
* Post-processes: |
|
|
|
@ -86,14 +98,37 @@ function parseMarkdownContent( |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// YouTube URLs - not in markdown links
|
|
|
|
|
|
|
|
const youtubeUrlMatches = Array.from(content.matchAll(YOUTUBE_URL_REGEX)) |
|
|
|
|
|
|
|
youtubeUrlMatches.forEach(match => { |
|
|
|
|
|
|
|
if (match.index !== undefined) { |
|
|
|
|
|
|
|
const url = match[0] |
|
|
|
|
|
|
|
// Only add if not already covered by a markdown link/image
|
|
|
|
|
|
|
|
const isInMarkdown = patterns.some(p =>
|
|
|
|
|
|
|
|
(p.type === 'markdown-link' || p.type === 'markdown-image') &&
|
|
|
|
|
|
|
|
match.index! >= p.index &&
|
|
|
|
|
|
|
|
match.index! < p.end |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
// Only process if not in markdown link
|
|
|
|
|
|
|
|
if (!isInMarkdown && isYouTubeUrl(url)) { |
|
|
|
|
|
|
|
patterns.push({ |
|
|
|
|
|
|
|
index: match.index, |
|
|
|
|
|
|
|
end: match.index + match[0].length, |
|
|
|
|
|
|
|
type: 'youtube-url', |
|
|
|
|
|
|
|
data: { url } |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// Relay URLs (wss:// or ws://) - not in markdown links
|
|
|
|
// Relay URLs (wss:// or ws://) - not in markdown links
|
|
|
|
const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX)) |
|
|
|
const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX)) |
|
|
|
relayUrlMatches.forEach(match => { |
|
|
|
relayUrlMatches.forEach(match => { |
|
|
|
if (match.index !== undefined) { |
|
|
|
if (match.index !== undefined) { |
|
|
|
const url = match[0] |
|
|
|
const url = match[0] |
|
|
|
// Only add if not already covered by a markdown link/image
|
|
|
|
// Only add if not already covered by a markdown link/image or YouTube URL
|
|
|
|
const isInMarkdown = patterns.some(p =>
|
|
|
|
const isInMarkdown = patterns.some(p =>
|
|
|
|
(p.type === 'markdown-link' || p.type === 'markdown-image') &&
|
|
|
|
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'youtube-url') &&
|
|
|
|
match.index! >= p.index &&
|
|
|
|
match.index! >= p.index &&
|
|
|
|
match.index! < p.end |
|
|
|
match.index! < p.end |
|
|
|
) |
|
|
|
) |
|
|
|
@ -109,14 +144,14 @@ function parseMarkdownContent( |
|
|
|
} |
|
|
|
} |
|
|
|
}) |
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links or relay URLs
|
|
|
|
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links, relay URLs, or YouTube URLs
|
|
|
|
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g |
|
|
|
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g |
|
|
|
const nostrMatches = Array.from(content.matchAll(nostrRegex)) |
|
|
|
const nostrMatches = Array.from(content.matchAll(nostrRegex)) |
|
|
|
nostrMatches.forEach(match => { |
|
|
|
nostrMatches.forEach(match => { |
|
|
|
if (match.index !== undefined) { |
|
|
|
if (match.index !== undefined) { |
|
|
|
// Only add if not already covered by a markdown link/image or relay URL
|
|
|
|
// Only add if not already covered by a markdown link/image, relay URL, or YouTube URL
|
|
|
|
const isInOther = patterns.some(p =>
|
|
|
|
const isInOther = patterns.some(p =>
|
|
|
|
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url') &&
|
|
|
|
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url') &&
|
|
|
|
match.index! >= p.index &&
|
|
|
|
match.index! >= p.index &&
|
|
|
|
match.index! < p.end |
|
|
|
match.index! < p.end |
|
|
|
) |
|
|
|
) |
|
|
|
@ -479,6 +514,17 @@ function parseMarkdownContent( |
|
|
|
{displayText} |
|
|
|
{displayText} |
|
|
|
</a> |
|
|
|
</a> |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
} else if (isYouTubeUrl(url)) { |
|
|
|
|
|
|
|
// Render YouTube URL as embedded player
|
|
|
|
|
|
|
|
parts.push( |
|
|
|
|
|
|
|
<div key={`youtube-${patternIdx}`} className="my-2"> |
|
|
|
|
|
|
|
<YoutubeEmbeddedPlayer |
|
|
|
|
|
|
|
url={url} |
|
|
|
|
|
|
|
className="max-w-[400px]" |
|
|
|
|
|
|
|
mustLoad={false} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
) |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS)
|
|
|
|
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS)
|
|
|
|
parts.push( |
|
|
|
parts.push( |
|
|
|
@ -495,6 +541,18 @@ function parseMarkdownContent( |
|
|
|
</a> |
|
|
|
</a> |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
} else if (pattern.type === 'youtube-url') { |
|
|
|
|
|
|
|
const { url } = pattern.data |
|
|
|
|
|
|
|
// Render YouTube URL as embedded player
|
|
|
|
|
|
|
|
parts.push( |
|
|
|
|
|
|
|
<div key={`youtube-url-${patternIdx}`} className="my-2"> |
|
|
|
|
|
|
|
<YoutubeEmbeddedPlayer |
|
|
|
|
|
|
|
url={url} |
|
|
|
|
|
|
|
className="max-w-[400px]" |
|
|
|
|
|
|
|
mustLoad={false} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
) |
|
|
|
} else if (pattern.type === 'relay-url') { |
|
|
|
} else if (pattern.type === 'relay-url') { |
|
|
|
const { url } = pattern.data |
|
|
|
const { url } = pattern.data |
|
|
|
const relayPath = `/relays/${encodeURIComponent(url)}` |
|
|
|
const relayPath = `/relays/${encodeURIComponent(url)}` |
|
|
|
@ -1108,7 +1166,29 @@ export default function MarkdownArticle({ |
|
|
|
return media |
|
|
|
return media |
|
|
|
}, [event.id, JSON.stringify(event.tags)]) |
|
|
|
}, [event.id, JSON.stringify(event.tags)]) |
|
|
|
|
|
|
|
|
|
|
|
// Extract non-media links from tags
|
|
|
|
// Extract YouTube URLs from tags (for display at top)
|
|
|
|
|
|
|
|
const tagYouTubeUrls = useMemo(() => { |
|
|
|
|
|
|
|
const youtubeUrls: string[] = [] |
|
|
|
|
|
|
|
const seenUrls = new Set<string>() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
event.tags |
|
|
|
|
|
|
|
.filter(tag => tag[0] === 'r' && tag[1]) |
|
|
|
|
|
|
|
.forEach(tag => { |
|
|
|
|
|
|
|
const url = tag[1] |
|
|
|
|
|
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return |
|
|
|
|
|
|
|
if (!isYouTubeUrl(url)) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
|
|
|
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
|
|
|
|
|
|
youtubeUrls.push(cleaned) |
|
|
|
|
|
|
|
seenUrls.add(cleaned) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return youtubeUrls |
|
|
|
|
|
|
|
}, [event.id, JSON.stringify(event.tags)]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract non-media links from tags (excluding YouTube URLs)
|
|
|
|
const tagLinks = useMemo(() => { |
|
|
|
const tagLinks = useMemo(() => { |
|
|
|
const links: string[] = [] |
|
|
|
const links: string[] = [] |
|
|
|
const seenUrls = new Set<string>() |
|
|
|
const seenUrls = new Set<string>() |
|
|
|
@ -1119,6 +1199,7 @@ export default function MarkdownArticle({ |
|
|
|
const url = tag[1] |
|
|
|
const url = tag[1] |
|
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return |
|
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) return |
|
|
|
if (isImage(url) || isMedia(url)) return |
|
|
|
if (isImage(url) || isMedia(url)) return |
|
|
|
|
|
|
|
if (isYouTubeUrl(url)) return // Exclude YouTube URLs
|
|
|
|
|
|
|
|
|
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
|
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
|
|
@ -1181,7 +1262,22 @@ export default function MarkdownArticle({ |
|
|
|
return urls |
|
|
|
return urls |
|
|
|
}, [event.content]) |
|
|
|
}, [event.content]) |
|
|
|
|
|
|
|
|
|
|
|
// Extract non-media links from content
|
|
|
|
// Extract YouTube URLs from content
|
|
|
|
|
|
|
|
const youtubeUrlsInContent = useMemo(() => { |
|
|
|
|
|
|
|
const urls = new Set<string>() |
|
|
|
|
|
|
|
const urlRegex = /https?:\/\/[^\s<>"']+/g |
|
|
|
|
|
|
|
let match |
|
|
|
|
|
|
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
|
|
|
|
|
|
const url = match[0] |
|
|
|
|
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
|
|
|
|
if (cleaned && isYouTubeUrl(cleaned)) { |
|
|
|
|
|
|
|
urls.add(cleaned) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return urls |
|
|
|
|
|
|
|
}, [event.content]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Extract non-media links from content (excluding YouTube URLs)
|
|
|
|
const contentLinks = useMemo(() => { |
|
|
|
const contentLinks = useMemo(() => { |
|
|
|
const links: string[] = [] |
|
|
|
const links: string[] = [] |
|
|
|
const seenUrls = new Set<string>() |
|
|
|
const seenUrls = new Set<string>() |
|
|
|
@ -1189,7 +1285,7 @@ export default function MarkdownArticle({ |
|
|
|
let match |
|
|
|
let match |
|
|
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
|
|
while ((match = urlRegex.exec(event.content)) !== null) { |
|
|
|
const url = match[0] |
|
|
|
const url = match[0] |
|
|
|
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { |
|
|
|
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) { |
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
|
|
if (cleaned && !seenUrls.has(cleaned)) { |
|
|
|
links.push(cleaned) |
|
|
|
links.push(cleaned) |
|
|
|
@ -1221,6 +1317,14 @@ export default function MarkdownArticle({ |
|
|
|
}) |
|
|
|
}) |
|
|
|
}, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata]) |
|
|
|
}, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Filter tag YouTube URLs to only show what's not in content
|
|
|
|
|
|
|
|
const leftoverTagYouTubeUrls = useMemo(() => { |
|
|
|
|
|
|
|
return tagYouTubeUrls.filter(url => { |
|
|
|
|
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
|
|
|
|
return cleaned && !youtubeUrlsInContent.has(cleaned) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}, [tagYouTubeUrls, youtubeUrlsInContent]) |
|
|
|
|
|
|
|
|
|
|
|
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
|
|
|
|
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
|
|
|
|
const leftoverTagLinks = useMemo(() => { |
|
|
|
const leftoverTagLinks = useMemo(() => { |
|
|
|
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean)) |
|
|
|
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean)) |
|
|
|
@ -1337,6 +1441,24 @@ export default function MarkdownArticle({ |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{/* YouTube URLs from tags (only if not in content) */} |
|
|
|
|
|
|
|
{leftoverTagYouTubeUrls.length > 0 && ( |
|
|
|
|
|
|
|
<div className="space-y-4 mb-6"> |
|
|
|
|
|
|
|
{leftoverTagYouTubeUrls.map((url) => { |
|
|
|
|
|
|
|
const cleaned = cleanUrl(url) |
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
|
|
<div key={`tag-youtube-${cleaned}`} className="my-2"> |
|
|
|
|
|
|
|
<YoutubeEmbeddedPlayer |
|
|
|
|
|
|
|
url={url} |
|
|
|
|
|
|
|
className="max-w-[400px]" |
|
|
|
|
|
|
|
mustLoad={false} |
|
|
|
|
|
|
|
/> |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
})} |
|
|
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
{/* Parsed content */} |
|
|
|
{/* Parsed content */} |
|
|
|
<div className="break-words whitespace-pre-wrap"> |
|
|
|
<div className="break-words whitespace-pre-wrap"> |
|
|
|
{parsedContent} |
|
|
|
{parsedContent} |
|
|
|
|