From d4753e742c132fdb60367e9094a7795451683c22 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 6 Apr 2026 18:21:28 +0200 Subject: [PATCH] bug-fixes --- src/components/Image/index.tsx | 51 ++++++++-- src/components/PostEditor/Uploader.tsx | 12 ++- src/components/UserAvatar/index.tsx | 5 +- src/lib/compress-image.ts | 97 +++++++++++++++++++ src/lib/nostr-build.ts | 50 ++++++++++ .../secondary/ProfileEditorPage/index.tsx | 32 ++---- src/services/media-upload.service.ts | 8 +- 7 files changed, 216 insertions(+), 39 deletions(-) create mode 100644 src/lib/compress-image.ts create mode 100644 src/lib/nostr-build.ts diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index a184a209..538e90bc 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -30,6 +30,7 @@ export default function Image({ hideIfError = false, errorPlaceholder = , style: wrapperStyleProp, + holdUntilClick = false, ...props }: HTMLAttributes & { classNames?: { @@ -40,10 +41,20 @@ export default function Image({ alt?: string hideIfError?: boolean errorPlaceholder?: React.ReactNode + /** + * When true AND a blurHash is available, the full image is NOT loaded until + * the user clicks. The blurhash canvas is shown as a bandwidth-saving + * placeholder. Clicking triggers loading (and will open the image link if + * the normal click handler does so). + */ + holdUntilClick?: boolean }) { const { t } = useTranslation() const urlOk = !!url?.trim() - const [isLoading, setIsLoading] = useState(urlOk) + // When holdUntilClick is active we start in the "held" state. + const shouldHold = holdUntilClick && !!blurHash + const [revealed, setRevealed] = useState(!shouldHold) + const [isLoading, setIsLoading] = useState(urlOk && revealed) const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) const [hasError, setHasError] = useState(!urlOk) const [imageUrl, setImageUrl] = useState(url) @@ -66,7 +77,8 @@ export default function Image({ useEffect(() => { setImageUrl(url) - setIsLoading(true) + setRevealed(!shouldHold) + setIsLoading(!!url?.trim() && !shouldHold) setHasError(false) setDisplaySkeleton(true) setFallbackIndex(0) @@ -76,11 +88,13 @@ export default function Image({ setHasError(true) setDisplaySkeleton(false) } + // shouldHold is derived from props — intentionally not in deps to avoid reset loops + // eslint-disable-next-line react-hooks/exhaustive-deps }, [url]) useEffect(() => { clearLoadWatch() - if (badSrc || !url?.trim()) return + if (badSrc || !url?.trim() || !revealed) return loadWatchRef.current = window.setTimeout(() => { loadWatchRef.current = null setIsLoading(false) @@ -88,7 +102,7 @@ export default function Image({ setHasError(true) }, IMAGE_LOAD_TIMEOUT_MS) return clearLoadWatch - }, [imageUrl, badSrc, url]) + }, [imageUrl, badSrc, url, revealed]) if (hideIfError && showErrorState) return null @@ -118,10 +132,17 @@ export default function Image({ ? { ...reserveStyle, ...wrapperStyleProp } : undefined + const handleReveal = () => { + if (revealed) return + setRevealed(true) + setIsLoading(true) + } + return ( {displaySkeleton && !showErrorState && ( @@ -131,7 +152,7 @@ export default function Image({ blurHash={blurHash} className={cn( 'absolute inset-0 transition-opacity duration-500 rounded-lg', - isLoading ? 'opacity-100' : 'opacity-0' + isLoading || !revealed ? 'opacity-100' : 'opacity-0' )} /> ) : ( @@ -142,9 +163,17 @@ export default function Image({ )} /> )} + {/* "Tap to view" overlay when held on blurhash */} + {!revealed && ( + + + {t('Tap to load image')} + + + )} )} - {!showErrorState && ( + {!showErrorState && revealed && ( {finalAlt} )} {showErrorState && ( -
so this block is inline-safe when Image is placed + // inside a

by MarkdownArticle (avoids validateDOMNesting violations). + {errorPlaceholder} -

{t('This image could not be loaded.')}

+ {t('This image could not be loaded.')} {badSrc && !hasError ? ( -

{t('Invalid or unsupported image address.')}

+ {t('Invalid or unsupported image address.')} ) : null} {openLinkHref ? ( ) : null} -
+ )} ) diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx index 8add86e6..5629d4e1 100644 --- a/src/components/PostEditor/Uploader.tsx +++ b/src/components/PostEditor/Uploader.tsx @@ -10,7 +10,8 @@ export default function Uploader({ onUploadEnd, onProgress, className, - accept = 'image/*' + accept = 'image/*', + maxFileSizeMb }: { children: React.ReactNode onUploadSuccess: (result: { url: string; tags: string[][]; file?: File }) => void @@ -19,6 +20,8 @@ export default function Uploader({ onProgress?: (file: File, progress: number) => void className?: string accept?: string + /** Reject files whose size (before compression) exceeds this limit and show a toast. */ + maxFileSizeMb?: number }) { const fileInputRef = useRef(null) @@ -34,6 +37,13 @@ export default function Uploader({ } for (const file of event.target.files) { + if (maxFileSizeMb !== undefined && file.size > maxFileSizeMb * 1024 * 1024) { + toast.error( + `"${file.name}" is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is ${maxFileSizeMb} MB.` + ) + onUploadEnd?.(file) + continue + } try { const abortController = abortControllerMap.get(file) const result = await mediaUpload.upload(file, { diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 54431472..6ff0cf0d 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -1,5 +1,6 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' +import { toNostrBuildThumbUrl } from '@/lib/nostr-build' import { generateImageByPubkey, userIdToPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' @@ -24,7 +25,9 @@ function useDeferRemoteProfileAvatar( const remoteHttp = useMemo(() => { const a = profileAvatar?.trim() if (!a || !isHttpOrHttpsUrl(a)) return '' - return a + // Always use the nostr.build thumbnail route for profile pictures — it's + // typically < 50 KB regardless of the original file size. + return toNostrBuildThumbUrl(a) }, [profileAvatar]) const nonHttpAvatar = useMemo(() => { diff --git a/src/lib/compress-image.ts b/src/lib/compress-image.ts new file mode 100644 index 00000000..b99597d4 --- /dev/null +++ b/src/lib/compress-image.ts @@ -0,0 +1,97 @@ +/** + * Client-side image compression via the Canvas API. + * + * Called before every media upload to reduce bandwidth and server storage costs. + * GIFs are returned unchanged (canvas flattens animation to a single frame). + * Non-image files are returned unchanged. + */ + +/** Longest edge cap before re-encoding. */ +const MAX_DIMENSION_PX = 2048 +/** Try WebP at this quality first — typically 30-50 % smaller than JPEG at same perceptual quality. */ +const WEBP_QUALITY = 0.85 +/** Starting JPEG quality; stepped down by 0.1 until the file fits. */ +const JPEG_QUALITY_START = 0.82 +/** Never go below this quality during progressive reduction. */ +const JPEG_QUALITY_MIN = 0.35 + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number +): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, type, quality)) +} + +/** + * Compress `file` so the result is at most `targetMaxBytes`. + * + * Strategy: + * 1. Down-scale to MAX_DIMENSION_PX on the longest edge. + * 2. Encode as WebP at WEBP_QUALITY — usually the winner. + * 3. If still too big, fall back to JPEG with progressive quality reduction. + * 4. If nothing fits, return the best (smallest) result even if still over limit, + * unless it is bigger than the original — in which case return the original. + */ +export async function compressImage(file: File, targetMaxBytes: number): Promise { + if (!file.type.startsWith('image/')) return file + if (file.type === 'image/gif') return file // canvas strips animation + if (file.type === 'image/svg+xml') return file + if (file.size <= targetMaxBytes) return file + + let bitmap: ImageBitmap + try { + bitmap = await createImageBitmap(file) + } catch { + return file + } + + let { width, height } = bitmap + if (width > MAX_DIMENSION_PX || height > MAX_DIMENSION_PX) { + const scale = Math.min(MAX_DIMENSION_PX / width, MAX_DIMENSION_PX / height) + width = Math.round(width * scale) + height = Math.round(height * scale) + } + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) { + bitmap.close() + return file + } + ctx.drawImage(bitmap, 0, 0, width, height) + bitmap.close() + + const baseName = file.name.replace(/\.[^.]+$/, '') + + // Try WebP first + const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY) + if (webpBlob && webpBlob.size <= targetMaxBytes) { + return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' }) + } + + // Progressive JPEG quality reduction + let bestBlob: Blob | null = webpBlob + for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { + const blob = await canvasToBlob(canvas, 'image/jpeg', q) + if (!blob) continue + if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob + if (blob.size <= targetMaxBytes) { + return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) + } + } + + // Return best effort result if it's at least smaller than the original + if (bestBlob && bestBlob.size < file.size) { + const isWebp = bestBlob.type === 'image/webp' + return new File( + [bestBlob], + `${baseName}${isWebp ? '.webp' : '.jpg'}`, + { type: bestBlob.type } + ) + } + + return file +} diff --git a/src/lib/nostr-build.ts b/src/lib/nostr-build.ts new file mode 100644 index 00000000..03790a8f --- /dev/null +++ b/src/lib/nostr-build.ts @@ -0,0 +1,50 @@ +/** + * Utilities for nostr.build CDN URLs. + * + * nostr.build generates a lightweight thumbnail at /thumb/ for every + * uploaded image. Thumbnails are typically < 50 KB regardless of the original + * file size — a huge bandwidth win for profile pictures and feed previews. + */ + +/** Returns true when a URL is hosted on any nostr.build domain. */ +export function isNostrBuildUrl(url: string): boolean { + const u = (url ?? '').trim() + if (!u) return false + try { + return new URL(u).hostname.endsWith('nostr.build') + } catch { + return false + } +} + +/** Returns true when the URL is on nostr.build but does NOT yet use the /thumb/ path. */ +export function canUseNostrBuildThumb(url: string): boolean { + const u = (url ?? '').trim() + if (!u) return false + try { + const parsed = new URL(u) + if (!parsed.hostname.endsWith('nostr.build')) return false + const p = parsed.pathname + return p !== '/thumb' && !p.startsWith('/thumb/') + } catch { + return false + } +} + +/** + * Returns the nostr.build thumbnail URL for `url`, inserting `/thumb` before the + * filename path segment. Returns the original URL unchanged if it is not on + * nostr.build, already uses /thumb/, or cannot be parsed. + */ +export function toNostrBuildThumbUrl(url: string): string { + const u = (url ?? '').trim() + if (!canUseNostrBuildThumb(u)) return u + try { + const parsed = new URL(u) + const p = parsed.pathname || '/' + parsed.pathname = '/thumb' + (p.startsWith('/') ? p : `/${p}`) + return parsed.toString() + } catch { + return u + } +} diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 0e7aa216..889ee988 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -32,6 +32,7 @@ import { SelectTrigger, SelectValue } from '@/components/ui/select' +import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import type { Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' @@ -456,6 +457,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { onUploadStart={() => setUploadingAvatar(true)} onUploadEnd={() => setUploadingAvatar(false)} className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full" + maxFileSizeMb={2} > @@ -935,32 +937,12 @@ function buildTagListFromEvent(event: Event | null): string[][] { return result } -/** Returns true when a nostr.build URL can gain a /thumb/ prefix. */ -function canInsertNostrBuildThumb(url: string): boolean { - const u = url.trim() - if (!u) return false - try { - const parsed = new URL(u) - if (!parsed.hostname.endsWith('nostr.build')) return false - const p = parsed.pathname - return p !== '/thumb' && !p.startsWith('/thumb/') - } catch { - return false - } -} - -/** Inserts /thumb/ into a nostr.build URL path, or returns null if not applicable. */ +// nostr.build thumb helpers are provided by @/lib/nostr-build. +// Thin local aliases keep the JSX call-sites readable. +const canInsertNostrBuildThumb = canUseNostrBuildThumb function insertNostrBuildThumbUrl(url: string): string | null { - const u = url.trim() - if (!canInsertNostrBuildThumb(u)) return null - try { - const parsed = new URL(u) - const p = parsed.pathname || '/' - parsed.pathname = '/thumb' + (p.startsWith('/') ? p : `/${p}`) - return parsed.toString() - } catch { - return null - } + if (!canUseNostrBuildThumb(url)) return null + return toNostrBuildThumbUrl(url) } // ─── Sub-components ─────────────────────────────────────────────────────────── diff --git a/src/services/media-upload.service.ts b/src/services/media-upload.service.ts index f520adfb..3e46e9c8 100644 --- a/src/services/media-upload.service.ts +++ b/src/services/media-upload.service.ts @@ -1,3 +1,4 @@ +import { compressImage } from '@/lib/compress-image' import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { simplifyUrl } from '@/lib/url' import { TDraftEvent, TMediaUploadServiceConfig } from '@/types' @@ -32,11 +33,14 @@ class MediaUploadService { } async upload(file: File, options?: UploadOptions) { + // Compress images before upload: target ≤ 4 MB, down-scale to 2048 px max edge. + const toUpload = await compressImage(file, 4 * 1024 * 1024) + let result: { url: string; tags: string[][] } if (this.serviceConfig.type === 'nip96') { - result = await this.uploadByNip96(this.serviceConfig.service, file, options) + result = await this.uploadByNip96(this.serviceConfig.service, toUpload, options) } else { - result = await this.uploadByBlossom(file, options) + result = await this.uploadByBlossom(toUpload, options) } if (result.tags.length > 0) {