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(
}
// `` 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(
{
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 (
{
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}
/>
)