Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
5dc44c443f
  1. 96
      src/components/Image/index.tsx
  2. 22
      src/components/PostEditor/PostContent.tsx
  3. 7
      src/components/UserAvatar/index.tsx
  4. 8
      src/lib/url.ts

96
src/components/Image/index.tsx

@ -5,7 +5,17 @@ import { TImetaInfo } from '@/types' @@ -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({ @@ -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 `<img>` so after the lightbox closes the real image can show from cache.
*
* Under {@link ContentPolicyProvider}, the users media auto-load setting overrides a stale
* `holdUntilClick` from parents (e.g. MarkdownArticles 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<number | null>(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, <img loading="lazy"> 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<HTMLImageElement | null>(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({ @@ -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({ @@ -108,18 +126,44 @@ export default function Image({
setIsLoading(false)
setHasError(true)
setDisplaySkeleton(false)
return
}
// shouldHold is derived from props — intentionally not in deps to avoid reset loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url])
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
}
}
}, [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 <img loading="lazy"> 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({ @@ -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({ @@ -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({ @@ -165,7 +207,7 @@ export default function Image({
}
const handleWrapperClick = (e: React.MouseEvent<HTMLSpanElement>) => {
if (holdUntilClick && !revealed) handleReveal()
if (effectiveHoldUntilClick && !revealed) handleReveal()
onClick?.(e)
}
@ -199,7 +241,7 @@ export default function Image({ @@ -199,7 +241,7 @@ export default function Image({
)}
/>
)}
{!revealed && holdUntilClick && fileSizeBytes != null && (
{!revealed && effectiveHoldUntilClick && fileSizeBytes != null && (
<span className="absolute bottom-2 right-2 z-20 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white/90 backdrop-blur-sm select-none pointer-events-none">
{formatFileSize(fileSizeBytes)}
</span>
@ -208,12 +250,14 @@ export default function Image({ @@ -208,12 +250,14 @@ export default function Image({
)}
{!showErrorState && revealed && (
<img
ref={imgRef}
src={imageUrl}
alt={finalAlt}
title={finalAlt || undefined}
referrerPolicy="no-referrer"
decoding="async"
loading={wasInitiallyHeldRef.current ? 'eager' : 'lazy'}
decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager"
draggable={false}
onLoad={handleLoad}
onError={handleError}

22
src/components/PostEditor/PostContent.tsx

@ -1334,11 +1334,8 @@ export default function PostContent({ @@ -1334,11 +1334,8 @@ export default function PostContent({
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
setIsDiscussionThread(false)
// Short note (kind 1) still supports NIP-94 imeta; only Clear should drop uploads/tags.
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
composerImetaTagsRef.current = []
uploadedMediaFileMap.current.clear()
}
const inferKindFromEditorMediaUrl = (url: string): number | null => {
@ -1605,7 +1602,7 @@ export default function PostContent({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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') => {

7
src/components/UserAvatar/index.tsx

@ -1,7 +1,7 @@ @@ -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 @@ -32,6 +32,11 @@ const AVATAR_HEAD_TIMEOUT_MS = 3000
async function fetchUrlSizeBytes(url: string): Promise<number | null> {
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)

8
src/lib/url.ts

@ -358,6 +358,9 @@ export function isVideo(url: string) { @@ -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 { @@ -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
}

Loading…
Cancel
Save