Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
bbe5ff8cfe
  1. 22
      src/components/Image/index.tsx
  2. 29
      src/components/ImageGallery/index.tsx
  3. 19
      src/components/MediaPlayer/index.tsx
  4. 19
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  5. 28
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 30
      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'
import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash' import { blurHashPlaceholderForMediaUrl } from '@/lib/media-placeholder-blurhash'
import { decode } from 'blurhash' import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react' 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' import { useTranslation } from 'react-i18next'
/** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ /** 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 = <ImageOff />, errorPlaceholder = <ImageOff />,
style: wrapperStyleProp, style: wrapperStyleProp,
holdUntilClick = false, holdUntilClick = false,
onClick,
...props ...props
}: HTMLAttributes<HTMLSpanElement> & { }: HTMLAttributes<HTMLSpanElement> & {
classNames?: { classNames?: {
@ -49,11 +50,9 @@ export default function Image({
hideIfError?: boolean hideIfError?: boolean
errorPlaceholder?: React.ReactNode errorPlaceholder?: React.ReactNode
/** /**
* When true, the full image is NOT loaded until the user interacts. * When true, the full image is not loaded until the user interacts.
* Shows a blurhash canvas if available, otherwise a skeleton placeholder. * The first click runs {@link onClick} (e.g. open lightbox) and also reveals the
* Intended for inline note images: clicking opens the lightbox (via the * inline `<img>` so after the lightbox closes the real image can show from cache.
* onClick handler passed from MarkdownArticle) without ever loading the
* full image inline.
*/ */
holdUntilClick?: boolean holdUntilClick?: boolean
}) { }) {
@ -165,11 +164,16 @@ export default function Image({
setIsLoading(true) setIsLoading(true)
} }
const handleWrapperClick = (e: React.MouseEvent<HTMLSpanElement>) => {
if (holdUntilClick && !revealed) handleReveal()
onClick?.(e)
}
return ( return (
<span <span
className={cn('relative overflow-hidden block w-full', classNames.wrapper)} className={cn('relative overflow-hidden block w-full', classNames.wrapper)}
style={mergedWrapperStyle} style={mergedWrapperStyle}
onClick={!revealed ? handleReveal : undefined} onClick={handleWrapperClick}
{...props} {...props}
> >
{displaySkeleton && !showErrorState && ( {displaySkeleton && !showErrorState && (
@ -209,7 +213,7 @@ export default function Image({
title={finalAlt || undefined} title={finalAlt || undefined}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
decoding="async" decoding="async"
loading="lazy" loading={wasInitiallyHeldRef.current ? 'eager' : 'lazy'}
draggable={false} draggable={false}
onLoad={handleLoad} onLoad={handleLoad}
onError={handleError} onError={handleError}
@ -269,7 +273,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
} }
}, [blurHash]) }, [blurHash])
useEffect(() => { useLayoutEffect(() => {
if (!pixels || !canvasRef.current) return if (!pixels || !canvasRef.current) return
const canvas = canvasRef.current const canvas = canvasRef.current

29
src/components/ImageGallery/index.tsx

@ -4,7 +4,7 @@ import logger from '@/lib/logger'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service' import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types' 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 { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
@ -63,6 +63,15 @@ export default function ImageGallery({
} }
const displayImages = images.slice(start, end) 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) { if (displayImages.length === 1) {
return ( return (
@ -77,8 +86,11 @@ export default function ImageGallery({
) )
} }
if (!mustLoad && !autoLoadMedia) { let imageContent: ReactNode | null = null
return displayImages.map((image, i) => ( if (tapToLoadGallery) {
imageContent = (
<>
{displayImages.map((image, i) => (
<ImageWithLightbox <ImageWithLightbox
key={i} key={i}
image={image} image={image}
@ -87,11 +99,10 @@ export default function ImageGallery({
wrapper: galleryImageWrapper(className) wrapper: galleryImageWrapper(className)
}} }}
/> />
)) ))}
} </>
)
let imageContent: ReactNode | null = null } else if (displayImages.length === 2 || displayImages.length === 4) {
if (displayImages.length === 2 || displayImages.length === 4) {
imageContent = ( imageContent = (
<div className="grid grid-cols-2 gap-2 w-full max-w-[400px]"> <div className="grid grid-cols-2 gap-2 w-full max-w-[400px]">
{displayImages.map((image, i) => ( {displayImages.map((image, i) => (
@ -120,7 +131,7 @@ export default function ImageGallery({
} }
const portal = const portal =
lightboxPortalActive && typeof document !== 'undefined' !tapToLoadGallery && lightboxPortalActive && typeof document !== 'undefined'
? createPortal( ? createPortal(
<div <div
data-lightbox-overlay data-lightbox-overlay

19
src/components/MediaPlayer/index.tsx

@ -1,7 +1,7 @@
import { isImage } from '@/lib/url' import { isImage } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' 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 AudioPlayer from '../AudioPlayer'
import VideoPlayer from '../VideoPlayer' import VideoPlayer from '../VideoPlayer'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
@ -48,7 +48,8 @@ export default function MediaPlayer({
blurHash?: string blurHash?: string
}) { }) {
const { autoLoadMedia } = useContentPolicy() 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 [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
const [probeFailed, setProbeFailed] = useState(false) const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = 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. */ /** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */
const effectiveMediaType = mediaType ?? urlEmbedTypeHint const effectiveMediaType = mediaType ?? urlEmbedTypeHint
const showEmbed = mustLoad || display const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
useEffect(() => { useLayoutEffect(() => {
if (autoLoadMedia) { if (!autoLoadMedia) setUserClickedLoad(false)
setDisplay(true)
} else {
setDisplay(false)
}
}, [autoLoadMedia]) }, [autoLoadMedia])
useEffect(() => { useEffect(() => {
@ -148,13 +145,13 @@ export default function MediaPlayer({
setEmbedPainted(true) setEmbedPainted(true)
}, []) }, [])
if (!mustLoad && !display) { if (!mustLoad && !showEmbed) {
return ( return (
<LazyMediaTapPlaceholder <LazyMediaTapPlaceholder
src={src} src={src}
posterUrl={imagePoster} posterUrl={imagePoster}
blurHash={blurHash} blurHash={blurHash}
onActivate={() => setDisplay(true)} onActivate={() => setUserClickedLoad(true)}
className={className} className={className}
/> />
) )

19
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -529,6 +529,11 @@ export default function AsciidocArticle({
return images return images
}, [extractedMedia.images, metadata.image]) }, [extractedMedia.images, metadata.image])
const lightboxSlides = useMemo(
() => allImages.map((img) => lightboxSlideFromImeta(img)),
[allImages]
)
// Create image index map for lightbox // Create image index map for lightbox
const imageIndexMap = useMemo(() => { const imageIndexMap = useMemo(() => {
const map = new Map<string, number>() const map = new Map<string, number>()
@ -571,11 +576,13 @@ export default function AsciidocArticle({
// Note: contentLinks removed - WebPreview is disabled for AsciiDoc articles // 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 [lightboxIndex, setLightboxIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
const openLightbox = useCallback((index: number) => { const openLightbox = useCallback((index: number) => {
setLightboxIndex(index) setLightboxIndex(index)
setLightboxPortalActive(true)
}, []) }, [])
// Filter tag media to only show what's not in content // Filter tag media to only show what's not in content
@ -2122,7 +2129,10 @@ export default function AsciidocArticle({
</div> </div>
{/* Image gallery lightbox */} {/* Image gallery lightbox */}
{allImages.length > 0 && createPortal( {allImages.length > 0 &&
lightboxPortalActive &&
typeof document !== 'undefined' &&
createPortal(
<div <div
data-lightbox-overlay data-lightbox-overlay
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -2132,10 +2142,13 @@ export default function AsciidocArticle({
> >
<Lightbox <Lightbox
index={lightboxIndex} index={lightboxIndex}
slides={allImages.map((img) => lightboxSlideFromImeta(img))} slides={lightboxSlides}
plugins={[Video, Zoom]} plugins={[Video, Zoom]}
open={lightboxIndex >= 0} open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)} close={() => setLightboxIndex(-1)}
on={{
exited: () => setLightboxPortalActive(false)
}}
controller={{ controller={{
closeOnBackdropClick: false, closeOnBackdropClick: false,
closeOnPullUp: true, closeOnPullUp: true,

28
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -28,7 +28,7 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag' import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji, TImetaInfo } from '@/types' import { TEmoji, TImetaInfo } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji' 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 { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
@ -4647,6 +4647,11 @@ export default function MarkdownArticle({
return images return images
}, [extractedMedia.images, metadata.image]) }, [extractedMedia.images, metadata.image])
const lightboxSlides = useMemo(
() => allImages.map((img) => lightboxSlideFromImeta(img)),
[allImages]
)
// Helper function to extract image filename/hash from URL for comparison // Helper function to extract image filename/hash from URL for comparison
// This helps identify the same image hosted on different domains // This helps identify the same image hosted on different domains
const getImageIdentifier = useMemo(() => { const getImageIdentifier = useMemo(() => {
@ -4751,15 +4756,22 @@ export default function MarkdownArticle({
return links return links
}, [event.content]) }, [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 [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0) const [lightboxIndex, setLightboxIndex] = useState(0)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
const openLightbox = useCallback((index: number) => { const openLightbox = useCallback((index: number) => {
setLightboxIndex(index) setLightboxIndex(index)
setLightboxOpen(true) setLightboxOpen(true)
setLightboxPortalActive(true)
}, []) }, [])
useLayoutEffect(() => {
setLightboxOpen(false)
setLightboxPortalActive(false)
}, [lazyMedia])
// Filter tag media to only show what's not in content // Filter tag media to only show what's not in content
const leftoverTagMedia = useMemo(() => { const leftoverTagMedia = useMemo(() => {
const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null
@ -5181,8 +5193,11 @@ export default function MarkdownArticle({
)} )}
</div> </div>
{/* Image gallery lightbox */} {/* Image gallery lightbox — mount portal only when open; avoids N× Lightbox reconciling on body when policy/feed re-renders */}
{allImages.length > 0 && createPortal( {allImages.length > 0 &&
lightboxPortalActive &&
typeof document !== 'undefined' &&
createPortal(
<div <div
data-lightbox-overlay data-lightbox-overlay
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -5192,12 +5207,13 @@ export default function MarkdownArticle({
> >
<Lightbox <Lightbox
index={lightboxIndex} index={lightboxIndex}
slides={allImages.map((img) => lightboxSlideFromImeta(img))} slides={lightboxSlides}
plugins={[Video, Zoom]} plugins={[Video, Zoom]}
open={lightboxOpen} open={lightboxOpen}
close={() => setLightboxOpen(false)} close={() => setLightboxOpen(false)}
on={{ on={{
view: ({ index }) => setLightboxIndex(index) view: ({ index }) => setLightboxIndex(index),
exited: () => setLightboxPortalActive(false)
}} }}
controller={{ controller={{
closeOnBackdropClick: false, closeOnBackdropClick: false,

30
src/components/YoutubeEmbeddedPlayer/index.tsx

@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service' import mediaManager from '@/services/media-manager.service'
import { YouTubePlayer } from '@/types/youtube' 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 { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink' import ExternalLink from '../ExternalLink'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -20,23 +20,21 @@ export default function YoutubeEmbeddedPlayer({
const { t } = useTranslation() const { t } = useTranslation()
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [display, setDisplay] = useState(autoLoadMedia) const [userClickedLoad, setUserClickedLoad] = useState(false)
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url]) const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
const [initSuccess, setInitSuccess] = useState(false) const [initSuccess, setInitSuccess] = useState(false)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const playerRef = useRef<YouTubePlayer | null>(null) const playerRef = useRef<YouTubePlayer | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useLayoutEffect(() => {
if (autoLoadMedia) { if (!autoLoadMedia) setUserClickedLoad(false)
setDisplay(true)
} else {
setDisplay(false)
}
}, [autoLoadMedia]) }, [autoLoadMedia])
const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
useEffect(() => { useEffect(() => {
if (!videoId || !containerRef.current || (!mustLoad && !display)) return if (!videoId || !containerRef.current || !showEmbed) return
let cancelled = false let cancelled = false
@ -71,24 +69,28 @@ export default function YoutubeEmbeddedPlayer({
return () => { return () => {
cancelled = true cancelled = true
if (playerRef.current) { const player = playerRef.current
playerRef.current.destroy()
playerRef.current = null 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) { if (error) {
return <ExternalLink url={url} /> return <ExternalLink url={url} />
} }
if (!mustLoad && !display) { if (!mustLoad && !showEmbed) {
return ( return (
<div <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" 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) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setDisplay(true) setUserClickedLoad(true)
}} }}
> >
[{t('Click to load YouTube video')}] [{t('Click to load YouTube video')}]

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

@ -1,18 +1,28 @@
import { isBlurhashValid } from 'blurhash' import { isBlurhashValid } from 'blurhash'
/** /**
* Stable, varied BlurHash strings for lazy media (no imeta). Picked from the reference * Stable, varied BlurHash strings for lazy media (no NIP-94 blurHash).
* encoder corpus; each validates with {@link isBlurhashValid}. *
* 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 = [ const PLACEHOLDER_BLURHASHES = [
'LEHV6nWB2yk8pyo0adR*.7kCMdnj', 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.', '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.', 'LdHxL5Rk^6#M@-5c,1J5@[or[Q6.',
'LGF?UQ%2Tw[w]~RBVZRi};RPxuwH', '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) ].filter((h) => isBlurhashValid(h).result)
function fallbackHashString(s: string): number { function fallbackHashString(s: string): number {

87
src/lib/nostr-parser.tsx

@ -15,6 +15,7 @@ import { Event } from 'nostr-tools'
import { NOSTR_PARSER_REGEX } from '@/lib/content-patterns' import { NOSTR_PARSER_REGEX } from '@/lib/content-patterns'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug' import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { useState } from 'react'
export interface ParsedNostrContent { export interface ParsedNostrContent {
elements: Array<{ elements: Array<{
@ -528,6 +529,50 @@ function getNostrType(bech32Id: string): 'npub' | 'nprofile' | 'nevent' | 'naddr
return null 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 * Render parsed nostr content as React elements
*/ */
@ -560,47 +605,21 @@ export function renderNostrContent(
if (element.type === 'video' && element.mediaUrl) { if (element.type === 'video' && element.mediaUrl) {
return ( return (
<video <NostrInlineVideo
key={index} key={index}
src={element.mediaUrl} mediaUrl={element.mediaUrl}
controls fallbackText={element.content}
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>
) )
} }
if (element.type === 'audio' && element.mediaUrl) { if (element.type === 'audio' && element.mediaUrl) {
return ( return (
<audio <NostrInlineAudio
key={index} key={index}
src={element.mediaUrl} mediaUrl={element.mediaUrl}
controls fallbackText={element.content}
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>
) )
} }

9
src/providers/ContentPolicyProvider.tsx

@ -98,7 +98,14 @@ export function ContentPolicyProvider({ children }: { children: React.ReactNode
const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => { const updateMediaAutoLoadPolicy = (policy: TMediaAutoLoadPolicy) => {
storage.setMediaAutoLoadPolicy(policy) 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 ( return (

Loading…
Cancel
Save