diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index be71fd94..b0e7536a 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -1,21 +1,35 @@ import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' -import client from '@/services/client.service' +import { isSafeMediaUrl } from '@/lib/url' import { TImetaInfo } from '@/types' -import { preferBlossomPrimalDisplayUrl, primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url' -import { getHashFromURL } from 'blossom-client-sdk' import { decode } from 'blurhash' import { ImageOff } from 'lucide-react' -import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' -import logger from '@/lib/logger' +import { CSSProperties, HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ +const IMAGE_LOAD_TIMEOUT_MS = 10_000 + +/** Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. */ +function wrapperReserveStyle( + dim: { width: number; height: number } | undefined, + showError: boolean +): CSSProperties | undefined { + if (showError) return undefined + if (dim && dim.width > 0 && dim.height > 0) { + return { aspectRatio: `${dim.width} / ${dim.height}` } + } + return { minHeight: 'min(30vh, 280px)' } +} export default function Image({ - image: { url, blurHash, pubkey, dim, alt: imetaAlt, fallback }, + image: { url, blurHash, dim, alt: imetaAlt, fallback }, alt, className = '', classNames = {}, hideIfError = false, errorPlaceholder = , + style: wrapperStyleProp, ...props }: HTMLAttributes & { classNames?: { @@ -27,117 +41,91 @@ export default function Image({ hideIfError?: boolean errorPlaceholder?: React.ReactNode }) { - const [isLoading, setIsLoading] = useState(true) - const [displaySkeleton, setDisplaySkeleton] = useState(true) - const [hasError, setHasError] = useState(false) - const [imageUrl, setImageUrl] = useState(() => preferBlossomPrimalDisplayUrl(url)) - const [tried, setTried] = useState(new Set()) + const { t } = useTranslation() + const urlOk = !!url?.trim() + const [isLoading, setIsLoading] = useState(urlOk) + const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) + const [hasError, setHasError] = useState(!urlOk) + const [imageUrl, setImageUrl] = useState(url) const [fallbackIndex, setFallbackIndex] = useState(0) - - // Use imeta alt text if available, otherwise use the passed alt prop + const loadWatchRef = useRef(null) + const finalAlt = imetaAlt || alt + const openLinkHref = + (isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || '' + + const badSrc = !imageUrl?.trim() || !isSafeMediaUrl(imageUrl.trim()) + const showErrorState = hasError || badSrc + + const clearLoadWatch = () => { + if (loadWatchRef.current != null) { + clearTimeout(loadWatchRef.current) + loadWatchRef.current = null + } + } useEffect(() => { - setImageUrl(preferBlossomPrimalDisplayUrl(url)) + setImageUrl(url) setIsLoading(true) setHasError(false) setDisplaySkeleton(true) - setTried(new Set()) setFallbackIndex(0) - }, [url]) - - if (hideIfError && hasError) return null - - const handleError = async () => { - // First, try fallback URLs from imeta if available - if (fallback && fallbackIndex < fallback.length) { - const nextFallbackUrl = fallback[fallbackIndex] - setFallbackIndex(prev => prev + 1) - setImageUrl(preferBlossomPrimalDisplayUrl(nextFallbackUrl)) - return - } - - // If no more fallbacks, try Blossom servers - let oldImageUrl: URL | undefined - let hash: string | null = null - try { - oldImageUrl = new URL(imageUrl) - hash = getHashFromURL(oldImageUrl) - } catch (error) { - logger.error('Invalid image URL', { error, imageUrl }) - } - if (!hash || !oldImageUrl) { + clearLoadWatch() + if (!url?.trim()) { setIsLoading(false) setHasError(true) - return - } - - // r2a failed: try canonical blossom URL from props (some networks only allow one hop). - if ( - oldImageUrl.hostname === 'r2a.primal.net' && - url && - url !== imageUrl && - url.includes('blossom.primal.net') && - !tried.has('blossom.primal.net-direct') - ) { - setTried((prev) => new Set(prev).add('blossom.primal.net-direct')) - setImageUrl(url) - return - } - - // Primal: only mirror blossom → r2a when we did not already open the note with that CDN URL (avoids r2a↔blossom loops). - if (oldImageUrl.hostname === 'blossom.primal.net') { - const r2a = primalR2aMirrorForBlossomPrimalUrl(oldImageUrl) - const noteAlreadyUsesPrimalCdnFirst = preferBlossomPrimalDisplayUrl(url) !== url - if (r2a && !noteAlreadyUsesPrimalCdnFirst && !tried.has('blossom.primal.net')) { - setTried((prev) => new Set(prev).add('blossom.primal.net')) - setImageUrl(r2a) - return - } + setDisplaySkeleton(false) } + }, [url]) - if (!pubkey) { + useEffect(() => { + clearLoadWatch() + if (badSrc || !url?.trim()) return + loadWatchRef.current = window.setTimeout(() => { + loadWatchRef.current = null setIsLoading(false) + setDisplaySkeleton(false) setHasError(true) - return - } + }, IMAGE_LOAD_TIMEOUT_MS) + return clearLoadWatch + }, [imageUrl, badSrc, url]) - const extMatch = oldImageUrl.pathname.match(/\.\w+$/i) - const extStr = extMatch?.[0] ?? '' - setTried((prev) => new Set(prev).add(oldImageUrl.hostname)) - - const blossomServerList = await client.fetchBlossomServerList(pubkey) - const urls = blossomServerList - .map((server) => { - try { - return new URL(server) - } catch (error) { - logger.error('Invalid Blossom server URL', { server, error }) - return undefined - } - }) - .filter((u) => !!u && !tried.has(u.hostname)) - const nextUrl = urls[0] - if (!nextUrl) { - setIsLoading(false) - setHasError(true) + if (hideIfError && showErrorState) return null + + const handleError = () => { + clearLoadWatch() + if (fallback && fallbackIndex < fallback.length) { + const next = fallback[fallbackIndex] + setFallbackIndex((prev) => prev + 1) + setImageUrl(next) return } - - nextUrl.pathname = '/' + hash + extStr - setImageUrl(preferBlossomPrimalDisplayUrl(nextUrl.toString())) + setIsLoading(false) + setDisplaySkeleton(false) + setHasError(true) } const handleLoad = () => { + clearLoadWatch() setIsLoading(false) setHasError(false) setTimeout(() => setDisplaySkeleton(false), 600) } + const reserveStyle = wrapperReserveStyle(dim, showErrorState) + const mergedWrapperStyle: CSSProperties | undefined = + reserveStyle || wrapperStyleProp + ? { ...reserveStyle, ...wrapperStyleProp } + : undefined + return ( - - {displaySkeleton && ( - + + {displaySkeleton && !showErrorState && ( + {blurHash ? ( )} )} - {!hasError && ( + {!showErrorState && ( {finalAlt} )} - {hasError && ( + {showErrorState && (
- {errorPlaceholder} + {errorPlaceholder} +

{t('This image could not be loaded.')}

+ {badSrc && !hasError ? ( +

{t('Invalid or unsupported image address.')}

+ ) : null} + {openLinkHref ? ( + e.stopPropagation()} + > + {t('Open image link')} + + ) : null}
)}
@@ -201,8 +204,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN if (!blurHash) return null try { return decode(blurHash, blurHashWidth, blurHashHeight) - } catch (error) { - logger.warn('Failed to decode blurhash', error as Error) + } catch { return null } }, [blurHash]) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 3fe549df..c4b06177 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -3111,9 +3111,7 @@ function parseMarkdownContentMarked( const src = String(token.href ?? '') const cleaned = cleanUrl(src) if (!cleaned) break - // Inline context: avoid block image/media mounts inside

/

  • //. - // Standalone image paragraphs are handled separately in renderParagraph(). - const label = String(token.text ?? src) + const label = String(token.text ?? '') if (isVideo(cleaned) || isAudio(cleaned)) { out.push( - {label} + {label || src} ) break } if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) { - out.push({label}) + out.push( + + {label || src} + + ) break } + // `![](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) + if (id) imageIdx = imageIndexMap.get(`__img_id:${id}`) + } out.push( - - {label} - + {label { + e.stopPropagation() + if (typeof imageIdx === 'number') openLightbox(imageIdx) + }} + /> ) break } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8f6c41e1..bcce83a4 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -341,6 +341,9 @@ export default { 'Picture note requires images': 'Bildnotiz erfordert Bilder', Relays: 'Relays', Image: 'Bild', + 'This image could not be loaded.': 'Dieses Bild konnte nicht geladen werden.', + 'Invalid or unsupported image address.': 'Ungültige oder nicht unterstützte Bildadresse.', + 'Open image link': 'Bildlink öffnen', 'Upload Image': 'Bild hochladen', 'Insert emoji': 'Emoji einfügen', 'Insert GIF': 'GIF einfügen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ebffb132..74fb85ec 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -340,6 +340,9 @@ export default { 'Picture note requires images': 'Picture note requires images', Relays: 'Relays', Image: 'Image', + 'This image could not be loaded.': 'This image could not be loaded.', + 'Invalid or unsupported image address.': 'Invalid or unsupported image address.', + 'Open image link': 'Open image link', 'Upload Image': 'Upload Image', 'Insert emoji': 'Insert emoji', 'Insert GIF': 'Insert GIF', diff --git a/src/lib/url.ts b/src/lib/url.ts index a324acb1..f8498c9c 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -367,11 +367,12 @@ export function primalR2aMirrorForBlossomPrimalUrl(url: string | URL): string | } /** - * Prefer Primal’s CDN URL for `img src` when the note points at `blossom.primal.net/…`. - * Same file as the blossom URL; avoids browsers that block or hang on the blossom host (Primal/Wisp-style delivery). + * Display URL for note/imeta image `src`. Keep `https://blossom.primal.net/{sha256}.ext` as-is: it is the + * canonical URL in events and usually loads reliably. Use {@link primalR2aMirrorForBlossomPrimalUrl} only + * as a fallback in {@link Image} `onError` when the blossom host fails. */ export function preferBlossomPrimalDisplayUrl(url: string): string { - return primalR2aMirrorForBlossomPrimalUrl(url) ?? url + return url } /** diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts index 286a716b..38f9ee76 100644 --- a/src/services/media-extraction.service.ts +++ b/src/services/media-extraction.service.ts @@ -1,6 +1,5 @@ import { Event } from 'nostr-tools' import { getImetaInfosFromEvent } from '@/lib/event' -import { tagNameEquals } from '@/lib/tag' import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url' import { TImetaInfo } from '@/types' import mediaUpload from './media-upload.service' @@ -15,7 +14,7 @@ export interface ExtractedMedia { /** * Unified service for extracting all media (images, videos, audio) from an event - * Sources: imeta tags, r tags, image tags, and content field + * Sources: imeta tags, image tags, and content field (not `r` tags — those are references, not media embeds) */ export function extractAllMediaFromEvent( event: Event, @@ -73,20 +72,13 @@ export function extractAllMediaFromEvent( } }) - // 2. Extract from r tags (reference/URL tags) - event.tags.filter(tagNameEquals('r')).forEach(([, url]) => { - if (url && (isImage(url) || isMedia(url))) { - addMedia(url) - } - }) - - // 3. Extract from image tag + // 2. Extract from image tag const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1]) if (imageTag?.[1]) { addMedia(imageTag[1]) } - // 4. Extract from content (if provided) + // 3. Extract from content (if provided) if (content) { // First, extract from markdown image syntax: ![alt](url) or [![](url)](link) // This handles images inside links