Browse Source

render internal and external hyperlinks sensibly

imwald
Silberengel 1 month ago
parent
commit
77e5faf1fe
  1. 21
      src/components/Content/index.tsx
  2. 5
      src/components/ContentPreview/NormalContentPreview.tsx
  3. 134
      src/components/Embedded/HttpNostrAwareUrl.tsx
  4. 1
      src/components/Embedded/index.tsx
  5. 17
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 2
      src/components/UniversalContent/SimpleContent.tsx
  7. 1
      src/i18n/locales/en.ts
  8. 194
      src/lib/nostr-from-http-url.ts
  9. 57
      src/lib/nostr-parser.tsx

21
src/components/Content/index.tsx

@ -7,6 +7,7 @@ import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getHttpUrlFromITags } from '@/lib/event' import { getHttpUrlFromITags } from '@/lib/event'
import { httpUrlSkipsBottomWebPreview } from '@/lib/nostr-from-http-url'
import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url' import { cleanUrl, isImage, isMedia, isAudio, isVideo, isPseudoNostrHttpsUrl } from '@/lib/url'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -17,7 +18,8 @@ import {
EmbeddedMention, EmbeddedMention,
EmbeddedNormalUrl, EmbeddedNormalUrl,
EmbeddedNote, EmbeddedNote,
EmbeddedWebsocketUrl EmbeddedWebsocketUrl,
HttpNostrAwareUrl
} from '../Embedded' } from '../Embedded'
import PaytoLink from '../PaytoLink' import PaytoLink from '../PaytoLink'
import Emoji from '../Emoji' import Emoji from '../Emoji'
@ -109,6 +111,7 @@ export default function Content({
if (!nodes) return [] if (!nodes) return []
const links: string[] = [] const links: string[] = []
const seenUrls = new Set<string>() const seenUrls = new Set<string>()
const appOrigin = typeof window !== 'undefined' ? window.location.origin : null
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.type === 'url') { if (node.type === 'url') {
@ -121,7 +124,12 @@ export default function Content({
!isYouTubeUrl(url) !isYouTubeUrl(url)
) { ) {
const cleaned = cleanUrl(url) const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned) && !(iArticleCleaned && cleaned === iArticleCleaned)) { if (
cleaned &&
!seenUrls.has(cleaned) &&
!(iArticleCleaned && cleaned === iArticleCleaned) &&
!httpUrlSkipsBottomWebPreview(url, appOrigin)
) {
links.push(cleaned) links.push(cleaned)
seenUrls.add(cleaned) seenUrls.add(cleaned)
} }
@ -461,7 +469,14 @@ export default function Content({
if (iArticleCleaned && cleanedUrl === iArticleCleaned) { if (iArticleCleaned && cleanedUrl === iArticleCleaned) {
return null return null
} }
return <EmbeddedNormalUrl url={node.data} key={index} /> return (
<HttpNostrAwareUrl
key={index}
url={node.data}
renderMode="note-content"
containingEvent={event}
/>
)
} }
if (node.type === 'invoice') { if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" /> return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />

5
src/components/ContentPreview/NormalContentPreview.tsx

@ -1,4 +1,6 @@
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from './Content' import Content from './Content'
export default function NormalContentPreview({ export default function NormalContentPreview({
@ -8,5 +10,6 @@ export default function NormalContentPreview({
event: Event event: Event
className?: string className?: string
}) { }) {
return <Content content={event.content} className={className} /> const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags])
return <Content content={event.content} className={className} emojiInfos={emojiInfos} />
} }

134
src/components/Embedded/HttpNostrAwareUrl.tsx

@ -0,0 +1,134 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url'
import {
extractExternalUrlNostrForExpandable,
getBrowserAppOrigin,
parseSameOriginAppNostrUrl
} from '@/lib/nostr-from-http-url'
import { ChevronDown } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EmbeddedMention } from './EmbeddedMention'
import { EmbeddedNormalUrl } from './EmbeddedNormalUrl'
import { EmbeddedNote } from './EmbeddedNote'
import WebPreview from '@/components/WebPreview'
type RenderMode = 'note-content' | 'article'
export function HttpNostrAwareUrl({
url,
renderMode,
containingEvent,
className
}: {
url: string
renderMode: RenderMode
containingEvent?: Event
className?: string
}) {
const { t } = useTranslation()
const appOrigin = useMemo(() => getBrowserAppOrigin(), [])
const sameOriginTarget = useMemo(
() => parseSameOriginAppNostrUrl(url, appOrigin),
[url, appOrigin]
)
const expandableTarget = useMemo(
() => (!sameOriginTarget ? extractExternalUrlNostrForExpandable(url, appOrigin) : null),
[url, appOrigin, sameOriginTarget]
)
const cleaned = cleanUrl(url) || url
if (sameOriginTarget) {
if (sameOriginTarget.kind === 'event') {
return (
<EmbeddedNote
noteId={sameOriginTarget.id}
className={cn('mt-2', className)}
containingEvent={containingEvent}
/>
)
}
return (
<span className={cn('inline', className)}>
<EmbeddedMention userId={sameOriginTarget.id} className="inline" />
</span>
)
}
if (expandableTarget) {
return (
<ExpandableExternalNostrLink
url={url}
cleanedUrl={cleaned}
target={expandableTarget}
containingEvent={containingEvent}
className={className}
expandLabel={t('link.expandNostrEmbed')}
/>
)
}
if (renderMode === 'article') {
return <WebPreview url={cleaned} className={cn('mt-2', className)} />
}
return <EmbeddedNormalUrl url={url} />
}
function ExpandableExternalNostrLink({
url,
cleanedUrl,
target,
containingEvent,
className,
expandLabel
}: {
url: string
cleanedUrl: string
target: { kind: 'event' | 'profile'; id: string }
containingEvent?: Event
className?: string
expandLabel: string
}) {
const [open, setOpen] = useState(false)
return (
<span className={cn('inline-flex max-w-full flex-wrap items-center gap-0.5 align-baseline', className)}>
<EmbeddedNormalUrl url={url}>{cleanedUrl}</EmbeddedNormalUrl>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
aria-expanded={open}
aria-label={expandLabel}
title={expandLabel}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen((o) => !o)
}}
>
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} />
</Button>
{open ? (
<span className="block w-full basis-full">
{target.kind === 'event' ? (
<EmbeddedNote
noteId={target.id}
className="mt-2"
containingEvent={containingEvent}
/>
) : (
<span className="mt-2 inline-block">
<EmbeddedMention userId={target.id} />
</span>
)}
</span>
) : null}
</span>
)
}

1
src/components/Embedded/index.tsx

@ -1,4 +1,5 @@
export * from './EmbeddedCalendarEvent' export * from './EmbeddedCalendarEvent'
export * from './HttpNostrAwareUrl'
export * from './EmbeddedHashtag' export * from './EmbeddedHashtag'
export * from './EmbeddedLNInvoice' export * from './EmbeddedLNInvoice'
export * from './EmbeddedMention' export * from './EmbeddedMention'

17
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -32,7 +32,7 @@ import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import CalendarEventContent from '@/components/CalendarEventContent' import CalendarEventContent from '@/components/CalendarEventContent'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import { EmbeddedNote, EmbeddedMention, HttpNostrAwareUrl } from '@/components/Embedded'
import EmbeddedCitation from '@/components/EmbeddedCitation' import EmbeddedCitation from '@/components/EmbeddedCitation'
import { preprocessMarkdownMediaLinks } from './preprocessMarkup' import { preprocessMarkdownMediaLinks } from './preprocessMarkup'
import { PAYTO_URI_REGEX, parsePaytoUri } from '@/lib/payto' import { PAYTO_URI_REGEX, parsePaytoUri } from '@/lib/payto'
@ -439,6 +439,8 @@ function parseMarkdownContent(
fullCalendarInvite?: { naddr: string; event: Event } fullCalendarInvite?: { naddr: string; event: Event }
/** Cleaned URL variants: standalone markdown links matching any render as inline (OG elsewhere). */ /** Cleaned URL variants: standalone markdown links matching any render as inline (OG elsewhere). */
suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet<string> suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet<string>
/** Event whose body is being rendered (embedded notes / HTTP nostr links). */
containingEvent?: Event
} }
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } { ): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const { const {
@ -452,7 +454,8 @@ function parseMarkdownContent(
getImageIdentifier, getImageIdentifier,
emojiInfos = [], emojiInfos = [],
fullCalendarInvite, fullCalendarInvite,
suppressStandaloneWebPreviewCleanedUrls suppressStandaloneWebPreviewCleanedUrls,
containingEvent
} = options } = options
const parts: React.ReactNode[] = [] const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>() const hashtagsInContent = new Set<string>()
@ -1878,8 +1881,12 @@ function parseMarkdownContent(
) )
} else { } else {
parts.push( parts.push(
<div key={`webpreview-${patternIdx}`} className="my-2"> <div key={`http-nostr-url-${patternIdx}`} className="my-2 not-prose max-w-full">
<WebPreview url={url} className="w-full" /> <HttpNostrAwareUrl
url={url}
renderMode="article"
containingEvent={containingEvent}
/>
</div> </div>
) )
} }
@ -3648,6 +3655,7 @@ export default function MarkdownArticle({
getImageIdentifier, getImageIdentifier,
emojiInfos, emojiInfos,
fullCalendarInvite, fullCalendarInvite,
containingEvent: event,
suppressStandaloneWebPreviewCleanedUrls: suppressStandaloneWebPreviewCleanedUrls:
webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined
}) })
@ -3655,6 +3663,7 @@ export default function MarkdownArticle({
return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent } return { nodes: result.nodes, hashtagsInContent: result.hashtagsInContent }
}, [ }, [
preprocessedContent, preprocessedContent,
event,
event.pubkey, event.pubkey,
imageIndexMap, imageIndexMap,
openLightbox, openLightbox,

2
src/components/UniversalContent/SimpleContent.tsx

@ -48,7 +48,7 @@ export default function SimpleContent({
return ( return (
<div className={cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className)}> <div className={cn('prose prose-sm prose-zinc max-w-none break-words dark:prose-invert w-full', className)}>
{renderNostrContent(parsedContent)} {renderNostrContent(parsedContent, undefined, event)}
</div> </div>
) )
} }

1
src/i18n/locales/en.ts

@ -27,6 +27,7 @@ export default {
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
Refresh: 'Refresh', Refresh: 'Refresh',
'refresh.longPressHardReload': 'Long-press: reload app and restore feed cache', 'refresh.longPressHardReload': 'Long-press: reload app and restore feed cache',
'link.expandNostrEmbed': 'Show Nostr preview',
Profile: 'Profile', Profile: 'Profile',
Logout: 'Logout', Logout: 'Logout',
Following: 'Following', Following: 'Following',

194
src/lib/nostr-from-http-url.ts

@ -0,0 +1,194 @@
import { nip19 } from 'nostr-tools'
/** Find npub1 / nprofile1 / note1 / nevent1 / naddr1 tokens in text. */
const BECH32_NOSTR_RE = /(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+/gi
export type NostrUrlExtract = { kind: 'event' | 'profile'; id: string }
function classifyBech32(id: string): NostrUrlExtract | null {
try {
const { type } = nip19.decode(id)
if (type === 'npub' || type === 'nprofile') return { kind: 'profile', id }
if (type === 'note' || type === 'nevent' || type === 'naddr') return { kind: 'event', id }
} catch {
// ignore
}
return null
}
function firstNostrExtractInString(s: string): NostrUrlExtract | null {
const re = new RegExp(BECH32_NOSTR_RE.source, 'gi')
let m: RegExpExecArray | null
while ((m = re.exec(s)) !== null) {
const hit = classifyBech32(m[0])
if (hit) return hit
}
return null
}
function isValidEmbeddedNotePointer(id: string): boolean {
const s = id.trim()
if (/^[0-9a-f]{64}$/i.test(s)) return true
const hit = classifyBech32(s)
return hit?.kind === 'event'
}
function isProfilePointer(id: string): boolean {
const s = id.trim()
if (/^[0-9a-f]{64}$/i.test(s)) return true
const hit = classifyBech32(s)
return hit?.kind === 'profile'
}
function extractHex64(s: string): string | null {
const m = s.match(/\b[0-9a-f]{64}\b/i)
return m ? m[0].toLowerCase() : null
}
/**
* True if this hostname serves this web app: current tab origin and/or known production/dev hosts.
* Needed so `https://jumble.imwald.eu/.../notes/nevent…` embeds while the dev server runs on localhost.
*/
export function urlHostnameIsKnownJumbleAppHost(
urlHostname: string,
appOrigin: string | null
): boolean {
const h = urlHostname.toLowerCase()
if (h === 'jumble.imwald.eu') return true
if (h === 'localhost' || h === '127.0.0.1') return true
if (appOrigin) {
try {
if (h === new URL(appOrigin).hostname.toLowerCase()) return true
} catch {
// ignore
}
}
return false
}
/**
* In-app HTTP(S) links to our routes embed like `nostr:…` (same tab origin or known jumble/localhost host).
*/
export function parseSameOriginAppNostrUrl(urlStr: string, appOrigin: string | null): NostrUrlExtract | null {
let u: URL
try {
u = new URL(urlStr)
} catch {
return null
}
if (!urlHostnameIsKnownJumbleAppHost(u.hostname, appOrigin)) return null
let path = u.pathname
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1)
if (!path) path = '/'
const usersMatch = path.match(/^\/users\/([^/?#]+)$/i)
if (usersMatch) {
const id = decodeURIComponent(usersMatch[1])
if (isProfilePointer(id)) {
return { kind: 'profile', id }
}
return null
}
const notesMatch = path.match(/\/notes\/([^/?#]+)$/i)
if (notesMatch) {
const id = decodeURIComponent(notesMatch[1])
if (isValidEmbeddedNotePointer(id)) {
return { kind: 'event', id }
}
return null
}
return null
}
const QUERY_KEYS_PRIORITY = [
'id',
'nevent',
'note',
'naddr',
'event',
'e',
'npub',
'nprofile',
'pubkey',
'user',
'p',
'author'
]
/**
* Third-party URLs: Nostr id in query or path offer chevron-expand embed (not auto).
*/
export function extractExternalUrlNostrForExpandable(
urlStr: string,
appOrigin: string | null
): NostrUrlExtract | null {
if (parseSameOriginAppNostrUrl(urlStr, appOrigin)) return null
let u: URL
try {
u = new URL(urlStr)
} catch {
return null
}
const tryPiece = (raw: string): NostrUrlExtract | null => {
const s = raw.trim()
if (!s) return null
const hex = extractHex64(s)
if (hex) return { kind: 'event', id: hex }
const b = firstNostrExtractInString(s)
if (b) return b
return null
}
for (const key of QUERY_KEYS_PRIORITY) {
const v = u.searchParams.get(key)
if (!v) continue
let decoded = v
try {
decoded = decodeURIComponent(v)
} catch {
// use raw
}
const hit = tryPiece(decoded)
if (hit) return hit
}
for (const [, v] of u.searchParams.entries()) {
let decoded = v
try {
decoded = decodeURIComponent(v)
} catch {
// use raw
}
const hit = tryPiece(decoded)
if (hit) return hit
}
const pathHit = tryPiece(u.pathname)
if (pathHit) return pathHit
const hash = u.hash ? u.hash.slice(1) : ''
if (hash) {
const hashHit = tryPiece(hash)
if (hashHit) return hashHit
}
return firstNostrExtractInString(u.href) ?? null
}
export function getBrowserAppOrigin(): string | null {
if (typeof window === 'undefined') return null
return window.location.origin
}
/** Skip duplicate WebPreview at bottom of note when URL is handled as embed / expandable. */
export function httpUrlSkipsBottomWebPreview(urlStr: string, appOrigin: string | null): boolean {
return (
parseSameOriginAppNostrUrl(urlStr, appOrigin) != null ||
extractExternalUrlNostrForExpandable(urlStr, appOrigin) != null
)
}

57
src/lib/nostr-parser.tsx

@ -3,9 +3,8 @@
*/ */
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' import { EmbeddedMention, EmbeddedNote, HttpNostrAwareUrl } from '@/components/Embedded'
import ImageGallery from '@/components/ImageGallery' import ImageGallery from '@/components/ImageGallery'
import WebPreview from '@/components/WebPreview'
import { BookstrContent } from '@/components/Bookstr/BookstrContent' import { BookstrContent } from '@/components/Bookstr/BookstrContent'
import { cleanUrl, isImage, isMedia, isPseudoNostrHttpsUrl } from '@/lib/url' import { cleanUrl, isImage, isMedia, isPseudoNostrHttpsUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
@ -19,7 +18,7 @@ import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
export interface ParsedNostrContent { export interface ParsedNostrContent {
elements: Array<{ elements: Array<{
type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'jumble-note' | 'payto' type: 'text' | 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'gallery' | 'url' | 'payto'
content: string content: string
bech32Id?: string bech32Id?: string
nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note' nostrType?: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'
@ -62,16 +61,13 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
// Regex to match wikilinks: [[target]] or [[target|display text]] or [[book::...]] // Regex to match wikilinks: [[target]] or [[target|display text]] or [[book::...]]
const wikilinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g 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
// Regex to match bookstr search URLs: any URL containing book%3A%3A or book:: // Regex to match bookstr search URLs: any URL containing book%3A%3A or book::
// Matches the pattern and captures the search term (everything after book%3A%3A or book:: until /, ?, #, &, or end) // Matches the pattern and captures the search term (everything after book%3A%3A or book:: until /, ?, #, &, or end)
const bookstrUrlRegex = /(https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))/gi const bookstrUrlRegex = /(https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))/gi
// Collect all matches (nostr, URLs, hashtags, wikilinks, jumble notes, and bookstr URLs) and sort by position // Collect all matches (nostr, URLs, hashtags, wikilinks, bookstr URLs) and sort by position
const allMatches: Array<{ const allMatches: Array<{
type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'jumble-note' | 'payto' type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'payto'
match: RegExpExecArray match: RegExpExecArray
start: number start: number
end: number end: number
@ -81,7 +77,6 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
displayText?: string displayText?: string
bookstrWikilink?: string bookstrWikilink?: string
sourceUrl?: string sourceUrl?: string
noteId?: string
paytoUri?: string paytoUri?: string
}> = [] }> = []
@ -253,25 +248,12 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
} }
} }
// 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 // Sort matches by position
allMatches.sort((a, b) => a.start - b.start) allMatches.sort((a, b) => a.start - b.start)
let lastIndex = 0 let lastIndex = 0
for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, noteId, paytoUri } of allMatches) { for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, paytoUri } of allMatches) {
// Add text before the match // Add text before the match
if (start > lastIndex) { if (start > lastIndex) {
const textContent = content.slice(lastIndex, start) const textContent = content.slice(lastIndex, start)
@ -373,13 +355,6 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
content: match[0], content: match[0],
url: url url: url
}) })
} else if (type === 'jumble-note' && url && noteId) {
elements.push({
type: 'jumble-note',
content: match[0],
url: url,
noteId: noteId
})
} else if (type === 'payto' && paytoUri) { } else if (type === 'payto' && paytoUri) {
elements.push({ elements.push({
type: 'payto', type: 'payto',
@ -556,7 +531,11 @@ function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr
/** /**
* Render parsed nostr content as React elements * Render parsed nostr content as React elements
*/ */
export function renderNostrContent(parsedContent: ParsedNostrContent, className?: string): JSX.Element { export function renderNostrContent(
parsedContent: ParsedNostrContent,
className?: string,
containingEvent?: Event
): JSX.Element {
return ( return (
<div className={className}> <div className={className}>
{parsedContent.elements.map((element, index) => { {parsedContent.elements.map((element, index) => {
@ -671,22 +650,12 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className?
} }
if (element.type === 'url' && element.url) { if (element.type === 'url' && element.url) {
// Use WebPreview for URLs to show OpenGraph cards
return ( return (
<WebPreview <HttpNostrAwareUrl
key={index} key={index}
url={element.url} url={element.url}
className="mt-2" renderMode="article"
/> containingEvent={containingEvent}
)
}
if (element.type === 'jumble-note' && element.noteId) {
return (
<EmbeddedNote
key={index}
noteId={element.noteId}
className="not-prose inline-block"
/> />
) )
} }

Loading…
Cancel
Save