Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
5293d3993d
  1. 26
      src/components/Image/index.tsx
  2. 155
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 3
      src/components/Note/index.tsx
  4. 1
      src/components/ReplyNote/index.tsx

26
src/components/Image/index.tsx

@ -42,17 +42,18 @@ export default function Image({ @@ -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({ @@ -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
<span className="absolute inset-0 h-full w-full rounded-lg bg-muted" />
) : (
<Skeleton
className={cn(
@ -163,14 +167,6 @@ export default function Image({ @@ -163,14 +167,6 @@ export default function Image({
)}
/>
)}
{/* "Tap to view" overlay when held on blurhash */}
{!revealed && (
<span className="absolute inset-0 flex items-center justify-center z-20">
<span className="rounded-full bg-black/50 px-3 py-1 text-xs text-white/90 select-none pointer-events-none">
{t('Tap to load image')}
</span>
</span>
)}
</span>
)}
{!showErrorState && revealed && (

155
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -601,6 +601,8 @@ export function parseMarkdownContentLegacy( @@ -601,6 +601,8 @@ export function parseMarkdownContentLegacy(
suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet<string>
/** 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<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@ -615,14 +617,26 @@ export function parseMarkdownContentLegacy( @@ -615,14 +617,26 @@ export function parseMarkdownContentLegacy(
emojiInfos = [],
fullCalendarInvite,
suppressStandaloneWebPreviewCleanedUrls,
containingEvent
containingEvent,
lazyMedia = true
} = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
const footnotes = new Map<string, string>()
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<string, TImetaInfo>()
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( @@ -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(
<div key={`img-${patternIdx}`} className={`my-2 block ${hasThumbnail ? 'max-w-[120px]' : 'max-w-[400px]'}`}>
<div key={`img-${patternIdx}`} className="my-2 block max-w-[400px]">
<Image
image={{ url: displayUrl, pubkey: eventPubkey }}
className={`${hasThumbnail ? 'h-auto' : 'w-full'} rounded-lg cursor-zoom-in`}
image={imetaInfoForUrl(cleaned)}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: `rounded-lg block ${hasThumbnail ? '' : 'w-full'}`,
wrapper: 'rounded-lg block w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
holdUntilClick={lazyMedia}
onClick={(e) => {
e.stopPropagation()
if (imageIndex !== undefined) {
@ -1921,7 +1919,7 @@ export function parseMarkdownContentLegacy( @@ -1921,7 +1919,7 @@ export function parseMarkdownContentLegacy(
<MediaPlayer
src={cleaned}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
poster={poster}
/>
</div>
@ -2019,7 +2017,7 @@ export function parseMarkdownContentLegacy( @@ -2019,7 +2017,7 @@ export function parseMarkdownContentLegacy(
<MediaPlayer
src={cleanedStandalone}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
poster={poster}
/>
</div>
@ -2114,7 +2112,7 @@ export function parseMarkdownContentLegacy( @@ -2114,7 +2112,7 @@ export function parseMarkdownContentLegacy(
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
/>
</div>
)
@ -2555,28 +2553,17 @@ 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(
<div key={`img-end-${imgIdx}`} className="my-2 block max-w-[400px] mx-auto">
<Image
image={{ url: displayUrl, pubkey: eventPubkey }}
image={imetaInfoForUrl(cleaned)}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg block w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
holdUntilClick={lazyMedia}
onClick={(e) => {
e.stopPropagation()
if (imageIndex !== undefined) {
@ -2942,6 +2929,8 @@ function parseMarkdownContentMarked( @@ -2942,6 +2929,8 @@ function parseMarkdownContentMarked(
fullCalendarInvite?: { naddr: string; event: Event }
suppressStandaloneWebPreviewCleanedUrls?: ReadonlySet<string>
containingEvent?: Event
/** Hold images as placeholders until clicked (lightbox). False in detail/full views. */
lazyMedia?: boolean
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@ -2951,12 +2940,12 @@ function parseMarkdownContentMarked( @@ -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( @@ -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( @@ -3136,11 +3126,6 @@ function parseMarkdownContentMarked(
}
// `![](url)` has empty alt — a plain <a>{label}</a> 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( @@ -3149,13 +3134,14 @@ function parseMarkdownContentMarked(
out.push(
<Image
key={`${key}-img-inline`}
image={{ ...baseImeta, url: imageUrl }}
image={{ ...baseImeta, url: src }}
alt={label || 'image'}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'not-prose my-2 block max-w-[400px] mx-auto rounded-lg w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
holdUntilClick={lazyMedia}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
if (typeof imageIdx === 'number') openLightbox(imageIdx)
@ -3288,7 +3274,7 @@ function parseMarkdownContentMarked( @@ -3288,7 +3274,7 @@ function parseMarkdownContentMarked(
<YoutubeEmbeddedPlayer
url={cleaned}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
/>
</div>
)
@ -3297,7 +3283,7 @@ function parseMarkdownContentMarked( @@ -3297,7 +3283,7 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-line-media-${lineIdx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
@ -3428,7 +3414,7 @@ function parseMarkdownContentMarked( @@ -3428,7 +3414,7 @@ function parseMarkdownContentMarked(
<YoutubeEmbeddedPlayer
url={cleaned}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
/>
</div>
)
@ -3437,7 +3423,7 @@ function parseMarkdownContentMarked( @@ -3437,7 +3423,7 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-url`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
@ -3515,7 +3501,7 @@ function parseMarkdownContentMarked( @@ -3515,7 +3501,7 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
nodes.push(
<div key={`${key}-inline-media-${idx}`} className="my-2">
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={false} />
<MediaPlayer src={cleaned} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
})
@ -3591,7 +3577,7 @@ function parseMarkdownContentMarked( @@ -3591,7 +3577,7 @@ function parseMarkdownContentMarked(
const poster = videoPosterMap?.get(cleaned)
return (
<div key={`${key}-media-block`} className="my-2">
<MediaPlayer src={src} poster={poster} className="max-w-[400px]" />
<MediaPlayer src={src} poster={poster} className="max-w-[400px]" mustLoad={!lazyMedia} />
</div>
)
}
@ -3602,19 +3588,15 @@ function parseMarkdownContentMarked( @@ -3602,19 +3588,15 @@ function parseMarkdownContentMarked(
</p>
)
}
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 (
<Image
key={`${key}-img-block`}
image={{ url: imageUrl, pubkey: eventPubkey }}
image={imetaInfoForStandaloneImageUrl(cleaned)}
alt={imageToken.text || 'image'}
className="w-full rounded-lg cursor-zoom-in my-0"
classNames={{ wrapper: 'my-2 block max-w-[400px] mx-auto' }}
holdUntilClick={lazyMedia}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
if (typeof imageIdx === 'number') openLightbox(imageIdx)
@ -4404,6 +4386,7 @@ export default function MarkdownArticle({ @@ -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({ @@ -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({ @@ -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<string, string>()
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({ @@ -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({ @@ -4836,6 +4838,7 @@ export default function MarkdownArticle({
getImageIdentifier,
emojiInfos,
fullCalendarInvite,
lazyMedia,
webPreviewSuppressCleanedSet
])
@ -4990,32 +4993,16 @@ export default function MarkdownArticle({ @@ -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 (
<div key={`tag-media-${cleaned}`} className={`my-2 ${hasThumbnail ? 'max-w-[120px]' : 'max-w-[400px]'}`}>
<div key={`tag-media-${cleaned}`} className="my-2 max-w-[400px]">
<Image
image={{ url: displayUrl, pubkey: event.pubkey }}
className={`${hasThumbnail ? 'h-auto' : 'w-full'} rounded-lg cursor-zoom-in`}
image={{ url: media.url, pubkey: event.pubkey, blurHash: imageBlurHashMap.get(cleaned) }}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: `rounded-lg ${hasThumbnail ? '' : 'w-full'}`,
wrapper: 'rounded-lg w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
holdUntilClick={lazyMedia}
onClick={(e) => {
e.stopPropagation()
if (mediaIndex !== undefined) {
@ -5023,7 +5010,7 @@ export default function MarkdownArticle({ @@ -5023,7 +5010,7 @@ export default function MarkdownArticle({
}
}}
/>
</div>
</div>
)
} else if (media.type === 'video' || media.type === 'audio') {
return (
@ -5031,7 +5018,7 @@ export default function MarkdownArticle({ @@ -5031,7 +5018,7 @@ export default function MarkdownArticle({
<MediaPlayer
src={media.url}
className="max-w-full sm:max-w-[400px] w-full"
mustLoad={true}
mustLoad={!lazyMedia}
poster={media.poster}
/>
</div>
@ -5052,7 +5039,7 @@ export default function MarkdownArticle({ @@ -5052,7 +5039,7 @@ export default function MarkdownArticle({
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
mustLoad={!lazyMedia}
/>
</div>
)

3
src/components/Note/index.tsx

@ -213,11 +213,12 @@ export default function Note({ @@ -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

1
src/components/ReplyNote/index.tsx

@ -177,6 +177,7 @@ export default function ReplyNote({ @@ -177,6 +177,7 @@ export default function ReplyNote({
className="mt-2"
event={event}
hideMetadata={true}
lazyMedia={false}
duplicateWebPreviewCleanedUrlHints={duplicateWebPreviewCleanedUrlHints}
/>
)

Loading…
Cancel
Save