Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
bbe5ff8cfe
  1. 22
      src/components/Image/index.tsx
  2. 43
      src/components/ImageGallery/index.tsx
  3. 19
      src/components/MediaPlayer/index.tsx
  4. 81
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  5. 94
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 32
      src/components/YoutubeEmbeddedPlayer/index.tsx
  7. 22
      src/lib/media-placeholder-blurhash.ts
  8. 87
      src/lib/nostr-parser.tsx
  9. 9
      src/providers/ContentPolicyProvider.tsx

22
src/components/Image/index.tsx

@ -5,7 +5,7 @@ import { TImetaInfo } from '@/types' @@ -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({ @@ -38,6 +38,7 @@ export default function Image({
errorPlaceholder = <ImageOff />,
style: wrapperStyleProp,
holdUntilClick = false,
onClick,
...props
}: HTMLAttributes<HTMLSpanElement> & {
classNames?: {
@ -49,11 +50,9 @@ export default function Image({ @@ -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 `<img>` so after the lightbox closes the real image can show from cache.
*/
holdUntilClick?: boolean
}) {
@ -165,11 +164,16 @@ export default function Image({ @@ -165,11 +164,16 @@ export default function Image({
setIsLoading(true)
}
const handleWrapperClick = (e: React.MouseEvent<HTMLSpanElement>) => {
if (holdUntilClick && !revealed) handleReveal()
onClick?.(e)
}
return (
<span
className={cn('relative overflow-hidden block w-full', classNames.wrapper)}
style={mergedWrapperStyle}
onClick={!revealed ? handleReveal : undefined}
onClick={handleWrapperClick}
{...props}
>
{displaySkeleton && !showErrorState && (
@ -209,7 +213,7 @@ export default function Image({ @@ -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 @@ -269,7 +273,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
}
}, [blurHash])
useEffect(() => {
useLayoutEffect(() => {
if (!pixels || !canvasRef.current) return
const canvas = canvasRef.current

43
src/components/ImageGallery/index.tsx

@ -4,7 +4,7 @@ import logger from '@/lib/logger' @@ -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({ @@ -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({ @@ -77,21 +86,23 @@ export default function ImageGallery({
)
}
if (!mustLoad && !autoLoadMedia) {
return displayImages.map((image, i) => (
<ImageWithLightbox
key={i}
image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: galleryImageWrapper(className)
}}
/>
))
}
let imageContent: ReactNode | null = null
if (displayImages.length === 2 || displayImages.length === 4) {
if (tapToLoadGallery) {
imageContent = (
<>
{displayImages.map((image, i) => (
<ImageWithLightbox
key={i}
image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: galleryImageWrapper(className)
}}
/>
))}
</>
)
} else if (displayImages.length === 2 || displayImages.length === 4) {
imageContent = (
<div className="grid grid-cols-2 gap-2 w-full max-w-[400px]">
{displayImages.map((image, i) => (
@ -120,7 +131,7 @@ export default function ImageGallery({ @@ -120,7 +131,7 @@ export default function ImageGallery({
}
const portal =
lightboxPortalActive && typeof document !== 'undefined'
!tapToLoadGallery && lightboxPortalActive && typeof document !== 'undefined'
? createPortal(
<div
data-lightbox-overlay

19
src/components/MediaPlayer/index.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { isImage } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink'
@ -48,7 +48,8 @@ export default function MediaPlayer({ @@ -48,7 +48,8 @@ export default function MediaPlayer({
blurHash?: string
}) {
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
/** Tap-to-load when {@link autoLoadMedia} is off; cleared when policy switches back to never. */
const [userClickedLoad, setUserClickedLoad] = useState(false)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = useState(false)
@ -66,14 +67,10 @@ export default function MediaPlayer({ @@ -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({ @@ -148,13 +145,13 @@ export default function MediaPlayer({
setEmbedPainted(true)
}, [])
if (!mustLoad && !display) {
if (!mustLoad && !showEmbed) {
return (
<LazyMediaTapPlaceholder
src={src}
posterUrl={imagePoster}
blurHash={blurHash}
onActivate={() => setDisplay(true)}
onActivate={() => setUserClickedLoad(true)}
className={className}
/>
)

81
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -529,6 +529,11 @@ export default function AsciidocArticle({ @@ -529,6 +529,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(() => {
const map = new Map<string, number>()
@ -571,11 +576,13 @@ export default function AsciidocArticle({ @@ -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({ @@ -2122,39 +2129,45 @@ export default function AsciidocArticle({
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={lightboxIndex}
slides={allImages.map((img) => 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
}}
/>
</div>,
document.body
)}
{allImages.length > 0 &&
lightboxPortalActive &&
typeof document !== 'undefined' &&
createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={lightboxIndex}
slides={lightboxSlides}
plugins={[Video, Zoom]}
open={lightboxIndex >= 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
}}
/>
</div>,
document.body
)}
</>
)
}

94
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -28,7 +28,7 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' @@ -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'
@ -4647,6 +4647,11 @@ export default function MarkdownArticle({ @@ -4647,6 +4647,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
const getImageIdentifier = useMemo(() => {
@ -4751,15 +4756,22 @@ export default function MarkdownArticle({ @@ -4751,15 +4756,22 @@ 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(() => {
const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null
@ -5181,43 +5193,47 @@ export default function MarkdownArticle({ @@ -5181,43 +5193,47 @@ export default function MarkdownArticle({
)}
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={lightboxIndex}
slides={allImages.map((img) => 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
}}
/>
</div>,
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(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={lightboxIndex}
slides={lightboxSlides}
plugins={[Video, Zoom]}
open={lightboxOpen}
close={() => 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
}}
/>
</div>,
document.body
)}
</>
)
}

32
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -3,7 +3,7 @@ import { cn } from '@/lib/utils' @@ -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({ @@ -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<YouTubePlayer | null>(null)
const containerRef = useRef<HTMLDivElement>(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({ @@ -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 <ExternalLink url={url} />
}
if (!mustLoad && !display) {
if (!mustLoad && !showEmbed) {
return (
<div
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline truncate w-fit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
setUserClickedLoad(true)
}}
>
[{t('Click to load YouTube video')}]

22
src/lib/media-placeholder-blurhash.ts

@ -1,18 +1,28 @@ @@ -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.70.85 on a 01 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 {

87
src/lib/nostr-parser.tsx

@ -15,6 +15,7 @@ import { Event } from 'nostr-tools' @@ -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 @@ -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 (
<span className="whitespace-pre-wrap break-words text-primary hover:underline">
{fallbackText}
</span>
)
}
return (
<video
src={mediaUrl}
controls
className="max-w-full sm:max-w-[400px] w-full h-auto rounded-lg my-2 block"
preload="metadata"
onError={() => setFailed(true)}
>
Your browser does not support the video tag.
</video>
)
}
function NostrInlineAudio({ mediaUrl, fallbackText }: { mediaUrl: string; fallbackText: string }) {
const [failed, setFailed] = useState(false)
if (failed) {
return (
<span className="whitespace-pre-wrap break-words text-primary hover:underline">
{fallbackText}
</span>
)
}
return (
<audio
src={mediaUrl}
controls
className="w-full my-2 block"
preload="metadata"
onError={() => setFailed(true)}
>
Your browser does not support the audio tag.
</audio>
)
}
/**
* Render parsed nostr content as React elements
*/
@ -560,47 +605,21 @@ export function renderNostrContent( @@ -560,47 +605,21 @@ export function renderNostrContent(
if (element.type === 'video' && element.mediaUrl) {
return (
<video
<NostrInlineVideo
key={index}
src={element.mediaUrl}
controls
className="max-w-full sm:max-w-[400px] w-full h-auto rounded-lg my-2 block"
preload="metadata"
onError={(e) => {
// Fallback to text if video fails to load
const target = e.target as HTMLVideoElement
target.style.display = 'none'
const textSpan = document.createElement('span')
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline'
textSpan.textContent = element.content
target.parentNode?.insertBefore(textSpan, target.nextSibling)
}}
>
Your browser does not support the video tag.
</video>
mediaUrl={element.mediaUrl}
fallbackText={element.content}
/>
)
}
if (element.type === 'audio' && element.mediaUrl) {
return (
<audio
<NostrInlineAudio
key={index}
src={element.mediaUrl}
controls
className="w-full my-2 block"
preload="metadata"
onError={(e) => {
// Fallback to text if audio fails to load
const target = e.target as HTMLAudioElement
target.style.display = 'none'
const textSpan = document.createElement('span')
textSpan.className = 'whitespace-pre-wrap break-words text-primary hover:underline'
textSpan.textContent = element.content
target.parentNode?.insertBefore(textSpan, target.nextSibling)
}}
>
Your browser does not support the audio tag.
</audio>
mediaUrl={element.mediaUrl}
fallbackText={element.content}
/>
)
}

9
src/providers/ContentPolicyProvider.tsx

@ -98,7 +98,14 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode @@ -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 (

Loading…
Cancel
Save