From 5293d3993de1fc6a10d4fedefe38cfaec3275269 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 6 Apr 2026 18:50:15 +0200 Subject: [PATCH] bug-fixes --- src/components/Image/index.tsx | 26 ++- .../Note/MarkdownArticle/MarkdownArticle.tsx | 155 ++++++++---------- src/components/Note/index.tsx | 3 +- src/components/ReplyNote/index.tsx | 1 + 4 files changed, 85 insertions(+), 100 deletions(-) diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 538e90bc..4fec46e9 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -42,17 +42,18 @@ export default function Image({ hideIfError?: boolean errorPlaceholder?: React.ReactNode /** - * When true AND a blurHash is available, the full image is NOT loaded until - * the user clicks. The blurhash canvas is shown as a bandwidth-saving - * placeholder. Clicking triggers loading (and will open the image link if - * the normal click handler does so). + * When true, the full image is NOT loaded until the user interacts. + * Shows a blurhash canvas if available, otherwise a skeleton placeholder. + * Intended for inline note images: clicking opens the lightbox (via the + * onClick handler passed from MarkdownArticle) without ever loading the + * full image inline. */ holdUntilClick?: boolean }) { const { t } = useTranslation() const urlOk = !!url?.trim() - // When holdUntilClick is active we start in the "held" state. - const shouldHold = holdUntilClick && !!blurHash + // When holdUntilClick is active we start in the "held" state (regardless of blurHash). + const shouldHold = holdUntilClick const [revealed, setRevealed] = useState(!shouldHold) const [isLoading, setIsLoading] = useState(urlOk && revealed) const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) @@ -152,9 +153,12 @@ export default function Image({ blurHash={blurHash} className={cn( 'absolute inset-0 transition-opacity duration-500 rounded-lg', - isLoading || !revealed ? 'opacity-100' : 'opacity-0' + !revealed ? 'opacity-100' : 'opacity-0' )} /> + ) : !revealed && !isLoading ? ( + // Static bg when held — no shimmer animation flashing indefinitely + ) : ( )} - {/* "Tap to view" overlay when held on blurhash */} - {!revealed && ( - - - {t('Tap to load image')} - - - )} )} {!showErrorState && revealed && ( diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index c4b06177..730afca7 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -601,6 +601,8 @@ export function parseMarkdownContentLegacy( suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet /** Event whose body is being rendered (embedded notes / HTTP nostr links). */ containingEvent?: Event + /** Hold images as placeholders until clicked (lightbox). False in detail/full views. */ + lazyMedia?: boolean } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { @@ -615,14 +617,26 @@ export function parseMarkdownContentLegacy( emojiInfos = [], fullCalendarInvite, suppressStandaloneWebPreviewCleanedUrls, - containingEvent + containingEvent, + lazyMedia = true } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() const footnotes = new Map() const citations: Array<{ id: string; type: string; citationId: string }> = [] let lastIndex = 0 - + + // Build imeta lookup map once for blurHash and other NIP-94 metadata + const imetaByCleanedUrl = new Map() + if (containingEvent) { + getImetaInfosFromEvent(containingEvent).forEach((info) => { + const cleaned = cleanUrl(info.url) + if (cleaned) imetaByCleanedUrl.set(cleaned, info) + }) + } + const imetaInfoForUrl = (cleaned: string): TImetaInfo => + imetaByCleanedUrl.get(cleaned) ?? { url: cleaned, pubkey: eventPubkey } + // Helper function to check if an index range falls within any block-level pattern const isWithinBlockPattern = (start: number, end: number, blockPatterns: Array<{ index: number; end: number }>): boolean => { return blockPatterns.some(blockPattern => @@ -1879,32 +1893,16 @@ export function parseMarkdownContentLegacy( } if (isImage(cleaned)) { - // Check if there's a thumbnail available for this image - // Use thumbnail for display, but original URL for lightbox - let thumbnailUrl: string | undefined - if (imageThumbnailMap) { - thumbnailUrl = imageThumbnailMap.get(cleaned) - // Also check by identifier for cross-domain matching - if (!thumbnailUrl && getImageIdentifier) { - const identifier = getImageIdentifier(cleaned) - if (identifier) { - thumbnailUrl = imageThumbnailMap.get(`__img_id:${identifier}`) - } - } - } - // Don't use thumbnails in notes - use original URL - const displayUrl = url - const hasThumbnail = false - parts.push( -
+
{ e.stopPropagation() if (imageIndex !== undefined) { @@ -1921,7 +1919,7 @@ export function parseMarkdownContentLegacy(
@@ -2019,7 +2017,7 @@ export function parseMarkdownContentLegacy(
@@ -2114,7 +2112,7 @@ export function parseMarkdownContentLegacy( ) @@ -2555,28 +2553,17 @@ export function parseMarkdownContentLegacy( imageIndex = imageIndexMap.get(`__img_id:${identifier}`) } } - - let thumbnailUrl: string | undefined - if (imageThumbnailMap) { - thumbnailUrl = imageThumbnailMap.get(cleaned) - if (!thumbnailUrl && getImageIdentifier) { - const identifier = getImageIdentifier(cleaned) - if (identifier) { - thumbnailUrl = imageThumbnailMap.get(`__img_id:${identifier}`) - } - } - } - const displayUrl = thumbnailUrl || imgUrl - + parts.push(
{ e.stopPropagation() if (imageIndex !== undefined) { @@ -2942,6 +2929,8 @@ function parseMarkdownContentMarked( fullCalendarInvite?: { naddr: string; event: Event } suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet containingEvent?: Event + /** Hold images as placeholders until clicked (lightbox). False in detail/full views. */ + lazyMedia?: boolean } ): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { @@ -2951,12 +2940,12 @@ function parseMarkdownContentMarked( navigateToHashtag, navigateToRelay, videoPosterMap, - imageThumbnailMap, getImageIdentifier, emojiInfos = [], fullCalendarInvite, suppressStandaloneWebPreviewCleanedUrls, - containingEvent + containingEvent, + lazyMedia = true } = options /** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */ @@ -2986,6 +2975,7 @@ function parseMarkdownContentMarked( wrapper: 'rounded-lg block w-full', errorPlaceholder: 'aspect-square h-[30vh]' }} + holdUntilClick={lazyMedia} onClick={(e) => { e.stopPropagation() if (imageIndex !== undefined) { @@ -3136,11 +3126,6 @@ function parseMarkdownContentMarked( } // `![](url)` has empty alt — a plain {label} was invisible. Use Image like block paragraphs. const baseImeta = imetaInfoForStandaloneImageUrl(cleaned) - const identifier = getImageIdentifier?.(cleaned) - const thumbnail = - imageThumbnailMap?.get(cleaned) ?? - (identifier ? imageThumbnailMap?.get(`__img_id:${identifier}`) : undefined) - const imageUrl = thumbnail || src let imageIdx = imageIndexMap.get(cleaned) if (imageIdx === undefined && getImageIdentifier) { const id = getImageIdentifier(cleaned) @@ -3149,13 +3134,14 @@ function parseMarkdownContentMarked( out.push( {label { e.stopPropagation() if (typeof imageIdx === 'number') openLightbox(imageIdx) @@ -3288,7 +3274,7 @@ function parseMarkdownContentMarked(
) @@ -3297,7 +3283,7 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -3428,7 +3414,7 @@ function parseMarkdownContentMarked( ) @@ -3437,7 +3423,7 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -3515,7 +3501,7 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) nodes.push(
- +
) }) @@ -3591,7 +3577,7 @@ function parseMarkdownContentMarked( const poster = videoPosterMap?.get(cleaned) return (
- +
) } @@ -3602,19 +3588,15 @@ function parseMarkdownContentMarked(

) } - const identifier = getImageIdentifier?.(cleaned) - const thumbnail = - imageThumbnailMap?.get(cleaned) ?? - (identifier ? imageThumbnailMap?.get(`__img_id:${identifier}`) : undefined) - const imageUrl = thumbnail || src const imageIdx = imageIndexMap.get(cleaned) return ( {imageToken.text { e.stopPropagation() if (typeof imageIdx === 'number') openLightbox(imageIdx) @@ -4404,6 +4386,7 @@ export default function MarkdownArticle({ event, className, hideMetadata = false, + lazyMedia = true, parentImageUrl, fullCalendarInvite, duplicateWebPreviewCleanedUrlHints @@ -4411,6 +4394,12 @@ export default function MarkdownArticle({ event: Event className?: string hideMetadata?: boolean + /** + * When true (default), images in the note are held as blur/skeleton placeholders + * until the user opens them in the lightbox. Set to false in full/detail views + * so images load immediately. + */ + lazyMedia?: boolean parentImageUrl?: string /** When viewing a kind-24 invite, render full calendar card with RSVP in place of the naddr embed */ fullCalendarInvite?: { naddr: string; event: Event } @@ -4795,6 +4784,18 @@ export default function MarkdownArticle({ return map }, [event.id, JSON.stringify(event.tags), getImageIdentifier]) + // Maps cleaned image URL → blurhash string (for inline placeholder rendering) + const imageBlurHashMap = useMemo(() => { + const map = new Map() + getImetaInfosFromEvent(event).forEach((info) => { + if (info.blurHash) { + const cleaned = cleanUrl(info.url) + if (cleaned) map.set(cleaned, info.blurHash) + } + }) + return map + }, [event.id, JSON.stringify(event.tags)]) + const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event.tags]) // Parse markdown content with post-processing for nostr: links and hashtags @@ -4811,6 +4812,7 @@ export default function MarkdownArticle({ emojiInfos, fullCalendarInvite, containingEvent: event, + lazyMedia, suppressStandaloneWebPreviewCleanedUrls: webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined } @@ -4836,6 +4838,7 @@ export default function MarkdownArticle({ getImageIdentifier, emojiInfos, fullCalendarInvite, + lazyMedia, webPreviewSuppressCleanedSet ]) @@ -4990,32 +4993,16 @@ export default function MarkdownArticle({ const mediaIndex = imageIndexMap.get(cleaned) if (media.type === 'image') { - // Check if there's a thumbnail available for this image - let thumbnailUrl: string | undefined - if (imageThumbnailMap) { - thumbnailUrl = imageThumbnailMap.get(cleaned) - // Also check by identifier for cross-domain matching - if (!thumbnailUrl) { - const identifier = getImageIdentifier(cleaned) - if (identifier) { - thumbnailUrl = imageThumbnailMap.get(`__img_id:${identifier}`) - } - } - } - // Don't use thumbnails in notes - they're too small - // Keep thumbnailUrl for fallback/OpenGraph data, but use original URL for display - const displayUrl = media.url - const hasThumbnail = false - return ( -
+
{ e.stopPropagation() if (mediaIndex !== undefined) { @@ -5023,7 +5010,7 @@ export default function MarkdownArticle({ } }} /> -
+
) } else if (media.type === 'video' || media.type === 'audio') { return ( @@ -5031,7 +5018,7 @@ export default function MarkdownArticle({ @@ -5052,7 +5039,7 @@ export default function MarkdownArticle({ ) diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 5817d9db..df4eee2f 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -213,11 +213,12 @@ export default function Note({ className={className} event={event} hideMetadata={hideMetadata} + lazyMedia={!showFull} fullCalendarInvite={fullCalendarInvite} /> ) }, - [event, fullCalendarInvite] + [event, fullCalendarInvite, showFull] ) let content: React.ReactNode diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 1ed140c1..55ffe378 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -177,6 +177,7 @@ export default function ReplyNote({ className="mt-2" event={event} hideMetadata={true} + lazyMedia={false} duplicateWebPreviewCleanedUrlHints={duplicateWebPreviewCleanedUrlHints} /> )