Browse Source

handle fallback cards for hyperlinks containing bookstr macro links or combinations of identifiers

imwald
Silberengel 3 months ago
parent
commit
bb2f7def55
  1. 18
      src/components/Bookstr/BookstrContent.tsx
  2. 99
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 297
      src/components/WebPreview/index.tsx
  4. 55
      src/lib/nostr-parser.tsx

18
src/components/Bookstr/BookstrContent.tsx

@ -3,7 +3,7 @@ import { Event } from 'nostr-tools' @@ -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' @@ -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?: @@ -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<BookSection[]>([])
const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching
const [error, setError] = useState<string | null>(null)
@ -942,6 +943,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -942,6 +943,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
}}
/>
</div>
{/* Source URL link button */}
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors shrink-0"
title="View original source"
>
<ExternalLink className="h-3 w-3" />
<span className="hidden sm:inline">Source</span>
</a>
)}
</div>
{/* OG Preview Card for bible/torah/quran external URLs */}

99
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -780,6 +780,13 @@ function parseMarkdownContent( @@ -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( @@ -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( @@ -2023,6 +2117,11 @@ function parseMarkdownContent(
if (shouldAddSpace) {
parts.push(<span key={`hashtag-space-${patternIdx}`} className="whitespace-pre"> </span>)
}
} else if (pattern.type === 'bookstr-url') {
const { wikilink, sourceUrl } = pattern.data
parts.push(
<BookstrContent key={`bookstr-url-${patternIdx}`} wikilink={wikilink} sourceUrl={sourceUrl} />
)
} else if (pattern.type === 'wikilink') {
const linkContent = pattern.data

297
src/components/WebPreview/index.tsx

@ -9,11 +9,15 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -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? @@ -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<Event | null>(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? @@ -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? @@ -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? @@ -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? @@ -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 (
<div
@ -224,24 +409,34 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -224,24 +409,34 @@ export default function WebPreview({ url, className }: { url: string; className?
window.open(cleanedUrl, '_blank')
}}
>
{eventImage && fetchedEvent && (
{eventImageThumbnail && fetchedEvent && (
<Image
image={{ url: eventImage, pubkey: fetchedEvent.pubkey }}
image={{ url: eventImageThumbnail, pubkey: fetchedEvent.pubkey }}
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800"
hideIfError
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-1.5 mb-1">
{fetchedEvent ? (
<>
<Username userId={fetchedEvent.pubkey} className="text-xs" />
{eventAuthorProfile?.avatar && (
<img
src={eventAuthorProfile.avatar}
alt=""
className="w-5 h-5 rounded-full flex-shrink-0 object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{eventTypeName}</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{isFetchingEvent ? 'Loading event...' : 'Event'}
{isFetchingEventFinal ? 'Loading event...' : 'Event'}
</span>
)}
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" />
@ -270,14 +465,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -270,14 +465,7 @@ export default function WebPreview({ url, className }: { url: string; className?
)}
</>
)}
{identifierParts.length > 0 && (
<div className="text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800">
{identifierParts.map((part, idx) => (
<span key={idx} className="font-mono">{part}</span>
))}
</div>
)}
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
<div className="text-xs text-muted-foreground truncate mt-2">{truncatedUrl}</div>
</div>
</div>
)
@ -285,17 +473,8 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -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 (
<div
@ -334,15 +513,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -334,15 +513,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{fetchedProfile?.about && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1 mt-1">{fetchedProfile.about}</div>
)}
{profileIdentifierParts.length > 0 && (
<div className="text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800">
{profileIdentifierParts.map((part, idx) => (
<span key={idx} className="font-mono">{part}</span>
))}
</div>
)}
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
<div className="text-xs text-muted-foreground truncate">{url}</div>
<div className="text-xs text-muted-foreground truncate mt-1">{truncatedUrl}</div>
</div>
</div>
)
@ -377,7 +548,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -377,7 +548,7 @@ export default function WebPreview({ url, className }: { url: string; className?
window.open(cleanedUrl, '_blank')
}}
>
<Image image={{ url: image }} className="w-full max-w-[400px] h-44 rounded-none" hideIfError />
<Image image={{ url: image }} className="w-20 h-20 rounded-lg object-cover" hideIfError />
<div className="bg-muted p-2 w-full">
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground truncate">{hostname}</div>
@ -402,7 +573,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -402,7 +573,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{image && (
<Image
image={{ url: image }}
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 max-w-[400px] rounded-none flex-shrink-0"
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover"
hideIfError
/>
)}

55
src/lib/nostr-parser.tsx

@ -24,6 +24,7 @@ export interface ParsedNostrContent { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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? @@ -500,6 +546,7 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className?
<BookstrContent
key={index}
wikilink={element.bookstrWikilink}
sourceUrl={element.sourceUrl}
className="my-2"
/>
)

Loading…
Cancel
Save