Browse Source

more parsing updates

imwald
Silberengel 5 months ago
parent
commit
640053ef0a
  1. 20
      src/components/Note/DiscussionContent/index.tsx
  2. 31
      src/components/Note/SimpleContent/index.tsx
  3. 4
      src/components/NoteOptions/RawEventDialog.tsx
  4. 7
      src/components/ReplyNote/index.tsx
  5. 242
      src/components/UniversalContent/SimpleContent.tsx
  6. 511
      src/lib/nostr-parser.tsx
  7. 4
      src/pages/secondary/NotePage/index.tsx

20
src/components/Note/DiscussionContent/index.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import { Event } from 'nostr-tools'
import ParsedContent from '../../UniversalContent/ParsedContent'
import { useMemo } from 'react'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { cn } from '@/lib/utils'
export default function DiscussionContent({
event,
@ -8,15 +10,9 @@ export default function DiscussionContent({ @@ -8,15 +10,9 @@ export default function DiscussionContent({
event: Event
className?: string
}) {
return (
<ParsedContent
event={event}
field="content"
className={className}
showMedia={true}
showLinks={false}
showHashtags={true}
showNostrLinks={false}
/>
)
const parsedContent = useMemo(() => {
return parseNostrContent(event.content, event)
}, [event.content, event])
return renderNostrContent(parsedContent, cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className))
}

31
src/components/Note/SimpleContent/index.tsx

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
import { Event } from 'nostr-tools'
import { useEventFieldParser } from '@/hooks/useContentParser'
import { useMemo } from 'react'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { cn } from '@/lib/utils'
export default function SimpleContent({
event,
@ -8,28 +10,9 @@ export default function SimpleContent({ @@ -8,28 +10,9 @@ export default function SimpleContent({
event: Event
className?: string
}) {
// Use the comprehensive content parser but without ToC
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
enableSyntaxHighlighting: true
})
const parsedContent = useMemo(() => {
return parseNostrContent(event.content, event)
}, [event.content, event])
if (isLoading) {
return <div className={className}>Loading...</div>
}
if (error) {
return <div className={className}>Error loading content</div>
}
if (!parsedContent) {
return <div className={className}>No content available</div>
}
return (
<div className={`${parsedContent.cssClasses} ${className || ''}`}>
{/* Render content without ToC and Article Info */}
<div className="simple-content" dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
</div>
)
return renderNostrContent(parsedContent, cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className))
}

4
src/components/NoteOptions/RawEventDialog.tsx

@ -19,13 +19,13 @@ export default function RawEventDialog({ @@ -19,13 +19,13 @@ export default function RawEventDialog({
}) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="h-[60vh]">
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px]">
<DialogHeader>
<DialogTitle>Raw Event</DialogTitle>
<DialogDescription className="sr-only">View the raw event data</DialogDescription>
</DialogHeader>
<ScrollArea className="h-full">
<pre className="text-sm text-muted-foreground select-text">
<pre className="text-sm text-muted-foreground select-text whitespace-pre-wrap break-words">
{JSON.stringify(event, null, 2)}
</pre>
<ScrollBar orientation="horizontal" />

7
src/components/ReplyNote/index.tsx

@ -11,7 +11,7 @@ import { useMemo, useState } from 'react' @@ -11,7 +11,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import Collapsible from '../Collapsible'
import EnhancedContent from '../UniversalContent/EnhancedContent'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
@ -103,7 +103,10 @@ export default function ReplyNote({ @@ -103,7 +103,10 @@ export default function ReplyNote({
/>
)}
{show ? (
<EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
(() => {
const parsedContent = parseNostrContent(event.content, event)
return renderNostrContent(parsedContent, 'mt-2 prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full')
})()
) : (
<Button
variant="outline"

242
src/components/UniversalContent/SimpleContent.tsx

@ -1,15 +1,8 @@ @@ -1,15 +1,8 @@
import { useMemo } from 'react'
import { cleanUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import logger from '@/lib/logger'
import { Event } from 'nostr-tools'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { remarkNostr } from '../Note/LongFormArticle/remarkNostr'
import NostrNode from '../Note/LongFormArticle/NostrNode'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
import { cn } from '@/lib/utils'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
interface SimpleContentProps {
event?: Event
@ -22,39 +15,10 @@ export default function SimpleContent({ @@ -22,39 +15,10 @@ export default function SimpleContent({
content,
className
}: SimpleContentProps) {
const imetaInfos = useMemo(() => event ? getImetaInfosFromEvent(event) : [], [event])
// Extract video URLs from imeta tags to avoid duplicate rendering
const imetaVideoUrls = useMemo(() => {
return imetaInfos
.filter(info => {
// Check if the imeta info is a video by looking at the URL extension
const url = info.url
const extension = url.split('.').pop()?.toLowerCase()
return extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v'].includes(extension)
})
.map(info => {
// Clean the URL first, then normalize
const cleanedUrl = (() => {
try {
return cleanUrl(info.url)
} catch {
return info.url
}
})()
try {
return new URL(cleanedUrl).href
} catch {
return cleanedUrl
}
})
}, [imetaInfos])
const processedContent = useMemo(() => {
const rawContent = content || event?.content || ''
// Clean URLs
// Clean URLs to remove tracking parameters
const cleaned = rawContent.replace(
/(https?:\/\/[^\s]+)/g,
(url) => {
@ -69,202 +33,14 @@ export default function SimpleContent({ @@ -69,202 +33,14 @@ export default function SimpleContent({
return cleaned
}, [content, event?.content])
// Process content to handle images, videos and markdown
const { markdownContent, mediaElements } = useMemo(() => {
const lines = processedContent.split('\n')
const elements: JSX.Element[] = []
const markdownLines: string[] = []
let key = 0
// Extract all image URLs from content
const imageUrls: string[] = []
lines.forEach((line) => {
const imageMatch = line.match(/(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|heic|svg))/i)
if (imageMatch) {
imageUrls.push(imageMatch[1])
}
})
// Extract all video URLs from content
const videoUrls: string[] = []
lines.forEach((line) => {
const videoMatch = line.match(/(https?:\/\/[^\s]+\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v))/i)
if (videoMatch) {
videoUrls.push(videoMatch[1])
}
})
// Get all unique images - prioritize imeta tags, then add content images that aren't in imeta
const allImageInfos = [...imetaInfos] // Start with imeta images
const processedUrls = new Set(imetaInfos.map(info => info.url))
// Add content images that aren't already in imeta
imageUrls.forEach(url => {
if (!processedUrls.has(url)) {
allImageInfos.push({ url: url, pubkey: event?.pubkey })
processedUrls.add(url)
}
})
// Get all unique videos - prioritize imeta tags, then add content videos that aren't in imeta
const allVideoInfos = imetaInfos.filter(info => {
// Check if the imeta info is a video by looking at the URL extension
const url = info.url
const extension = url.split('.').pop()?.toLowerCase()
return extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v'].includes(extension)
})
const processedVideoUrls = new Set(allVideoInfos.map(info => {
try {
return new URL(cleanUrl(info.url)).href
} catch {
return cleanUrl(info.url)
}
}))
// Add content videos that aren't already in imeta
videoUrls.forEach(url => {
const cleanedUrl = (() => {
try {
return cleanUrl(url)
} catch {
return url
}
})()
const normalizedUrl = (() => {
try {
return new URL(cleanedUrl).href
} catch {
return cleanedUrl
}
})()
if (!processedVideoUrls.has(normalizedUrl)) {
allVideoInfos.push({ url: url, pubkey: event?.pubkey })
processedVideoUrls.add(normalizedUrl)
}
})
logger.debug('[SimpleContent] Processing content:', {
totalLines: lines.length,
imetaImages: imetaInfos.length,
contentImages: imageUrls.length,
totalUniqueImages: allImageInfos.length,
imetaVideos: imetaInfos.filter(info => {
const extension = info.url.split('.').pop()?.toLowerCase()
return extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'm4v'].includes(extension)
}).length,
contentVideos: videoUrls.length,
totalUniqueVideos: allVideoInfos.length
})
// If we have images, create a single ImageGallery for all of them
if (allImageInfos.length > 0) {
logger.debug('[SimpleContent] Creating ImageGallery with all unique images:', {
count: allImageInfos.length,
urls: allImageInfos.map(i => i.url)
})
elements.push(
<div key={key++} className="my-4">
<ImageGallery
images={allImageInfos}
className="max-w-[400px]"
/>
</div>
)
}
// Add all unique videos to elements
allVideoInfos.forEach(videoInfo => {
const cleanedVideoUrl = (() => {
try {
return cleanUrl(videoInfo.url)
} catch {
return videoInfo.url
}
})()
elements.push(
<div key={key++} className="my-4">
<MediaPlayer
src={cleanedVideoUrl}
className="max-w-[400px] h-auto rounded-lg"
/>
</div>
)
})
// Process lines for text content (excluding images and videos)
lines.forEach((line) => {
// Skip lines that contain images or videos (already processed above)
const hasImage = line.match(/(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|heic|svg))/i)
const hasVideo = line.match(/(https?:\/\/[^\s]+\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v))/i)
if (hasImage || hasVideo) {
return // Skip this line as it's already processed
}
// Regular text line - add to markdown processing
markdownLines.push(line)
})
return {
markdownContent: markdownLines.join('\n'),
mediaElements: elements
}
}, [processedContent, imetaInfos, event?.pubkey, imetaVideoUrls])
const components = useMemo(() => ({
nostr: ({ rawText, bech32Id }: { rawText: string; bech32Id: string }) => (
<NostrNode rawText={rawText} bech32Id={bech32Id} />
),
a: ({ href, children, ...props }: any) => {
if (!href) {
return <span {...props} className="break-words" />
}
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-primary hover:underline break-words"
>
{children}
</a>
)
},
p: (props: any) => <p {...props} className="mb-2 last:mb-0" />,
code: (props: any) => <code {...props} className="bg-muted px-1 py-0.5 rounded text-sm break-words" />,
pre: (props: any) => <pre {...props} className="bg-muted p-3 rounded overflow-x-auto" />,
blockquote: (props: any) => <blockquote {...props} className="border-l-4 border-muted pl-4 italic" />,
ul: (props: any) => <ul {...props} className="list-disc list-inside mb-2" />,
ol: (props: any) => <ol {...props} className="list-decimal list-inside mb-2" />,
li: (props: any) => <li {...props} className="mb-1" />,
h1: (props: any) => <h1 {...props} className="text-xl font-bold mb-2 break-words" />,
h2: (props: any) => <h2 {...props} className="text-lg font-bold mb-2 break-words" />,
h3: (props: any) => <h3 {...props} className="text-base font-bold mb-2 break-words" />,
strong: (props: any) => <strong {...props} className="font-bold" />,
em: (props: any) => <em {...props} className="italic" />
}), [])
// Parse content for nostr addresses and media
const parsedContent = useMemo(() => {
return parseNostrContent(processedContent, event)
}, [processedContent, event])
return (
<div className={cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert', className)}>
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{markdownContent}
</Markdown>
{mediaElements}
<div className={cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className)}>
{renderNostrContent(parsedContent)}
</div>
)
}
}

511
src/lib/nostr-parser.tsx

@ -0,0 +1,511 @@ @@ -0,0 +1,511 @@
/**
* Nostr address parser that converts nostr: addresses to embedded content
*/
import { nip19 } from 'nostr-tools'
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded'
import ImageGallery from '@/components/ImageGallery'
import { cleanUrl, isImage, isMedia } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
export interface ParsedNostrContent {
elements: Array<{
type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'gallery' | 'url' | 'jumble-note'
content: string
bech32Id?: string
nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'
mediaUrl?: string
hashtag?: string
wikilink?: string
displayText?: string
images?: TImetaInfo[]
url?: string
noteId?: string
}>
}
/**
* Parse content and convert nostr: addresses and media URLs to embedded components
*/
export function parseNostrContent(content: string, event?: Event): ParsedNostrContent {
const elements: ParsedNostrContent['elements'] = []
// Regex to match nostr: addresses that are not inside URLs or other contexts
const nostrRegex = /(?:^|\s|>|\[)nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)(?=\s|$|>|\]|,|\.|!|\?|;|:)/g
// Regex to match all URLs (we'll filter by type later)
const urlRegex = /(https?:\/\/[^\s]+)/gi
// Regex to match hashtags
const hashtagRegex = /#([a-zA-Z0-9_]+)/g
// Regex to match wikilinks: [[target]] or [[target|display text]] or [[book:...]]
const wikilinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g
// Regex to match Jumble note URLs: https://jumble.imwald.eu/notes/noteId
const jumbleNoteRegex = /(https:\/\/jumble\.imwald\.eu\/notes\/([a-zA-Z0-9]+))/g
// Collect all matches (nostr, URLs, hashtags, wikilinks, and jumble notes) and sort by position
const allMatches: Array<{
type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'url' | 'jumble-note'
match: RegExpExecArray
start: number
end: number
url?: string
hashtag?: string
wikilink?: string
displayText?: string
noteId?: string
}> = []
// Find nostr matches
let nostrMatch
while ((nostrMatch = nostrRegex.exec(content)) !== null) {
if (isNostrAddressInValidContext(content, nostrMatch.index, nostrMatch.index + nostrMatch[0].length)) {
allMatches.push({
type: 'nostr',
match: nostrMatch,
start: nostrMatch.index,
end: nostrMatch.index + nostrMatch[0].length
})
}
}
// Find URL matches and categorize them
let urlMatch
while ((urlMatch = urlRegex.exec(content)) !== null) {
const url = urlMatch[1]
const cleanedUrl = cleanUrl(url)
// Check if it's an image
if (isImage(cleanedUrl)) {
allMatches.push({
type: 'image',
match: urlMatch,
start: urlMatch.index,
end: urlMatch.index + urlMatch[0].length,
url: cleanedUrl
})
}
// Check if it's media (video/audio)
else if (isMedia(cleanedUrl)) {
// Determine if it's video or audio based on extension
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(cleanedUrl)
allMatches.push({
type: isVideo ? 'video' : 'audio',
match: urlMatch,
start: urlMatch.index,
end: urlMatch.index + urlMatch[0].length,
url: cleanedUrl
})
}
// Regular URL (not media)
else {
allMatches.push({
type: 'url',
match: urlMatch,
start: urlMatch.index,
end: urlMatch.index + urlMatch[0].length,
url: cleanedUrl
})
}
}
// Find hashtag matches
let hashtagMatch
while ((hashtagMatch = hashtagRegex.exec(content)) !== null) {
allMatches.push({
type: 'hashtag',
match: hashtagMatch,
start: hashtagMatch.index,
end: hashtagMatch.index + hashtagMatch[0].length,
hashtag: hashtagMatch[1]
})
}
// Find wikilink matches
let wikilinkMatch
while ((wikilinkMatch = wikilinkRegex.exec(content)) !== null) {
allMatches.push({
type: 'wikilink',
match: wikilinkMatch,
start: wikilinkMatch.index,
end: wikilinkMatch.index + wikilinkMatch[0].length,
wikilink: wikilinkMatch[1],
displayText: wikilinkMatch[2] || wikilinkMatch[1]
})
}
// Find Jumble note URL matches
let jumbleNoteMatch
while ((jumbleNoteMatch = jumbleNoteRegex.exec(content)) !== null) {
allMatches.push({
type: 'jumble-note',
match: jumbleNoteMatch,
start: jumbleNoteMatch.index,
end: jumbleNoteMatch.index + jumbleNoteMatch[0].length,
url: jumbleNoteMatch[1],
noteId: jumbleNoteMatch[2]
})
}
// Sort matches by position
allMatches.sort((a, b) => a.start - b.start)
let lastIndex = 0
for (const { type, match, start, end, url, hashtag, wikilink, displayText, noteId } of allMatches) {
// Add text before the match
if (start > lastIndex) {
const textContent = content.slice(lastIndex, start)
if (textContent) {
elements.push({
type: 'text',
content: textContent
})
}
}
if (type === 'nostr') {
const bech32Id = match[1]
const nostrType = getNostrType(bech32Id)
elements.push({
type: 'nostr',
content: match[0],
bech32Id,
nostrType: nostrType || undefined
})
} else if (['image', 'video', 'audio'].includes(type) && url) {
elements.push({
type: type as 'image' | 'video' | 'audio',
content: match[0],
mediaUrl: url
})
} else if (type === 'hashtag' && hashtag) {
elements.push({
type: 'hashtag',
content: match[0],
hashtag: hashtag
})
} else if (type === 'wikilink' && wikilink) {
elements.push({
type: 'wikilink',
content: match[0],
wikilink: wikilink,
displayText: displayText
})
} else if (type === 'url' && url) {
elements.push({
type: 'url',
content: match[0],
url: url
})
} else if (type === 'jumble-note' && url && noteId) {
elements.push({
type: 'jumble-note',
content: match[0],
url: url,
noteId: noteId
})
}
lastIndex = end
}
// Add remaining text after the last match
if (lastIndex < content.length) {
const textContent = content.slice(lastIndex)
if (textContent) {
elements.push({
type: 'text',
content: textContent
})
}
}
// Collect all images from content and imeta tags
const allImages: TImetaInfo[] = []
const processedUrls = new Set<string>()
// Add imeta images first (they have priority) - only actual images, not videos
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach(imageInfo => {
// Only add if it's actually an image (not video/audio)
if (!processedUrls.has(imageInfo.url) && isImage(imageInfo.url)) {
allImages.push(imageInfo)
processedUrls.add(imageInfo.url)
}
})
}
// Add content images that aren't already in imeta
elements.forEach(element => {
if (element.type === 'image' && element.mediaUrl) {
if (!processedUrls.has(element.mediaUrl)) {
allImages.push({ url: element.mediaUrl, pubkey: event?.pubkey })
processedUrls.add(element.mediaUrl)
}
}
})
// Process imeta videos separately
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach(imetaInfo => {
// Check if it's a video that hasn't been processed yet
if (isMedia(imetaInfo.url) && !isImage(imetaInfo.url)) {
// Check if this video is already in elements
const alreadyProcessed = elements.some(element =>
element.type === 'video' && element.mediaUrl === imetaInfo.url
)
if (!alreadyProcessed) {
// Determine if it's video or audio based on extension
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(imetaInfo.url)
elements.push({
type: isVideo ? 'video' : 'audio',
content: imetaInfo.url,
mediaUrl: imetaInfo.url
})
}
}
})
}
// If we have images, add a gallery element and remove individual image elements
if (allImages.length > 0) {
// Remove individual image elements
const filteredElements = elements.filter(element => element.type !== 'image')
// Add gallery element at the end
filteredElements.push({
type: 'gallery',
content: '',
images: allImages
})
return { elements: filteredElements }
}
// If no special content found, return the whole content as text
if (elements.length === 0) {
elements.push({
type: 'text',
content
})
}
return { elements }
}
/**
* Check if a nostr address is in a valid context (not inside URLs, etc.)
*/
function isNostrAddressInValidContext(content: string, start: number, _end: number): boolean {
// Don't parse if it's inside a URL (preceded by http://, https://, or www.)
const beforeContext = content.slice(Math.max(0, start - 20), start)
if (beforeContext.match(/(https?:\/\/|www\.)[^\s]*$/)) {
return false
}
// Don't parse if it's inside markdown links [text](url) or images ![text](url)
const beforeMatch = content.slice(Math.max(0, start - 10), start)
if (beforeMatch.match(/[!]?\[[^\]]*\]\([^)]*$/)) {
return false
}
// Don't parse if it's inside HTML tags
const beforeTag = content.slice(Math.max(0, start - 50), start)
if (beforeTag.match(/<[^>]*$/)) {
return false
}
// Don't parse if it's inside code blocks or inline code
const beforeCode = content.slice(Math.max(0, start - 10), start)
if (beforeCode.match(/`[^`]*$/)) {
return false
}
// Don't parse if it's inside a code block (```)
const beforeCodeBlock = content.slice(0, start)
const codeBlockMatches = beforeCodeBlock.match(/```/g)
if (codeBlockMatches && codeBlockMatches.length % 2 === 1) {
return false
}
return true
}
/**
* Get the nostr type from a bech32 ID
*/
function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' | null {
try {
const { type } = nip19.decode(bech32Id)
if (['npub', 'nprofile', 'nevent', 'naddr', 'note'].includes(type)) {
return type as 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'
}
} catch (error) {
console.error('Invalid bech32 ID:', bech32Id, error)
}
return null
}
/**
* Render parsed nostr content as React elements
*/
export function renderNostrContent(parsedContent: ParsedNostrContent, className?: string): JSX.Element {
return (
<div className={className}>
{parsedContent.elements.map((element, index) => {
if (element.type === 'text') {
return (
<span key={index} className="whitespace-pre-wrap break-words">
{element.content}
</span>
)
}
if (element.type === 'gallery' && element.images) {
return (
<div key={index} className="my-2">
<ImageGallery
images={element.images}
className="max-w-[400px]"
/>
</div>
)
}
if (element.type === 'video' && element.mediaUrl) {
return (
<video
key={index}
src={element.mediaUrl}
controls
className="max-w-[400px] w-full h-auto rounded-lg my-2 block"
preload="metadata"
onError={(e) => {
// Fallback to text if video fails to load
const target = e.target as HTMLVideoElement
target.style.display = 'none'
const textSpan = document.createElement('span')
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline'
textSpan.textContent = element.content
target.parentNode?.insertBefore(textSpan, target.nextSibling)
}}
>
Your browser does not support the video tag.
</video>
)
}
if (element.type === 'audio' && element.mediaUrl) {
return (
<audio
key={index}
src={element.mediaUrl}
controls
className="w-full my-2 block"
preload="metadata"
onError={(e) => {
// Fallback to text if audio fails to load
const target = e.target as HTMLAudioElement
target.style.display = 'none'
const textSpan = document.createElement('span')
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline'
textSpan.textContent = element.content
target.parentNode?.insertBefore(textSpan, target.nextSibling)
}}
>
Your browser does not support the audio tag.
</audio>
)
}
if (element.type === 'hashtag' && element.hashtag) {
const normalizedHashtag = element.hashtag.toLowerCase()
return (
<a
key={index}
href={`/notes?t=${normalizedHashtag}`}
className="text-primary hover:text-primary/80 hover:underline break-words"
>
#{element.hashtag}
</a>
)
}
if (element.type === 'wikilink' && element.wikilink && element.displayText) {
const normalizedWikilink = element.wikilink.toLowerCase()
return (
<a
key={index}
href={`/wiki/${encodeURIComponent(normalizedWikilink)}`}
className="text-primary hover:text-primary/80 hover:underline break-words"
>
{element.displayText}
</a>
)
}
if (element.type === 'url' && element.url) {
return (
<a
key={index}
href={element.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 hover:underline break-words"
>
{element.content}
</a>
)
}
if (element.type === 'jumble-note' && element.noteId) {
return (
<EmbeddedNote
key={index}
noteId={element.noteId}
className="not-prose inline-block"
/>
)
}
if (element.type === 'nostr' && element.bech32Id && element.nostrType) {
// Render as embedded content
if (element.nostrType === 'npub' || element.nostrType === 'nprofile') {
return (
<EmbeddedMention
key={index}
userId={element.bech32Id}
className="not-prose inline-block"
/>
)
} else if (['nevent', 'naddr', 'note'].includes(element.nostrType)) {
return (
<EmbeddedNote
key={index}
noteId={element.bech32Id}
className="not-prose inline-block"
/>
)
}
}
// Fallback to text if something goes wrong
return (
<span key={index} className="whitespace-pre-wrap break-words">
{element.content}
</span>
)
})}
</div>
)
}

4
src/pages/secondary/NotePage/index.tsx

@ -109,7 +109,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; @@ -109,7 +109,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : getNoteTypeTitle(finalEvent.kind)} displayScrollToTopButton>
<div className="px-4 pt-3 max-w-4xl mx-auto">
<div className="px-4 pt-3 w-full">
{rootITag && <ExternalRoot value={rootITag[1]} />}
{rootEventId && rootEventId !== parentEventId && (
<ParentNote
@ -139,7 +139,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; @@ -139,7 +139,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
<NoteStats className="mt-3" event={finalEvent} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<div className="px-4 pb-4 max-w-4xl mx-auto">
<div className="px-4 pb-4 w-full">
<NoteInteractions key={`note-interactions-${finalEvent.id}`} pageIndex={index} event={finalEvent} />
</div>
</SecondaryPageLayout>

Loading…
Cancel
Save