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 && (
{
@@ -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
}