Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
a988fd57d5
  1. 196
      src/components/Image/index.tsx
  2. 47
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 3
      src/i18n/locales/de.ts
  4. 3
      src/i18n/locales/en.ts
  5. 7
      src/lib/url.ts
  6. 14
      src/services/media-extraction.service.ts

196
src/components/Image/index.tsx

@ -1,21 +1,35 @@ @@ -1,21 +1,35 @@
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { isSafeMediaUrl } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { preferBlossomPrimalDisplayUrl, primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url'
import { getHashFromURL } from 'blossom-client-sdk'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
import logger from '@/lib/logger'
import { CSSProperties, HTMLAttributes, useEffect, 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. */
const IMAGE_LOAD_TIMEOUT_MS = 10_000
/** Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. */
function wrapperReserveStyle(
dim: { width: number; height: number } | undefined,
showError: boolean
): CSSProperties | undefined {
if (showError) return undefined
if (dim && dim.width > 0 && dim.height > 0) {
return { aspectRatio: `${dim.width} / ${dim.height}` }
}
return { minHeight: 'min(30vh, 280px)' }
}
export default function Image({
image: { url, blurHash, pubkey, dim, alt: imetaAlt, fallback },
image: { url, blurHash, dim, alt: imetaAlt, fallback },
alt,
className = '',
classNames = {},
hideIfError = false,
errorPlaceholder = <ImageOff />,
style: wrapperStyleProp,
...props
}: HTMLAttributes<HTMLSpanElement> & {
classNames?: {
@ -27,117 +41,91 @@ export default function Image({ @@ -27,117 +41,91 @@ export default function Image({
hideIfError?: boolean
errorPlaceholder?: React.ReactNode
}) {
const [isLoading, setIsLoading] = useState(true)
const [displaySkeleton, setDisplaySkeleton] = useState(true)
const [hasError, setHasError] = useState(false)
const [imageUrl, setImageUrl] = useState(() => preferBlossomPrimalDisplayUrl(url))
const [tried, setTried] = useState(new Set())
const { t } = useTranslation()
const urlOk = !!url?.trim()
const [isLoading, setIsLoading] = useState(urlOk)
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)
// Use imeta alt text if available, otherwise use the passed alt prop
const finalAlt = imetaAlt || alt
const openLinkHref =
(isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || ''
const badSrc = !imageUrl?.trim() || !isSafeMediaUrl(imageUrl.trim())
const showErrorState = hasError || badSrc
const clearLoadWatch = () => {
if (loadWatchRef.current != null) {
clearTimeout(loadWatchRef.current)
loadWatchRef.current = null
}
}
useEffect(() => {
setImageUrl(preferBlossomPrimalDisplayUrl(url))
setImageUrl(url)
setIsLoading(true)
setHasError(false)
setDisplaySkeleton(true)
setTried(new Set())
setFallbackIndex(0)
}, [url])
if (hideIfError && hasError) return null
const handleError = async () => {
// First, try fallback URLs from imeta if available
if (fallback && fallbackIndex < fallback.length) {
const nextFallbackUrl = fallback[fallbackIndex]
setFallbackIndex(prev => prev + 1)
setImageUrl(preferBlossomPrimalDisplayUrl(nextFallbackUrl))
return
}
// If no more fallbacks, try Blossom servers
let oldImageUrl: URL | undefined
let hash: string | null = null
try {
oldImageUrl = new URL(imageUrl)
hash = getHashFromURL(oldImageUrl)
} catch (error) {
logger.error('Invalid image URL', { error, imageUrl })
}
if (!hash || !oldImageUrl) {
clearLoadWatch()
if (!url?.trim()) {
setIsLoading(false)
setHasError(true)
return
}
// r2a failed: try canonical blossom URL from props (some networks only allow one hop).
if (
oldImageUrl.hostname === 'r2a.primal.net' &&
url &&
url !== imageUrl &&
url.includes('blossom.primal.net') &&
!tried.has('blossom.primal.net-direct')
) {
setTried((prev) => new Set(prev).add('blossom.primal.net-direct'))
setImageUrl(url)
return
}
// Primal: only mirror blossom → r2a when we did not already open the note with that CDN URL (avoids r2a↔blossom loops).
if (oldImageUrl.hostname === 'blossom.primal.net') {
const r2a = primalR2aMirrorForBlossomPrimalUrl(oldImageUrl)
const noteAlreadyUsesPrimalCdnFirst = preferBlossomPrimalDisplayUrl(url) !== url
if (r2a && !noteAlreadyUsesPrimalCdnFirst && !tried.has('blossom.primal.net')) {
setTried((prev) => new Set(prev).add('blossom.primal.net'))
setImageUrl(r2a)
return
}
setDisplaySkeleton(false)
}
}, [url])
if (!pubkey) {
useEffect(() => {
clearLoadWatch()
if (badSrc || !url?.trim()) return
loadWatchRef.current = window.setTimeout(() => {
loadWatchRef.current = null
setIsLoading(false)
setDisplaySkeleton(false)
setHasError(true)
return
}
}, IMAGE_LOAD_TIMEOUT_MS)
return clearLoadWatch
}, [imageUrl, badSrc, url])
const extMatch = oldImageUrl.pathname.match(/\.\w+$/i)
const extStr = extMatch?.[0] ?? ''
setTried((prev) => new Set(prev).add(oldImageUrl.hostname))
if (hideIfError && showErrorState) return null
const blossomServerList = await client.fetchBlossomServerList(pubkey)
const urls = blossomServerList
.map((server) => {
try {
return new URL(server)
} catch (error) {
logger.error('Invalid Blossom server URL', { server, error })
return undefined
const handleError = () => {
clearLoadWatch()
if (fallback && fallbackIndex < fallback.length) {
const next = fallback[fallbackIndex]
setFallbackIndex((prev) => prev + 1)
setImageUrl(next)
return
}
})
.filter((u) => !!u && !tried.has(u.hostname))
const nextUrl = urls[0]
if (!nextUrl) {
setIsLoading(false)
setDisplaySkeleton(false)
setHasError(true)
return
}
nextUrl.pathname = '/' + hash + extStr
setImageUrl(preferBlossomPrimalDisplayUrl(nextUrl.toString()))
}
const handleLoad = () => {
clearLoadWatch()
setIsLoading(false)
setHasError(false)
setTimeout(() => setDisplaySkeleton(false), 600)
}
const reserveStyle = wrapperReserveStyle(dim, showErrorState)
const mergedWrapperStyle: CSSProperties | undefined =
reserveStyle || wrapperStyleProp
? { ...reserveStyle, ...wrapperStyleProp }
: undefined
return (
<span className={cn('relative overflow-hidden block', classNames.wrapper)} {...props}>
{displaySkeleton && (
<span className="absolute inset-0 z-10 inline-block">
<span
className={cn('relative overflow-hidden block w-full', classNames.wrapper)}
style={mergedWrapperStyle}
{...props}
>
{displaySkeleton && !showErrorState && (
<span className="absolute inset-0 z-10 block rounded-lg bg-muted/30">
{blurHash ? (
<BlurHashCanvas
blurHash={blurHash}
@ -149,21 +137,21 @@ export default function Image({ @@ -149,21 +137,21 @@ export default function Image({
) : (
<Skeleton
className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg',
'absolute inset-0 h-full min-h-[8rem] w-full transition-opacity duration-500 rounded-lg',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
)}
</span>
)}
{!hasError && (
{!showErrorState && (
<img
src={imageUrl}
alt={finalAlt}
title={finalAlt || undefined}
referrerPolicy="no-referrer"
decoding="async"
loading="lazy"
loading="eager"
draggable={false}
onLoad={handleLoad}
onError={handleError}
@ -174,18 +162,33 @@ export default function Image({ @@ -174,18 +162,33 @@ export default function Image({
)}
width={dim?.width}
height={dim?.height}
{...props}
/>
)}
{hasError && (
{showErrorState && (
<div
role="alert"
className={cn(
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
'flex flex-col items-center justify-center gap-2 w-full min-h-[120px] p-4 rounded-lg bg-muted text-muted-foreground text-center',
className,
classNames.errorPlaceholder
)}
>
{errorPlaceholder}
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-10">{errorPlaceholder}</span>
<p className="text-sm leading-snug">{t('This image could not be loaded.')}</p>
{badSrc && !hasError ? (
<p className="text-xs opacity-80 break-all max-w-full">{t('Invalid or unsupported image address.')}</p>
) : null}
{openLinkHref ? (
<a
href={openLinkHref}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline break-all max-w-full"
onClick={(e) => e.stopPropagation()}
>
{t('Open image link')}
</a>
) : null}
</div>
)}
</span>
@ -201,8 +204,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN @@ -201,8 +204,7 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
if (!blurHash) return null
try {
return decode(blurHash, blurHashWidth, blurHashHeight)
} catch (error) {
logger.warn('Failed to decode blurhash', error as Error)
} catch {
return null
}
}, [blurHash])

47
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -3111,9 +3111,7 @@ function parseMarkdownContentMarked( @@ -3111,9 +3111,7 @@ function parseMarkdownContentMarked(
const src = String(token.href ?? '')
const cleaned = cleanUrl(src)
if (!cleaned) break
// Inline context: avoid block image/media mounts inside <p>/<li>/<th>/<td>.
// Standalone image paragraphs are handled separately in renderParagraph().
const label = String(token.text ?? src)
const label = String(token.text ?? '')
if (isVideo(cleaned) || isAudio(cleaned)) {
out.push(
<a
@ -3123,25 +3121,46 @@ function parseMarkdownContentMarked( @@ -3123,25 +3121,46 @@ function parseMarkdownContentMarked(
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label}
{label || src}
</a>
)
break
}
if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) {
out.push(<span key={`${key}-img-fallback`} className="break-words">{label}</span>)
out.push(
<span key={`${key}-img-fallback`} className="break-words">
{label || src}
</span>
)
break
}
// `![](url)` has empty alt — a plain <a>{label}</a> was invisible. Use Image like block paragraphs.
const baseImeta = imetaInfoForStandaloneImageUrl(cleaned)
const identifier = getImageIdentifier?.(cleaned)
const thumbnail =
imageThumbnailMap?.get(cleaned) ??
(identifier ? imageThumbnailMap?.get(`__img_id:${identifier}`) : undefined)
const imageUrl = thumbnail || src
let imageIdx = imageIndexMap.get(cleaned)
if (imageIdx === undefined && getImageIdentifier) {
const id = getImageIdentifier(cleaned)
if (id) imageIdx = imageIndexMap.get(`__img_id:${id}`)
}
out.push(
<a
key={`${key}-img-link`}
href={src}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label}
</a>
<Image
key={`${key}-img-inline`}
image={{ ...baseImeta, url: imageUrl }}
alt={label || 'image'}
className="w-full rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'not-prose my-2 block max-w-[400px] mx-auto rounded-lg w-full',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
if (typeof imageIdx === 'number') openLightbox(imageIdx)
}}
/>
)
break
}

3
src/i18n/locales/de.ts

@ -341,6 +341,9 @@ export default { @@ -341,6 +341,9 @@ export default {
'Picture note requires images': 'Bildnotiz erfordert Bilder',
Relays: 'Relays',
Image: 'Bild',
'This image could not be loaded.': 'Dieses Bild konnte nicht geladen werden.',
'Invalid or unsupported image address.': 'Ungültige oder nicht unterstützte Bildadresse.',
'Open image link': 'Bildlink öffnen',
'Upload Image': 'Bild hochladen',
'Insert emoji': 'Emoji einfügen',
'Insert GIF': 'GIF einfügen',

3
src/i18n/locales/en.ts

@ -340,6 +340,9 @@ export default { @@ -340,6 +340,9 @@ export default {
'Picture note requires images': 'Picture note requires images',
Relays: 'Relays',
Image: 'Image',
'This image could not be loaded.': 'This image could not be loaded.',
'Invalid or unsupported image address.': 'Invalid or unsupported image address.',
'Open image link': 'Open image link',
'Upload Image': 'Upload Image',
'Insert emoji': 'Insert emoji',
'Insert GIF': 'Insert GIF',

7
src/lib/url.ts

@ -367,11 +367,12 @@ export function primalR2aMirrorForBlossomPrimalUrl(url: string | URL): string | @@ -367,11 +367,12 @@ export function primalR2aMirrorForBlossomPrimalUrl(url: string | URL): string |
}
/**
* Prefer Primals CDN URL for `img src` when the note points at `blossom.primal.net/…`.
* Same file as the blossom URL; avoids browsers that block or hang on the blossom host (Primal/Wisp-style delivery).
* Display URL for note/imeta image `src`. Keep `https://blossom.primal.net/{sha256}.ext` as-is: it is the
* canonical URL in events and usually loads reliably. Use {@link primalR2aMirrorForBlossomPrimalUrl} only
* as a fallback in {@link Image} `onError` when the blossom host fails.
*/
export function preferBlossomPrimalDisplayUrl(url: string): string {
return primalR2aMirrorForBlossomPrimalUrl(url) ?? url
return url
}
/**

14
src/services/media-extraction.service.ts

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import { TImetaInfo } from '@/types'
import mediaUpload from './media-upload.service'
@ -15,7 +14,7 @@ export interface ExtractedMedia { @@ -15,7 +14,7 @@ export interface ExtractedMedia {
/**
* Unified service for extracting all media (images, videos, audio) from an event
* Sources: imeta tags, r tags, image tags, and content field
* Sources: imeta tags, image tags, and content field (not `r` tags those are references, not media embeds)
*/
export function extractAllMediaFromEvent(
event: Event,
@ -73,20 +72,13 @@ export function extractAllMediaFromEvent( @@ -73,20 +72,13 @@ export function extractAllMediaFromEvent(
}
})
// 2. Extract from r tags (reference/URL tags)
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addMedia(url)
}
})
// 3. Extract from image tag
// 2. Extract from image tag
const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addMedia(imageTag[1])
}
// 4. Extract from content (if provided)
// 3. Extract from content (if provided)
if (content) {
// First, extract from markdown image syntax: ![alt](url) or [![](url)](link)
// This handles images inside links

Loading…
Cancel
Save