diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 1dc3f88d..5fd7242e 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -5,7 +5,7 @@ import { TImetaInfo } from '@/types' import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash' import { decode } from 'blurhash' import { ImageOff } from 'lucide-react' -import { CSSProperties, HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' +import { CSSProperties, HTMLAttributes, useEffect, useLayoutEffect, 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. */ @@ -38,6 +38,7 @@ export default function Image({ errorPlaceholder = , style: wrapperStyleProp, holdUntilClick = false, + onClick, ...props }: HTMLAttributes & { classNames?: { @@ -49,11 +50,9 @@ export default function Image({ hideIfError?: boolean errorPlaceholder?: React.ReactNode /** - * 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. + * 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. */ holdUntilClick?: boolean }) { @@ -165,11 +164,16 @@ export default function Image({ setIsLoading(true) } + const handleWrapperClick = (e: React.MouseEvent) => { + if (holdUntilClick && !revealed) handleReveal() + onClick?.(e) + } + return ( {displaySkeleton && !showErrorState && ( @@ -209,7 +213,7 @@ export default function Image({ title={finalAlt || undefined} referrerPolicy="no-referrer" decoding="async" - loading="lazy" + loading={wasInitiallyHeldRef.current ? 'eager' : 'lazy'} draggable={false} onLoad={handleLoad} onError={handleError} @@ -269,7 +273,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN } }, [blurHash]) - useEffect(() => { + useLayoutEffect(() => { if (!pixels || !canvasRef.current) return const canvas = canvasRef.current diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index 4524fb93..43635a58 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -4,7 +4,7 @@ import logger from '@/lib/logger' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import modalManager from '@/services/modal-manager.service' import { TImetaInfo } from '@/types' -import { ReactNode, useEffect, useMemo, useState } from 'react' +import { ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' import Lightbox from 'yet-another-react-lightbox' @@ -63,6 +63,15 @@ export default function ImageGallery({ } const displayImages = images.slice(start, end) + /** Tap-to-load: no shared grid lightbox — each image uses {@link ImageWithLightbox}. */ + const tapToLoadGallery = !mustLoad && !autoLoadMedia + + useLayoutEffect(() => { + if (tapToLoadGallery) { + setIndex(-1) + setLightboxPortalActive(false) + } + }, [tapToLoadGallery]) if (displayImages.length === 1) { return ( @@ -77,21 +86,23 @@ export default function ImageGallery({ ) } - if (!mustLoad && !autoLoadMedia) { - return displayImages.map((image, i) => ( - - )) - } - let imageContent: ReactNode | null = null - if (displayImages.length === 2 || displayImages.length === 4) { + if (tapToLoadGallery) { + imageContent = ( + <> + {displayImages.map((image, i) => ( + + ))} + + ) + } else if (displayImages.length === 2 || displayImages.length === 4) { imageContent = (
{displayImages.map((image, i) => ( @@ -120,7 +131,7 @@ export default function ImageGallery({ } const portal = - lightboxPortalActive && typeof document !== 'undefined' + !tapToLoadGallery && lightboxPortalActive && typeof document !== 'undefined' ? createPortal(
(null) const [probeFailed, setProbeFailed] = useState(false) const [embedPainted, setEmbedPainted] = useState(false) @@ -66,14 +67,10 @@ export default function MediaPlayer({ /** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */ const effectiveMediaType = mediaType ?? urlEmbedTypeHint - const showEmbed = mustLoad || display + const showEmbed = mustLoad || autoLoadMedia || userClickedLoad - useEffect(() => { - if (autoLoadMedia) { - setDisplay(true) - } else { - setDisplay(false) - } + useLayoutEffect(() => { + if (!autoLoadMedia) setUserClickedLoad(false) }, [autoLoadMedia]) useEffect(() => { @@ -148,13 +145,13 @@ export default function MediaPlayer({ setEmbedPainted(true) }, []) - if (!mustLoad && !display) { + if (!mustLoad && !showEmbed) { return ( setDisplay(true)} + onActivate={() => setUserClickedLoad(true)} className={className} /> ) diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 9664a15a..8ef3a460 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -528,6 +528,11 @@ export default function AsciidocArticle({ return images }, [extractedMedia.images, metadata.image]) + + const lightboxSlides = useMemo( + () => allImages.map((img) => lightboxSlideFromImeta(img)), + [allImages] + ) // Create image index map for lightbox const imageIndexMap = useMemo(() => { @@ -571,11 +576,13 @@ export default function AsciidocArticle({ // Note: contentLinks removed - WebPreview is disabled for AsciiDoc articles - // Image gallery state + // Image gallery state — portal only while open (see MarkdownArticle lightbox comment). const [lightboxIndex, setLightboxIndex] = useState(-1) - + const [lightboxPortalActive, setLightboxPortalActive] = useState(false) + const openLightbox = useCallback((index: number) => { setLightboxIndex(index) + setLightboxPortalActive(true) }, []) // Filter tag media to only show what's not in content @@ -2122,39 +2129,45 @@ export default function AsciidocArticle({
{/* Image gallery lightbox */} - {allImages.length > 0 && createPortal( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - > - lightboxSlideFromImeta(img))} - plugins={[Video, Zoom]} - open={lightboxIndex >= 0} - close={() => setLightboxIndex(-1)} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: allImages.length <= 1 ? () => null : undefined, - buttonNext: allImages.length <= 1 ? () => null : undefined - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - carousel={{ - finite: false - }} - /> -
, - document.body - )} + {allImages.length > 0 && + lightboxPortalActive && + typeof document !== 'undefined' && + createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + = 0} + close={() => setLightboxIndex(-1)} + on={{ + exited: () => setLightboxPortalActive(false) + }} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: allImages.length <= 1 ? () => null : undefined, + buttonNext: allImages.length <= 1 ? () => null : undefined + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + carousel={{ + finite: false + }} + /> +
, + document.body + )} ) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 1f3e8ddc..3eceaab9 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -28,7 +28,7 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { TEmoji, TImetaInfo } from '@/types' import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' -import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react' +import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' import Lightbox from 'yet-another-react-lightbox' @@ -4646,6 +4646,11 @@ export default function MarkdownArticle({ return images }, [extractedMedia.images, metadata.image]) + + const lightboxSlides = useMemo( + () => allImages.map((img) => lightboxSlideFromImeta(img)), + [allImages] + ) // Helper function to extract image filename/hash from URL for comparison // This helps identify the same image hosted on different domains @@ -4751,14 +4756,21 @@ export default function MarkdownArticle({ return links }, [event.content]) - // Image gallery state + // Image gallery state — portal mounts only while active so feed re-renders don't run N closed Lightboxes on body. const [lightboxOpen, setLightboxOpen] = useState(false) const [lightboxIndex, setLightboxIndex] = useState(0) - + const [lightboxPortalActive, setLightboxPortalActive] = useState(false) + const openLightbox = useCallback((index: number) => { setLightboxIndex(index) setLightboxOpen(true) + setLightboxPortalActive(true) }, []) + + useLayoutEffect(() => { + setLightboxOpen(false) + setLightboxPortalActive(false) + }, [lazyMedia]) // Filter tag media to only show what's not in content const leftoverTagMedia = useMemo(() => { @@ -5181,43 +5193,47 @@ export default function MarkdownArticle({ )}
- {/* Image gallery lightbox */} - {allImages.length > 0 && createPortal( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - > - lightboxSlideFromImeta(img))} - plugins={[Video, Zoom]} - open={lightboxOpen} - close={() => setLightboxOpen(false)} - on={{ - view: ({ index }) => setLightboxIndex(index) - }} - controller={{ - closeOnBackdropClick: false, - closeOnPullUp: true, - closeOnPullDown: true - }} - render={{ - buttonPrev: allImages.length <= 1 ? () => null : undefined, - buttonNext: allImages.length <= 1 ? () => null : undefined - }} - styles={{ - toolbar: { paddingTop: '2.25rem' } - }} - carousel={{ - finite: false - }} - /> -
, - document.body - )} + {/* Image gallery lightbox — mount portal only when open; avoids N× Lightbox reconciling on body when policy/feed re-renders */} + {allImages.length > 0 && + lightboxPortalActive && + typeof document !== 'undefined' && + createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > + setLightboxOpen(false)} + on={{ + view: ({ index }) => setLightboxIndex(index), + exited: () => setLightboxPortalActive(false) + }} + controller={{ + closeOnBackdropClick: false, + closeOnPullUp: true, + closeOnPullDown: true + }} + render={{ + buttonPrev: allImages.length <= 1 ? () => null : undefined, + buttonNext: allImages.length <= 1 ? () => null : undefined + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + carousel={{ + finite: false + }} + /> +
, + document.body + )} ) } diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx index 471e26ff..da896c37 100644 --- a/src/components/YoutubeEmbeddedPlayer/index.tsx +++ b/src/components/YoutubeEmbeddedPlayer/index.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import mediaManager from '@/services/media-manager.service' import { YouTubePlayer } from '@/types/youtube' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ExternalLink from '../ExternalLink' import logger from '@/lib/logger' @@ -20,23 +20,21 @@ export default function YoutubeEmbeddedPlayer({ const { t } = useTranslation() const contentPolicy = useContentPolicyOptional() const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true - const [display, setDisplay] = useState(autoLoadMedia) + const [userClickedLoad, setUserClickedLoad] = useState(false) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) const [initSuccess, setInitSuccess] = useState(false) const [error, setError] = useState(false) const playerRef = useRef(null) const containerRef = useRef(null) - useEffect(() => { - if (autoLoadMedia) { - setDisplay(true) - } else { - setDisplay(false) - } + useLayoutEffect(() => { + if (!autoLoadMedia) setUserClickedLoad(false) }, [autoLoadMedia]) + const showEmbed = mustLoad || autoLoadMedia || userClickedLoad + useEffect(() => { - if (!videoId || !containerRef.current || (!mustLoad && !display)) return + if (!videoId || !containerRef.current || !showEmbed) return let cancelled = false @@ -71,24 +69,28 @@ export default function YoutubeEmbeddedPlayer({ return () => { cancelled = true - if (playerRef.current) { - playerRef.current.destroy() - playerRef.current = null + const player = playerRef.current + playerRef.current = null + if (!player) return + try { + player.destroy() + } catch { + // React often removes the host node first when auto-load media is turned off; YT then hits removeChild errors. } } - }, [videoId, display, mustLoad]) + }, [videoId, showEmbed]) if (error) { return } - if (!mustLoad && !display) { + if (!mustLoad && !showEmbed) { return (
{ e.stopPropagation() - setDisplay(true) + setUserClickedLoad(true) }} > [{t('Click to load YouTube video')}] diff --git a/src/lib/media-placeholder-blurhash.ts b/src/lib/media-placeholder-blurhash.ts index 18d44e00..2ce44992 100644 --- a/src/lib/media-placeholder-blurhash.ts +++ b/src/lib/media-placeholder-blurhash.ts @@ -1,18 +1,28 @@ import { isBlurhashValid } from 'blurhash' /** - * Stable, varied BlurHash strings for lazy media (no imeta). Picked from the reference - * encoder corpus; each validates with {@link isBlurhashValid}. + * Stable, varied BlurHash strings for lazy media (no NIP-94 blurHash). + * + * Earlier we used a small set from the BlurHash reference corpus; several of those decode + * to very high average luminance (~0.7–0.85 on a 0–1 scale), so at 32×32 scaled up they + * read as “empty white” boxes — especially next to real colorful hashes from imeta. + * + * This list mixes medium-luma reference hashes with encodings of saturated solids and + * gradients (all validated). URL hashing still picks deterministically among them. */ const PLACEHOLDER_BLURHASHES = [ 'LEHV6nWB2yk8pyo0adR*.7kCMdnj', 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.', - 'LjIY%^?bH?xu_4t8V_NHxZxbx]ae', - 'L6PZfSjE.Adjc0j]WCWVH?j?bHwc', - 'LKO2?U%2Tw[w]~RBVZRi};RPxuwH', 'LdHxL5Rk^6#M@-5c,1J5@[or[Q6.', 'LGF?UQ%2Tw[w]~RBVZRi};RPxuwH', - 'L6PZ0Si_.AyE_3t7t7R**0o#D%IU' + 'U18:W20c[[Os-ZNrjta}fQfQfQfQ-ZNrjta}', + 'U1Ed6O05}-I[}rEhoKazfQfQfQfQ}rEhoKaz', + 'U32?$,uWklo{kWk9fjfjfQfQfQfQkWk9fjfj', + 'U08DbR00omx9?IRhfSjsfQfQfQfQ?IRhfSjs', + 'U56aYxGKfmkEogbIfRfRfQfQfQfQogbIfRfR', + 'L19GOz-afQ-a-aj]fQj]fQfQfQfQ', + 'L03eAJuifQuiuikCfQkCfQfQfQfQ', + 'L1D*FJ}rfQ}r}roLfQoLfQfQfQfQ' ].filter((h) => isBlurhashValid(h).result) function fallbackHashString(s: string): number { diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index d24975be..218c9c27 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -15,6 +15,7 @@ import { Event } from 'nostr-tools' import { NOSTR_PARSER_REGEX } from '@/lib/content-patterns' import logger from '@/lib/logger' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' +import { useState } from 'react' export interface ParsedNostrContent { elements: Array<{ @@ -528,6 +529,50 @@ function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr return null } +function NostrInlineVideo({ mediaUrl, fallbackText }: { mediaUrl: string; fallbackText: string }) { + const [failed, setFailed] = useState(false) + if (failed) { + return ( + + {fallbackText} + + ) + } + return ( + + ) +} + +function NostrInlineAudio({ mediaUrl, fallbackText }: { mediaUrl: string; fallbackText: string }) { + const [failed, setFailed] = useState(false) + if (failed) { + return ( + + {fallbackText} + + ) + } + return ( + + ) +} + /** * Render parsed nostr content as React elements */ @@ -560,47 +605,21 @@ export function renderNostrContent( if (element.type === 'video' && element.mediaUrl) { return ( - + mediaUrl={element.mediaUrl} + fallbackText={element.content} + /> ) } - + if (element.type === 'audio' && element.mediaUrl) { return ( - + mediaUrl={element.mediaUrl} + fallbackText={element.content} + /> ) } diff --git a/src/providers/ContentPolicyProvider.tsx b/src/providers/ContentPolicyProvider.tsx index f24a7180..b59ff696 100644 --- a/src/providers/ContentPolicyProvider.tsx +++ b/src/providers/ContentPolicyProvider.tsx @@ -98,7 +98,14 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => { storage.setMediaAutoLoadPolicy(policy) - setMediaAutoLoadPolicy(policy) + // Defer React state: Radix Select fires onValueChange while its portal is still unmounting. + // An immediate full-tree re-render (feed + body portals) races removeChild and throws. + const run = () => setMediaAutoLoadPolicy(policy) + if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { + window.setTimeout(run, 0) + } else { + run() + } } return (