Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
cd2855252d
  1. 9
      src/components/Collapsible/index.tsx
  2. 32
      src/components/Image/index.tsx
  3. 15
      src/components/MediaPlayer/index.tsx
  4. 3
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 18
      src/components/UserAvatar/index.tsx
  6. 24
      src/lib/nostr-build.test.ts
  7. 25
      src/lib/nostr-build.ts
  8. 23
      src/lib/tag.ts
  9. 131
      src/lib/upload-nip94-imeta.ts
  10. 32
      src/services/media-upload.service.ts

9
src/components/Collapsible/index.tsx

@ -58,10 +58,13 @@ export default function Collapsible({
> >
{children} {children}
{shouldCollapse && !expanded && ( {shouldCollapse && !expanded && (
<div className="absolute bottom-0 h-40 w-full bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4"> <div
<div className="bg-background rounded-md"> className="pointer-events-none absolute bottom-0 z-20 flex h-40 w-full items-end justify-center bg-gradient-to-b from-transparent to-background/90 pb-4"
data-collapsible-show-more
>
<div className="pointer-events-auto rounded-md">
<Button <Button
className="bg-foreground hover:bg-foreground/80" className="bg-foreground text-background hover:bg-foreground/90 hover:text-background"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setExpanded(!expanded) setExpanded(!expanded)

32
src/components/Image/index.tsx

@ -2,6 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { isRenderableMediaUrl, isSafeMediaUrl } from '@/lib/url' import { isRenderableMediaUrl, isSafeMediaUrl } from '@/lib/url'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
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, useMemo, useRef, useState } from 'react'
@ -80,6 +81,15 @@ export default function Image({
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim()) const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim())
const showErrorState = hasError || badSrc const showErrorState = hasError || badSrc
/** NIP-94 blurhash when present; otherwise a stable URL-derived placeholder (many events omit blurhash). */
const effectiveBlurHash = useMemo(() => {
const fromTag = blurHash?.trim()
if (fromTag) return fromTag
const u = url?.trim()
if (!u) return undefined
return blurHashPlaceholderForMediaUrl(u)
}, [blurHash, url])
const clearLoadWatch = () => { const clearLoadWatch = () => {
if (loadWatchRef.current != null) { if (loadWatchRef.current != null) {
clearTimeout(loadWatchRef.current) clearTimeout(loadWatchRef.current)
@ -163,13 +173,15 @@ export default function Image({
{...props} {...props}
> >
{displaySkeleton && !showErrorState && ( {displaySkeleton && !showErrorState && (
<span className="absolute inset-0 z-10 block rounded-lg bg-muted/30"> <span className="absolute inset-0 z-10 block rounded-lg bg-muted/40">
{blurHash ? ( {effectiveBlurHash ? (
<BlurHashCanvas <BlurHashCanvas
blurHash={blurHash} blurHash={effectiveBlurHash}
className={cn( className={cn(
'absolute inset-0 transition-opacity duration-500 rounded-lg', 'absolute inset-0 transition-opacity duration-500 rounded-lg',
!revealed ? 'opacity-100' : 'opacity-0' // Keep placeholder visible while the full image is still loading (auto-load),
// otherwise both blur and <img> are opacity-0 and only a faint bg shows (looks like a white box).
!revealed || isLoading ? 'opacity-100' : 'opacity-0'
)} )}
/> />
) : !revealed && !isLoading ? ( ) : !revealed && !isLoading ? (
@ -271,12 +283,22 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
if (!blurHash) return null if (!blurHash) return null
// Failed decode or unsupported hash: empty <canvas> often paints as solid white — use muted fill instead.
if (!pixels) {
return (
<span
className={cn('block h-full w-full rounded-lg bg-muted object-cover', className)}
aria-hidden
/>
)
}
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={blurHashWidth} width={blurHashWidth}
height={blurHashHeight} height={blurHashHeight}
className={cn('w-full h-full object-cover rounded-lg', className)} className={cn('h-full w-full object-cover rounded-lg', className)}
style={{ style={{
imageRendering: 'auto', imageRendering: 'auto',
filter: 'blur(0.5px)' filter: 'blur(0.5px)'

15
src/components/MediaPlayer/index.tsx

@ -1,5 +1,6 @@
import { isImage } from '@/lib/url'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useEffect, useState } from 'react' import { useEffect, useMemo, 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'
@ -23,6 +24,14 @@ export default function MediaPlayer({
const [display, setDisplay] = useState(autoLoadMedia) const [display, setDisplay] = useState(autoLoadMedia)
const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null) const [mediaType, setMediaType] = useState<'video' | 'audio' | null>(null)
// imeta `thumb` / `image` are sometimes the same .mp4 as `url` — <img> cannot use that, and it
// would hide the blurhash placeholder in LazyMediaTapPlaceholder.
const imagePoster = useMemo(() => {
const p = poster?.trim()
if (!p) return undefined
return isImage(p) ? p : undefined
}, [poster])
useEffect(() => { useEffect(() => {
if (autoLoadMedia) { if (autoLoadMedia) {
setDisplay(true) setDisplay(true)
@ -78,7 +87,7 @@ export default function MediaPlayer({
return ( return (
<LazyMediaTapPlaceholder <LazyMediaTapPlaceholder
src={src} src={src}
posterUrl={poster} posterUrl={imagePoster}
blurHash={blurHash} blurHash={blurHash}
onActivate={() => setDisplay(true)} onActivate={() => setDisplay(true)}
className={className} className={className}
@ -91,7 +100,7 @@ export default function MediaPlayer({
} }
if (mediaType === 'video') { if (mediaType === 'video') {
return <VideoPlayer src={src} className={className} poster={poster} /> return <VideoPlayer src={src} className={className} poster={imagePoster} />
} }
return <AudioPlayer src={src} className={className} /> return <AudioPlayer src={src} className={className} />

3
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -4836,7 +4836,8 @@ export default function MarkdownArticle({
if (info.m?.startsWith('video/') || isVideo(info.url)) { if (info.m?.startsWith('video/') || isVideo(info.url)) {
const cleaned = cleanUrl(info.url) const cleaned = cleanUrl(info.url)
const posterUrl = info.image || info.thumb const posterUrl = info.image || info.thumb
if (cleaned && posterUrl) { // thumb is often wrongly set to the same video URL; only real image URLs work as <img poster>.
if (cleaned && posterUrl && isImage(posterUrl)) {
map.set(cleaned, posterUrl) map.set(cleaned, posterUrl)
} }
} }

18
src/components/UserAvatar/index.tsx

@ -28,12 +28,25 @@ const loadedAvatarUrls = new Set<string>()
* Non-blocking HEAD request to get Content-Length for a URL. * Non-blocking HEAD request to get Content-Length for a URL.
* Result is cached permanently in memory. Resolves null on CORS failure or missing header. * Result is cached permanently in memory. Resolves null on CORS failure or missing header.
*/ */
const AVATAR_HEAD_TIMEOUT_MS = 3000
async function fetchUrlSizeBytes(url: string): Promise<number | null> { async function fetchUrlSizeBytes(url: string): Promise<number | null> {
if (urlSizeCache.has(url)) return urlSizeCache.get(url)! if (urlSizeCache.has(url)) return urlSizeCache.get(url)!
try { try {
const res = await fetch(url, { method: 'HEAD' }) const ctrl = new AbortController()
const timer = window.setTimeout(() => ctrl.abort(), AVATAR_HEAD_TIMEOUT_MS)
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) {
urlSizeCache.set(url, null)
return null
}
const cl = res.headers.get('content-length') const cl = res.headers.get('content-length')
const size = cl ? parseInt(cl, 10) : null const size = cl ? parseInt(cl, 10) : null
if (size != null && !Number.isFinite(size)) {
urlSizeCache.set(url, null)
return null
}
urlSizeCache.set(url, size) urlSizeCache.set(url, size)
return size return size
} catch { } catch {
@ -59,8 +72,7 @@ function useDeferRemoteProfileAvatar(
if (!a || !isHttpOrHttpsUrl(a)) return '' if (!a || !isHttpOrHttpsUrl(a)) return ''
// Video files don't have a /thumb/ route — serve them as-is. // Video files don't have a /thumb/ route — serve them as-is.
if (isVideo(a)) return a if (isVideo(a)) return a
// Always use the nostr.build thumbnail route for profile pictures — it's // i.nostr.build serves /thumb/… for images (cdn.nostr.build does not).
// typically < 50 KB regardless of the original file size.
return toNostrBuildThumbUrl(a) return toNostrBuildThumbUrl(a)
}, [profileAvatar]) }, [profileAvatar])

24
src/lib/nostr-build.test.ts

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from './nostr-build'
describe('nostr-build thumb URLs', () => {
it('allows /thumb/ rewrite only for i.nostr.build images', () => {
expect(canUseNostrBuildThumb('https://i.nostr.build/foo.webp')).toBe(true)
expect(toNostrBuildThumbUrl('https://i.nostr.build/foo.webp')).toBe(
'https://i.nostr.build/thumb/foo.webp'
)
})
it('does not rewrite cdn.nostr.build (no /thumb/ service)', () => {
expect(canUseNostrBuildThumb('https://cdn.nostr.build/i/abc123.webp')).toBe(false)
expect(toNostrBuildThumbUrl('https://cdn.nostr.build/i/abc123.webp')).toBe(
'https://cdn.nostr.build/i/abc123.webp'
)
})
it('does not rewrite video URLs on i.nostr.build', () => {
const u = 'https://i.nostr.build/bar.webm'
expect(canUseNostrBuildThumb(u)).toBe(false)
expect(toNostrBuildThumbUrl(u)).toBe(u)
})
})

25
src/lib/nostr-build.ts

@ -1,14 +1,16 @@
/** /**
* Utilities for nostr.build CDN URLs. * Utilities for nostr.build media URLs.
* *
* nostr.build generates a lightweight thumbnail at /thumb/<filename> for every * Thumbnails at `/thumb/<path>` are served on **i.nostr.build** only. Other hosts
* uploaded image. Thumbnails are typically < 50 KB regardless of the original * (e.g. **cdn.nostr.build**) do not provide that route never rewrite those URLs.
* file size a huge bandwidth win for profile pictures and feed previews. *
* Note: the /thumb/ route only works for image files never apply it to video URLs. * The /thumb/ route is for **images** only never apply it to video URLs.
*/ */
import { isVideo } from './url' import { isVideo } from './url'
const I_NOSTR_BUILD = 'i.nostr.build'
/** Returns true when a URL is hosted on any nostr.build domain. */ /** Returns true when a URL is hosted on any nostr.build domain. */
export function isNostrBuildUrl(url: string): boolean { export function isNostrBuildUrl(url: string): boolean {
const u = (url ?? '').trim() const u = (url ?? '').trim()
@ -20,15 +22,17 @@ export function isNostrBuildUrl(url: string): boolean {
} }
} }
/** Returns true when the URL is on nostr.build but does NOT yet use the /thumb/ path, and is not a video file. */ /**
* True when we may rewrite `url` to i.nostr.builds `/thumb/…` variant.
* Only **i.nostr.build** serves generated thumbs; cdn.nostr.build does not.
*/
export function canUseNostrBuildThumb(url: string): boolean { export function canUseNostrBuildThumb(url: string): boolean {
const u = (url ?? '').trim() const u = (url ?? '').trim()
if (!u) return false if (!u) return false
// /thumb/ is image-only on nostr.build — never apply it to video files
if (isVideo(u)) return false if (isVideo(u)) return false
try { try {
const parsed = new URL(u) const parsed = new URL(u)
if (!parsed.hostname.endsWith('nostr.build')) return false if (parsed.hostname !== I_NOSTR_BUILD) return false
const p = parsed.pathname const p = parsed.pathname
return p !== '/thumb' && !p.startsWith('/thumb/') return p !== '/thumb' && !p.startsWith('/thumb/')
} catch { } catch {
@ -37,9 +41,8 @@ export function canUseNostrBuildThumb(url: string): boolean {
} }
/** /**
* Returns the nostr.build thumbnail URL for `url`, inserting `/thumb` before the * Returns the i.nostr.build thumbnail URL for `url` (insert `/thumb` before the path).
* filename path segment. Returns the original URL unchanged if it is not on * Returns `url` unchanged if not on i.nostr.build, already under /thumb/, or invalid.
* nostr.build, already uses /thumb/, or cannot be parsed.
*/ */
export function toNostrBuildThumbUrl(url: string): string { export function toNostrBuildThumbUrl(url: string): string {
const u = (url ?? '').trim() const u = (url ?? '').trim()

23
src/lib/tag.ts

@ -19,6 +19,27 @@ export function tagNameEquals(tagName: string) {
const NOTE_HEX_ID_RE = /^[0-9a-f]{64}$/i const NOTE_HEX_ID_RE = /^[0-9a-f]{64}$/i
/**
* Some clients publish non-NIP-94 `m` values, e.g. `gif(694866 bytes)`.
* Map common image tokens to a proper MIME type for routing and media extraction.
*/
function normalizeImetaMimeField(raw: string): string {
const s = raw.trim()
if (/^(image|video|audio)\//i.test(s)) return s
const m = s.match(/^(gif|jpe?g|png|webp|avif|heic|svg)\b/i)
if (m) {
const k = m[1].toLowerCase()
if (k === 'jpeg' || k === 'jpg') return 'image/jpeg'
if (k === 'svg') return 'image/svg+xml'
if (k === 'gif') return 'image/gif'
if (k === 'png') return 'image/png'
if (k === 'webp') return 'image/webp'
if (k === 'avif') return 'image/avif'
if (k === 'heic') return 'image/heic'
}
return s
}
/** First hex event id on an `e` / `E` tag (reactions, reposts, replies). */ /** First hex event id on an `e` / `E` tag (reactions, reposts, replies). */
export function getFirstHexEventIdFromETags(tags: string[][]): string | undefined { export function getFirstHexEventIdFromETags(tags: string[][]): string | undefined {
for (const t of tags) { for (const t of tags) {
@ -160,7 +181,7 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta
} }
if (mimeType) { if (mimeType) {
imeta.m = mimeType imeta.m = normalizeImetaMimeField(mimeType)
} }
// Parse alt text // Parse alt text

131
src/lib/upload-nip94-imeta.ts

@ -84,6 +84,119 @@ function videoDimensionsFromFile(file: File): Promise<{ width: number; height: n
}) })
} }
/** Frame grab + blurhash for one video file (used for NIP-94 before/after main upload). */
export type VideoNip94PreviewResult = {
blurhash: string | null
posterJpeg: File | null
width: number
height: number
}
const VIDEO_PREVIEW_LOAD_MS = 18_000
const VIDEO_PREVIEW_SEEK_MS = 12_000
/**
* Decode one frame from a local video file: JPEG poster (for separate upload) + blurhash + dimensions.
* Never throws; returns null fields on failure (unsupported codec, timeout, etc.).
*/
export async function extractVideoNip94Preview(file: File): Promise<VideoNip94PreviewResult | null> {
if (!file.type.startsWith('video/')) return null
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
const cleanup = () => {
try {
URL.revokeObjectURL(objectUrl)
} catch {
/* ignore */
}
video.removeAttribute('src')
video.load()
}
try {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('load timeout')), VIDEO_PREVIEW_LOAD_MS)
const done = (err?: Error) => {
clearTimeout(timer)
if (err) reject(err)
else resolve()
}
video.onloadeddata = () => done()
video.onerror = () => done(new Error('video load error'))
video.src = objectUrl
})
const w = video.videoWidth
const h = video.videoHeight
if (!w || !h) return null
const dur = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0
const tseek = dur > 0 ? Math.min(Math.max(0.05, dur * 0.1), dur - 0.05) : 0.1
video.currentTime = tseek
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('seek timeout')), VIDEO_PREVIEW_SEEK_MS)
video.onseeked = () => {
clearTimeout(timer)
resolve()
}
video.onerror = () => {
clearTimeout(timer)
reject(new Error('seek error'))
}
})
const maxPosterSide = 1280
const ps = Math.min(1, maxPosterSide / Math.max(w, h))
const pw = Math.max(1, Math.round(w * ps))
const ph = Math.max(1, Math.round(h * ps))
const canvas = document.createElement('canvas')
canvas.width = pw
canvas.height = ph
const ctx = canvas.getContext('2d')
if (!ctx) return { blurhash: null, posterJpeg: null, width: w, height: h }
ctx.drawImage(video, 0, 0, pw, ph)
const maxBh = 64
const bs = Math.min(1, maxBh / Math.max(pw, ph))
const bw = Math.max(1, Math.round(pw * bs))
const bhH = Math.max(1, Math.round(ph * bs))
const bhCanvas = document.createElement('canvas')
bhCanvas.width = bw
bhCanvas.height = bhH
const bhCtx = bhCanvas.getContext('2d')
let blurhash: string | null = null
if (bhCtx) {
bhCtx.drawImage(canvas, 0, 0, bw, bhH)
try {
const { data } = bhCtx.getImageData(0, 0, bw, bhH)
blurhash = encodeBlurhash(data, bw, bhH, 4, 3)
} catch {
blurhash = null
}
}
const jpegBlob: Blob | null = await new Promise((res) =>
canvas.toBlob((b) => res(b), 'image/jpeg', 0.82)
)
const base = file.name.replace(/\.[^.]+$/, '') || 'video'
const posterJpeg = jpegBlob
? new File([jpegBlob], `${base}-poster.jpg`, { type: 'image/jpeg' })
: null
return { blurhash, posterJpeg, width: w, height: h }
} catch {
return null
} finally {
cleanup()
}
}
function shouldTryBlurhash(mime: string | undefined): boolean { function shouldTryBlurhash(mime: string | undefined): boolean {
if (!mime || !mime.startsWith('image/')) return false if (!mime || !mime.startsWith('image/')) return false
if (mime === 'image/svg+xml') return false if (mime === 'image/svg+xml') return false
@ -194,7 +307,11 @@ export function nip94PairsToImetaTag(pairs: string[][]): string[] {
/** /**
* Client-side NIP-94 fields for the uploaded bytes (post-compression) and final URL. * Client-side NIP-94 fields for the uploaded bytes (post-compression) and final URL.
*/ */
export async function buildClientNip94Pairs(file: File, url: string): Promise<string[][]> { export async function buildClientNip94Pairs(
file: File,
url: string,
videoPreview?: VideoNip94PreviewResult | null
): Promise<string[][]> {
const pairs: string[][] = [['url', url]] const pairs: string[][] = [['url', url]]
const mime = guessMimeFromFile(file) const mime = guessMimeFromFile(file)
@ -217,10 +334,20 @@ export async function buildClientNip94Pairs(file: File, url: string): Promise<st
} }
} }
} else if (mime?.startsWith('video/')) { } else if (mime?.startsWith('video/')) {
const dim = await videoDimensionsFromFile(file) let vp = videoPreview
if (!vp) {
vp = await extractVideoNip94Preview(file)
}
const dim =
vp && vp.width > 0 && vp.height > 0
? { width: vp.width, height: vp.height }
: await videoDimensionsFromFile(file)
if (dim) { if (dim) {
pairs.push(['dim', `${dim.width}x${dim.height}`]) pairs.push(['dim', `${dim.width}x${dim.height}`])
} }
if (vp?.blurhash) {
pairs.push(['blurhash', vp.blurhash])
}
} }
return pairs return pairs

32
src/services/media-upload.service.ts

@ -1,8 +1,10 @@
/** Compression runs entirely in-app before upload (`compress-upload-media`). */ /** Compression runs entirely in-app before upload (`compress-upload-media`). */
import { compressMediaForUpload } from '@/lib/compress-upload-media' import { compressMediaForUpload } from '@/lib/compress-upload-media'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
import { import {
buildClientNip94Pairs, buildClientNip94Pairs,
extractVideoNip94Preview,
mergeNip94Pairs, mergeNip94Pairs,
nip94PairsToImetaTag nip94PairsToImetaTag
} from '@/lib/upload-nip94-imeta' } from '@/lib/upload-nip94-imeta'
@ -87,14 +89,32 @@ class MediaUploadService {
// ignore // ignore
} }
let result: { url: string; tags: string[][] } const videoPreviewPromise =
if (this.serviceConfig.type === 'nip96') { toUpload.type.startsWith('video/') ? extractVideoNip94Preview(toUpload) : Promise.resolve(null)
result = await this.uploadByNip96(this.serviceConfig.service, toUpload, options)
} else { const uploadPromise =
result = await this.uploadByBlossom(toUpload, options) this.serviceConfig.type === 'nip96'
? this.uploadByNip96(this.serviceConfig.service, toUpload, options)
: this.uploadByBlossom(toUpload, options)
const [videoPreview, result] = await Promise.all([videoPreviewPromise, uploadPromise])
const clientPairs = await buildClientNip94Pairs(toUpload, result.url, videoPreview)
if (videoPreview?.posterJpeg) {
try {
const posterResult =
this.serviceConfig.type === 'nip96'
? await this.uploadByNip96(this.serviceConfig.service, videoPreview.posterJpeg, options)
: await this.uploadByBlossom(videoPreview.posterJpeg, options)
clientPairs.push(['image', posterResult.url], ['thumb', posterResult.url])
} catch (e) {
logger.warn('Video poster frame upload failed; imeta may omit image/thumb', {
error: String(e)
})
}
} }
const clientPairs = await buildClientNip94Pairs(toUpload, result.url)
const mergedTags = mergeNip94Pairs(clientPairs, result.tags) const mergedTags = mergeNip94Pairs(clientPairs, result.tags)
this.imetaTagMap.set(result.url, nip94PairsToImetaTag(mergedTags)) this.imetaTagMap.set(result.url, nip94PairsToImetaTag(mergedTags))
return { url: result.url, tags: mergedTags } return { url: result.url, tags: mergedTags }

Loading…
Cancel
Save