diff --git a/src/components/Bookstr/BookstrContent.tsx b/src/components/Bookstr/BookstrContent.tsx index d8c25a4..a3b6f62 100644 --- a/src/components/Bookstr/BookstrContent.tsx +++ b/src/components/Bookstr/BookstrContent.tsx @@ -3,7 +3,7 @@ import { Event } from 'nostr-tools' import { parseBookWikilink, extractBookMetadata, BookReference } from '@/lib/bookstr-parser' import client from '@/services/client.service' import { ExtendedKind } from '@/constants' -import { Loader2, AlertCircle } from 'lucide-react' +import { Loader2, AlertCircle, ExternalLink } from 'lucide-react' import { Select, SelectContent, @@ -18,6 +18,7 @@ import WebPreview from '@/components/WebPreview' interface BookstrContentProps { wikilink: string + sourceUrl?: string className?: string } @@ -212,7 +213,7 @@ function buildExternalUrl(reference: BookReference, bookType: string, version?: } } -export function BookstrContent({ wikilink, className }: BookstrContentProps) { +export function BookstrContent({ wikilink, sourceUrl, className }: BookstrContentProps) { const [sections, setSections] = useState([]) const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching const [error, setError] = useState(null) @@ -942,6 +943,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { }} /> + {/* Source URL link button */} + {sourceUrl && ( + + + Source + + )} {/* OG Preview Card for bible/torah/quran external URLs */} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 5d8893d..75cb7d4 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -780,6 +780,13 @@ function parseMarkdownContent( return } + // Skip if the URL is a bookstr URL (contains book%3A%3A or book::) + const linkUrl = match[2] + const isBookstrUrl = /(?:book%3A%3A|book::)/i.test(linkUrl) + if (isBookstrUrl) { + return + } + // Check if link is standalone (on its own line, not part of a sentence/list/quote) const isStandalone = (() => { // Get the line containing this link @@ -925,6 +932,93 @@ function parseMarkdownContent( } }) + // Bookstr URLs: detect markdown links containing bookstr URLs first, then standalone bookstr URLs + // This must be detected before regular markdown links to avoid conflicts + const markdownLinkWithBookstrRegex = /\[([^\]]+)\]\((https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))\)/gi + const markdownBookstrMatches = Array.from(content.matchAll(markdownLinkWithBookstrRegex)) + markdownBookstrMatches.forEach(match => { + if (match.index !== undefined) { + const fullUrl = match[2] + const searchTermEncoded = match[3] + const start = match.index + const end = match.index + match[0].length + + // Only add if not already covered by other patterns and not in block pattern + const isInOther = patterns.some(p => + (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || + p.type === 'relay-url' || p.type === 'youtube-url') && + start >= p.index && + start < p.end + ) + + if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) { + try { + // Decode the URL-encoded search term + const decodedSearchTerm = decodeURIComponent(searchTermEncoded) + + // Check if it starts with book:: (it should, but handle both cases) + let bookstrWikilink = decodedSearchTerm + if (!bookstrWikilink.startsWith('book::')) { + // If it doesn't start with book::, add it + bookstrWikilink = `book::${bookstrWikilink}` + } + + patterns.push({ + index: start, + end: end, + type: 'bookstr-url', + data: { wikilink: bookstrWikilink.trim(), sourceUrl: fullUrl } + }) + } catch (err) { + // If decoding fails, skip this URL (will be handled as regular URL) + } + } + } + }) + + // Standalone bookstr URLs (not in markdown links): any URL containing book%3A%3A or book:: pattern + const bookstrUrlRegex = /(https?:\/\/[^\s]*(?:book%3A%3A|book::)([^\/\?\#\&\s]+))/gi + const bookstrUrlMatches = Array.from(content.matchAll(bookstrUrlRegex)) + bookstrUrlMatches.forEach(match => { + if (match.index !== undefined) { + const fullUrl = match[1] + const searchTermEncoded = match[2] + const start = match.index + const end = match.index + match[0].length + + // Only add if not already covered by other patterns (including markdown links with bookstr URLs) and not in block pattern + const isInOther = patterns.some(p => + (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || + p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'bookstr-url') && + start >= p.index && + start < p.end + ) + + if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) { + try { + // Decode the URL-encoded search term + const decodedSearchTerm = decodeURIComponent(searchTermEncoded) + + // Check if it starts with book:: (it should, but handle both cases) + let bookstrWikilink = decodedSearchTerm + if (!bookstrWikilink.startsWith('book::')) { + // If it doesn't start with book::, add it + bookstrWikilink = `book::${bookstrWikilink}` + } + + patterns.push({ + index: start, + end: end, + type: 'bookstr-url', + data: { wikilink: bookstrWikilink.trim(), sourceUrl: fullUrl } + }) + } catch (err) { + // If decoding fails, skip this URL (will be handled as regular URL) + } + } + } + }) + // Citation markup: [[citation::type::nevent...]] const citationRegex = /\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g const citationMatches = Array.from(content.matchAll(citationRegex)) @@ -2023,6 +2117,11 @@ function parseMarkdownContent( if (shouldAddSpace) { parts.push( ) } + } else if (pattern.type === 'bookstr-url') { + const { wikilink, sourceUrl } = pattern.data + parts.push( + + ) } else if (pattern.type === 'wikilink') { const linkContent = pattern.data diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index abd0fa9..83ddf87 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -9,11 +9,15 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { ExternalLink } from 'lucide-react' import { nip19, kinds } from 'nostr-tools' -import { useMemo } from 'react' +import { useMemo, useEffect, useState } from 'react' import Image from '../Image' import Username from '../Username' import { cleanUrl } from '@/lib/url' import { tagNameEquals } from '@/lib/tag' +import client from '@/services/client.service' +import { Event } from 'nostr-tools' +import { BIG_RELAY_URLS } from '@/constants' +import { getImetaInfosFromEvent } from '@/lib/event' // Helper function to get event type name function getEventTypeName(kind: number): string { @@ -95,16 +99,197 @@ export default function WebPreview({ url, className }: { url: string; className? const isInternalJumbleLink = useMemo(() => hostname === 'jumble.imwald.eu', [hostname]) + // Extract replaceable event info (d-tag and pubkey) from URL patterns + // This is separate from nostrIdentifier to allow fetching without kind + const replaceableEventInfo = useMemo(() => { + try { + // Pattern 1: d-tag*npub format + const dtagNpubMatch = cleanedUrl.match(/([^\/\?\#\&\*]+)\*(npub1[a-z0-9]{58})/i) + if (dtagNpubMatch) { + const dTag = dtagNpubMatch[1].split('/').pop() || dtagNpubMatch[1] + const npub = dtagNpubMatch[2] + try { + const decoded = nip19.decode(npub) + if (decoded.type === 'npub') { + return { dTag, pubkey: decoded.data } + } + } catch {} + } + + // Pattern 2: d-tag*hexpubkey format + const dtagHexMatch = cleanedUrl.match(/([^\/\?\#\&\*]+)\*([a-f0-9]{64})/i) + if (dtagHexMatch) { + const dTag = dtagHexMatch[1].split('/').pop() || dtagHexMatch[1] + const hexPubkey = dtagHexMatch[2] + return { dTag, pubkey: hexPubkey } + } + + // Pattern 3: d-tag/npub format + const dtagSlashNpubMatch = cleanedUrl.match(/([^\/\?\#\&]+)\/(npub1[a-z0-9]{58})/i) + if (dtagSlashNpubMatch) { + const dTag = dtagSlashNpubMatch[1].split('/').pop() || dtagSlashNpubMatch[1] + const npub = dtagSlashNpubMatch[2] + try { + const decoded = nip19.decode(npub) + if (decoded.type === 'npub') { + return { dTag, pubkey: decoded.data } + } + } catch {} + } + + // Pattern 4: d-tag and npub in path (e.g., https://wikifreedia.xyz/nostr-event-register/npub1...) + // Only check if we haven't already matched a more specific pattern + if (!dtagNpubMatch && !dtagHexMatch && !dtagSlashNpubMatch) { + const pathNpubMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) + if (pathNpubMatch) { + const npub = pathNpubMatch[1] + const npubIndex = cleanedUrl.indexOf(npub) + const pathBeforeNpub = cleanedUrl.substring(0, npubIndex) + const pathSegments = pathBeforeNpub.split('/').filter(Boolean) + if (pathSegments.length > 0) { + const possibleDTag = pathSegments[pathSegments.length - 1] + try { + const decoded = nip19.decode(npub) + if (decoded.type === 'npub') { + return { dTag: possibleDTag, pubkey: decoded.data } + } + } catch {} + } + } + } + + // Pattern 5: d-tag only with /d/ prefix - try to find pubkey in URL + // Only check if we haven't already matched a pattern with both d-tag and pubkey + if (!dtagNpubMatch && !dtagHexMatch && !dtagSlashNpubMatch) { + const dtagOnlyMatch = cleanedUrl.match(/\/d\/([^\/\?\#\&]+)/i) + if (dtagOnlyMatch) { + const dTag = dtagOnlyMatch[1] + const urlParts = cleanedUrl.split('/d/') + const pathBefore = urlParts[0].split('/').filter(Boolean) + const pathAfter = urlParts[1] ? urlParts[1].split('/').filter(Boolean) : [] + const allPathParts = [...pathBefore, ...pathAfter] + + for (const part of allPathParts) { + if (/^npub1[a-z0-9]{58}$/i.test(part)) { + try { + const decoded = nip19.decode(part) + if (decoded.type === 'npub') { + return { dTag, pubkey: decoded.data } + } + } catch {} + } else if (/^[a-f0-9]{64}$/i.test(part)) { + return { dTag, pubkey: part } + } + } + // If no pubkey found, return d-tag only (we can't fetch without pubkey, will show OG card) + return { dTag, pubkey: null } + } + } + } catch (error) { + // Failed to parse + } + return null + }, [cleanedUrl]) + + // Fetch replaceable event by d-tag and pubkey (without kind) + // If pubkey is null, fetch by d-tag only (across all authors) + // Only use the result if exactly one event is found (to avoid ambiguous d-tags) + const [fetchedReplaceableEvent, setFetchedReplaceableEvent] = useState(null) + const [isFetchingReplaceableEvent, setIsFetchingReplaceableEvent] = useState(false) + + useEffect(() => { + if (!replaceableEventInfo || !replaceableEventInfo.dTag) { + setFetchedReplaceableEvent(null) + setIsFetchingReplaceableEvent(false) + return + } + + setIsFetchingReplaceableEvent(true) + + // Fetch replaceable events by d-tag and pubkey across all replaceable kinds + // Common replaceable event kinds + const replaceableKinds = [30023, 30818, 30041, 30817, 30040, 30024] + + const fetchReplaceableEvent = async () => { + try { + const filters = replaceableKinds.map(kind => { + const filter: any = { + kinds: [kind], + '#d': [replaceableEventInfo.dTag], + limit: 1 + } + // Only filter by author if we have a pubkey + if (replaceableEventInfo.pubkey) { + filter.authors = [replaceableEventInfo.pubkey] + } + return filter + }) + + const events = await client.fetchEvents(BIG_RELAY_URLS, filters) + + // Find all events with matching d-tag + const matchingEvents = events.filter(event => { + const eventDTag = event.tags.find(tagNameEquals('d'))?.[1] + return eventDTag === replaceableEventInfo.dTag + }) + + // Only use the result if exactly one event is found + // If zero or multiple events, fall back to OG card (ambiguous d-tag) + if (matchingEvents.length === 1) { + setFetchedReplaceableEvent(matchingEvents[0]) + } else { + setFetchedReplaceableEvent(null) + } + } catch (error) { + // Failed to fetch + setFetchedReplaceableEvent(null) + } finally { + setIsFetchingReplaceableEvent(false) + } + } + + fetchReplaceableEvent() + }, [replaceableEventInfo]) + // Extract nostr identifier from URL + // If we found a replaceable event and fetched it, create naddr from the fetched event + // Otherwise, check for direct nostr identifiers const nostrIdentifier = useMemo(() => { + // If we found a replaceable event and fetched it, create naddr from the fetched event + if (fetchedReplaceableEvent) { + try { + const eventDTag = fetchedReplaceableEvent.tags.find(tagNameEquals('d'))?.[1] || '' + const naddr = nip19.naddrEncode({ + kind: fetchedReplaceableEvent.kind, + pubkey: fetchedReplaceableEvent.pubkey, + identifier: eventDTag + }) + return naddr + } catch { + // Failed to encode + } + } + + // Check for direct nostr identifiers in URL + // IMPORTANT: Check for npub in specific paths (like /p/npub1...) to avoid treating as event + const isNpubOnlyPath = /\/p\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) || + /\/profile\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) || + /\/user\/(npub1[a-z0-9]{58})/i.test(cleanedUrl) + const naddrMatch = cleanedUrl.match(/(naddr1[a-z0-9]+)/i) const neventMatch = cleanedUrl.match(/(nevent1[a-z0-9]+)/i) const noteMatch = cleanedUrl.match(/(note1[a-z0-9]{58})/i) - const npubMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) + const npubMatch = isNpubOnlyPath ? null : cleanedUrl.match(/(npub1[a-z0-9]{58})/i) const nprofileMatch = cleanedUrl.match(/(nprofile1[a-z0-9]+)/i) + // If npub-only path, extract npub for profile + if (isNpubOnlyPath) { + const npubPathMatch = cleanedUrl.match(/(npub1[a-z0-9]{58})/i) + return npubPathMatch?.[1] || null + } + return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null - }, [cleanedUrl]) + }, [cleanedUrl, fetchedReplaceableEvent]) // Determine nostr type and extract details const nostrDetails = useMemo(() => { @@ -150,16 +335,16 @@ export default function WebPreview({ url, className }: { url: string; className? const { profile: fetchedProfile, isFetching: isFetchingProfile } = useFetchProfile(profileId) // Fetch event for naddr/nevent/note + // If we already fetched a replaceable event, use that; otherwise fetch by identifier const eventId = (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') ? (nostrIdentifier || undefined) : undefined - const { event: fetchedEvent, isFetching: isFetchingEvent } = useFetchEvent(eventId) + const { event: fetchedEventById, isFetching: isFetchingEvent } = useFetchEvent(eventId) + const fetchedEvent = fetchedReplaceableEvent || fetchedEventById + const isFetchingEventFinal = isFetchingReplaceableEvent || isFetchingEvent + + // Fetch profile for event author (to show avatar in event cards) + const eventAuthorProfileId = fetchedEvent?.pubkey ? nip19.npubEncode(fetchedEvent.pubkey) : undefined + const { profile: eventAuthorProfile } = useFetchProfile(eventAuthorProfileId) - // Extract d-tag from fetched event if available - const eventDTag = useMemo(() => { - if (fetchedEvent) { - return fetchedEvent.tags.find(tagNameEquals('d'))?.[1] - } - return nostrDetails?.dTag - }, [fetchedEvent, nostrDetails]) // Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns const contentPreview = useMemo(() => { @@ -177,9 +362,11 @@ export default function WebPreview({ url, className }: { url: string; className? // Check if we have any opengraph data (title, description, or image) const hasOpengraphData = !isInternalJumbleLink && (title || description || image) - // If no opengraph metadata available, show enhanced fallback link card + // Show enhanced fallback link card if: + // 1. No OG data available, OR + // 2. A nostr identifier was detected (we want to show the detailed nostr card even with OG data) // Note: We always attempt to fetch OG data via useFetchWebMetadata hook above - if (!hasOpengraphData) { + if (!hasOpengraphData || nostrIdentifier) { // Enhanced card for event URLs (always show if nostr identifier detected, even while loading) if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') { const eventMetadata = fetchedEvent ? getLongFormArticleMetadataFromEvent(fetchedEvent) : null @@ -188,6 +375,18 @@ export default function WebPreview({ url, className }: { url: string; className? const eventSummary = eventMetadata?.summary || description const eventImage = eventMetadata?.image + // Extract imeta info to check for thumbnails + const imetaInfos = fetchedEvent ? getImetaInfosFromEvent(fetchedEvent) : [] + // Find thumbnail for the event image if available + let eventImageThumbnail: string | null = null + if (eventImage && fetchedEvent) { + const cleanedEventImage = cleanUrl(eventImage) + // Find imeta info that matches the event image URL + const matchingImeta = imetaInfos.find(info => cleanUrl(info.url) === cleanedEventImage) + // Return thumbnail if available, otherwise return original image + eventImageThumbnail = matchingImeta?.thumb || eventImage + } + // Extract bookstr metadata if applicable const bookMetadata = fetchedEvent ? extractBookMetadata(fetchedEvent) : null const isBookstrEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.PUBLICATION || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata?.book @@ -199,22 +398,8 @@ export default function WebPreview({ url, className }: { url: string; className? .join(' ') } - // Build identifier details - const identifierParts: string[] = [] - if (nostrDetails?.hexId) { - identifierParts.push(`Hex: ${nostrDetails.hexId.substring(0, 16)}...`) - } - if (eventDTag) { - identifierParts.push(`d-tag: ${eventDTag}`) - } else if (nostrDetails?.dTag) { - identifierParts.push(`d-tag: ${nostrDetails.dTag}`) - } - if (nostrDetails?.kind) { - identifierParts.push(`kind: ${nostrDetails.kind}`) - } - if (nostrType) { - identifierParts.push(`Type: ${nostrType}`) - } + // Truncate original URL to 150 characters + const truncatedUrl = url.length > 150 ? url.substring(0, 150) + '...' : url return (
- {eventImage && fetchedEvent && ( + {eventImageThumbnail && fetchedEvent && ( )}
-
+
{fetchedEvent ? ( <> + {eventAuthorProfile?.avatar && ( + { + e.currentTarget.style.display = 'none' + }} + /> + )} {eventTypeName} ) : ( - {isFetchingEvent ? 'Loading event...' : 'Event'} + {isFetchingEventFinal ? 'Loading event...' : 'Event'} )} @@ -270,14 +465,7 @@ export default function WebPreview({ url, className }: { url: string; className? )} )} - {identifierParts.length > 0 && ( -
- {identifierParts.map((part, idx) => ( - {part} - ))} -
- )} -
{hostname}
+
{truncatedUrl}
) @@ -285,17 +473,8 @@ export default function WebPreview({ url, className }: { url: string; className? // Enhanced card for profile URLs (loading state) if (nostrType === 'npub' || nostrType === 'nprofile') { - // Build identifier details for profile - const profileIdentifierParts: string[] = [] - if (nostrDetails?.pubkey) { - profileIdentifierParts.push(`Pubkey: ${nostrDetails.pubkey.substring(0, 16)}...`) - } - if (fetchedProfile?.nip05) { - profileIdentifierParts.push(`NIP-05: ${fetchedProfile.nip05}`) - } - if (nostrType) { - profileIdentifierParts.push(`Type: ${nostrType}`) - } + // Truncate original URL to 150 characters + const truncatedUrl = url.length > 150 ? url.substring(0, 150) + '...' : url return (
{fetchedProfile.about}
)} - {profileIdentifierParts.length > 0 && ( -
- {profileIdentifierParts.map((part, idx) => ( - {part} - ))} -
- )} -
{hostname}
-
{url}
+
{truncatedUrl}
) @@ -377,7 +548,7 @@ export default function WebPreview({ url, className }: { url: string; className? window.open(cleanedUrl, '_blank') }} > - +
{hostname}
@@ -402,7 +573,7 @@ export default function WebPreview({ url, className }: { url: string; className? {image && ( )} diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index 7ebf566..7a6f4f3 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -24,6 +24,7 @@ export interface ParsedNostrContent { wikilink?: string displayText?: string bookstrWikilink?: string + sourceUrl?: string images?: TImetaInfo[] url?: string noteId?: string @@ -52,7 +53,11 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo // 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 + // 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) + 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 const allMatches: Array<{ type: 'nostr' | 'image' | 'video' | 'audio' | 'hashtag' | 'wikilink' | 'bookstr-wikilink' | 'url' | 'jumble-note' match: RegExpExecArray @@ -63,6 +68,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo wikilink?: string displayText?: string bookstrWikilink?: string + sourceUrl?: string noteId?: string }> = [] @@ -79,10 +85,49 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo } } - // Find URL matches and categorize them + // Find bookstr URL matches first (before regular URL matching to avoid conflicts) + // Look for any URL containing book%3A%3A or book:: pattern + let bookstrUrlMatch + while ((bookstrUrlMatch = bookstrUrlRegex.exec(content)) !== null) { + const fullUrl = bookstrUrlMatch[1] + const searchTermEncoded = bookstrUrlMatch[2] + + try { + // Decode the URL-encoded search term + const decodedSearchTerm = decodeURIComponent(searchTermEncoded) + + // Check if it starts with book:: (it should, but handle both cases) + let bookstrWikilink = decodedSearchTerm + if (!bookstrWikilink.startsWith('book::')) { + // If it doesn't start with book::, add it + bookstrWikilink = `book::${bookstrWikilink}` + } + + allMatches.push({ + type: 'bookstr-wikilink', + match: bookstrUrlMatch, + start: bookstrUrlMatch.index, + end: bookstrUrlMatch.index + bookstrUrlMatch[0].length, + bookstrWikilink: bookstrWikilink.trim(), + sourceUrl: fullUrl + }) + } catch (err) { + // If decoding fails, treat as regular URL + logger.warn('Failed to decode bookstr URL', { url: fullUrl, error: err }) + } + } + + // Find URL matches and categorize them (skip if already matched as bookstr URL) let urlMatch while ((urlMatch = urlRegex.exec(content)) !== null) { const url = urlMatch[1] + + // Skip if this URL was already matched as a bookstr URL (check if it contains book%3A%3A or book::) + const isBookstrUrl = /(?:book%3A%3A|book::)/i.test(url) + if (isBookstrUrl) { + continue + } + const cleanedUrl = cleanUrl(url) // Check if it's an image @@ -176,7 +221,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo let lastIndex = 0 - for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, noteId } of allMatches) { + for (const { type, match, start, end, url, hashtag, wikilink, displayText, bookstrWikilink, sourceUrl, noteId } of allMatches) { // Add text before the match if (start > lastIndex) { const textContent = content.slice(lastIndex, start) @@ -235,7 +280,8 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo elements.push({ type: 'bookstr-wikilink', content: match[0], - bookstrWikilink: bookstrWikilink + bookstrWikilink: bookstrWikilink, + sourceUrl: sourceUrl }) } else if (type === 'wikilink' && wikilink) { elements.push({ @@ -500,6 +546,7 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className? )