diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 5fd7242e..4fa21bbc 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -5,7 +5,17 @@ import { TImetaInfo } from '@/types' import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash' import { decode } from 'blurhash' import { ImageOff } from 'lucide-react' -import { CSSProperties, HTMLAttributes, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + CSSProperties, + HTMLAttributes, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useTranslation } from 'react-i18next' /** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ @@ -53,25 +63,31 @@ export default function Image({ * When true, the full image is not loaded until the user interacts. * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the * inline `` so after the lightbox closes the real image can show from cache. + * + * Under {@link ContentPolicyProvider}, the user’s media auto-load setting overrides a stale + * `holdUntilClick` from parents (e.g. MarkdownArticle’s default `lazyMedia`). */ holdUntilClick?: boolean }) { const { t } = useTranslation() + const contentPolicy = useContentPolicyOptional() + /** Tap-to-load only if the parent asked and policy allows (or there is no policy — trust the parent). */ + const effectiveHoldUntilClick = + holdUntilClick && (contentPolicy !== undefined ? !contentPolicy.autoLoadMedia : true) + const urlOk = !!url?.trim() - // 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 [revealed, setRevealed] = useState(!effectiveHoldUntilClick) + const [isLoading, setIsLoading] = useState(urlOk && !effectiveHoldUntilClick) const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) const [hasError, setHasError] = useState(!urlOk) const [imageUrl, setImageUrl] = useState(url) const [fallbackIndex, setFallbackIndex] = useState(0) const loadWatchRef = useRef(null) - // Track whether this image started in the held state (required an explicit click to reveal). - // The timeout is only meaningful when the user already triggered a load — for auto-revealed - // images, delays the browser request until the element nears the viewport, - // so a 10 s timeout would fire before off-screen images are even fetched. - const wasInitiallyHeldRef = useRef(holdUntilClick) + // Kept in sync in the reset effect; load-timeout runs only while tap-to-load is actually active. + const wasInitiallyHeldRef = useRef(effectiveHoldUntilClick) + const imgRef = useRef(null) + /** Deduplicate onLoad vs sync cache hit vs decode() — otherwise blurhash can stick when `onLoad` never runs. */ + const loadSettledRef = useRef(false) const finalAlt = imetaAlt || alt const openLinkHref = @@ -98,8 +114,10 @@ export default function Image({ useEffect(() => { setImageUrl(url) + loadSettledRef.current = false + wasInitiallyHeldRef.current = effectiveHoldUntilClick + const shouldHold = effectiveHoldUntilClick setRevealed(!shouldHold) - setIsLoading(!!url?.trim() && !shouldHold) setHasError(false) setDisplaySkeleton(true) setFallbackIndex(0) @@ -108,18 +126,44 @@ export default function Image({ setIsLoading(false) setHasError(true) setDisplaySkeleton(false) + return + } + setIsLoading(!shouldHold) + }, [url, effectiveHoldUntilClick]) + + const notifyLoaded = useCallback(() => { + if (loadSettledRef.current) return + loadSettledRef.current = true + clearLoadWatch() + setIsLoading(false) + setHasError(false) + setTimeout(() => setDisplaySkeleton(false), 600) + }, []) + + // Cached images are often `complete` before `onLoad` is attached (feed mounts many cards at once). + useLayoutEffect(() => { + if (!revealed || badSrc || !imageUrl?.trim() || loadSettledRef.current) return + const el = imgRef.current + if (!el) return + if (el.complete && el.naturalWidth > 0) { + notifyLoaded() + return + } + if (!effectiveHoldUntilClick && typeof el.decode === 'function') { + let cancelled = false + el.decode().then(() => { + if (!cancelled && el.naturalWidth > 0) notifyLoaded() + }).catch(() => {}) + return () => { + cancelled = true + } } - // shouldHold is derived from props — intentionally not in deps to avoid reset loops - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [url]) + }, [revealed, badSrc, imageUrl, effectiveHoldUntilClick, notifyLoaded]) useEffect(() => { clearLoadWatch() if (badSrc || !url?.trim() || !revealed) return - // Skip the timeout for auto-load images (holdUntilClick was false from mount). - // Their request hasn't necessarily started yet when revealed - // becomes true, so the timeout would fire before the browser even fetches the image. - // For those images, onError is sufficient — it fires whenever the browser does try. + // No stall-timeout when not in tap-to-load mode; only that path waits on user-driven reveal. if (!wasInitiallyHeldRef.current) return loadWatchRef.current = window.setTimeout(() => { loadWatchRef.current = null @@ -137,6 +181,7 @@ export default function Image({ if (fallback && fallbackIndex < fallback.length) { const next = fallback[fallbackIndex] setFallbackIndex((prev) => prev + 1) + loadSettledRef.current = false setImageUrl(next) return } @@ -146,10 +191,7 @@ export default function Image({ } const handleLoad = () => { - clearLoadWatch() - setIsLoading(false) - setHasError(false) - setTimeout(() => setDisplaySkeleton(false), 600) + notifyLoaded() } const reserveStyle = wrapperReserveStyle(dim, showErrorState) @@ -165,7 +207,7 @@ export default function Image({ } const handleWrapperClick = (e: React.MouseEvent) => { - if (holdUntilClick && !revealed) handleReveal() + if (effectiveHoldUntilClick && !revealed) handleReveal() onClick?.(e) } @@ -199,7 +241,7 @@ export default function Image({ )} /> )} - {!revealed && holdUntilClick && fileSizeBytes != null && ( + {!revealed && effectiveHoldUntilClick && fileSizeBytes != null && ( {formatFileSize(fileSizeBytes)} @@ -208,12 +250,14 @@ export default function Image({ )} {!showErrorState && revealed && ( {finalAlt} { @@ -1605,7 +1602,7 @@ export default function PostContent({ // Note: URL will be inserted when upload completes in handleMediaUploadSuccess } } - // Root short-note composer: do not switch to a native media kind on upload — user uses "Media kind". + // Root composer: native media kind is set in processMediaUpload after kind detection (ambiguous types use the dialog). } } @@ -1689,7 +1686,11 @@ export default function PostContent({ setMediaNoteKind(resolvedKind) } else { resolvedKind = await getMediaKindFromFile(uploadingFile, false) - // Root composer: keep kind 1 until the user uses "Media kind" (ambiguous webm/mp4 still sets kind via dialog). + const isRootComposer = !parentEvent && !isPublicMessage && !(isDiscussionThread && !parentEvent) + if (isRootComposer) { + setMediaNoteKind(resolvedKind) + setMediaUrl(url) + } } const imetaTag = mediaUpload.getImetaTagByUrl(url) @@ -1839,8 +1840,11 @@ export default function PostContent({ const imetaTag = mediaUpload.getImetaTagByUrl(url) if (imetaTag) { setMediaImetaTags([imetaTag]) + composerImetaTagsRef.current = [imetaTag] } else if (tags && tags.length > 0) { - setMediaImetaTags([nip94PairsToImetaTag(tags)]) + const nipRow = nip94PairsToImetaTag(tags) + setMediaImetaTags([nipRow]) + composerImetaTagsRef.current = [nipRow] } else { const basicImetaTag: string[] = ['imeta', `url ${url}`] // For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/* @@ -1863,6 +1867,7 @@ export default function PostContent({ basicImetaTag.push(`m ${mimeType}`) } setMediaImetaTags([basicImetaTag]) + composerImetaTagsRef.current = [basicImetaTag] } // Insert the URL into the editor content so it shows in the edit pane // Use setTimeout to ensure the state has updated and editor is ready @@ -1917,9 +1922,8 @@ export default function PostContent({ setIsCitationPrompt(false) setIsDiscussionThread(false) - // Clear uploaded file from map and picture accumulation ref + // Clear uploaded file map (upload finished). Keep composerImetaTagsRef in sync with mediaImetaTags — do not wipe here. uploadedMediaFileMap.current.clear() - composerImetaTagsRef.current = [] } const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => { diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index ac46f301..03d234ed 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -1,7 +1,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { toNostrBuildThumbUrl } from '@/lib/nostr-build' -import { isVideo } from '@/lib/url' +import { isImage, isMedia, isVideo } from '@/lib/url' import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' @@ -32,6 +32,11 @@ const AVATAR_HEAD_TIMEOUT_MS = 3000 async function fetchUrlSizeBytes(url: string): Promise { if (urlSizeCache.has(url)) return urlSizeCache.get(url)! + // Cross-origin HEAD to image/media URLs usually has no CORS — Firefox logs errors even when we catch. + if (isImage(url) || isMedia(url)) { + urlSizeCache.set(url, null) + return null + } try { const ctrl = new AbortController() const timer = window.setTimeout(() => ctrl.abort(), AVATAR_HEAD_TIMEOUT_MS) diff --git a/src/lib/url.ts b/src/lib/url.ts index e2589eb1..2278d94e 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -358,6 +358,9 @@ export function isVideo(url: string) { /** * Return true if the URL looks like a fetchable web page (http(s) with a plausible host). * Used to skip OG metadata fetch for invalid or non-http URLs (e.g. "https://1.4ghz/"). + * + * Direct image/video/audio URLs (e.g. nostr.build `…/file.jpg`) are not HTML; OG metadata fetch uses + * `fetch` with `mode: "cors"`, which fails on 301/CDN responses without ACAO and spams the console. */ export function isLikelyWebPageUrl(url: string): boolean { try { @@ -365,8 +368,9 @@ export function isLikelyWebPageUrl(url: string): boolean { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false const host = parsed.hostname || '' if (!host) return false - // Require a dot (e.g. example.com) or localhost so we skip bare hostnames like "1.4ghz" - return host.includes('.') || host === 'localhost' + if (!host.includes('.') && host !== 'localhost') return false + if (isImage(url) || isMedia(url)) return false + return true } catch { return false }