Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
d4753e742c
  1. 51
      src/components/Image/index.tsx
  2. 12
      src/components/PostEditor/Uploader.tsx
  3. 5
      src/components/UserAvatar/index.tsx
  4. 97
      src/lib/compress-image.ts
  5. 50
      src/lib/nostr-build.ts
  6. 32
      src/pages/secondary/ProfileEditorPage/index.tsx
  7. 8
      src/services/media-upload.service.ts

51
src/components/Image/index.tsx

@ -30,6 +30,7 @@ export default function Image({ @@ -30,6 +30,7 @@ export default function Image({
hideIfError = false,
errorPlaceholder = <ImageOff />,
style: wrapperStyleProp,
holdUntilClick = false,
...props
}: HTMLAttributes<HTMLSpanElement> & {
classNames?: {
@ -40,10 +41,20 @@ export default function Image({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -118,10 +132,17 @@ export default function Image({
? { ...reserveStyle, ...wrapperStyleProp }
: undefined
const handleReveal = () => {
if (revealed) return
setRevealed(true)
setIsLoading(true)
}
return (
<span
className={cn('relative overflow-hidden block w-full', classNames.wrapper)}
style={mergedWrapperStyle}
onClick={!revealed ? handleReveal : undefined}
{...props}
>
{displaySkeleton && !showErrorState && (
@ -131,7 +152,7 @@ export default function Image({ @@ -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({ @@ -142,9 +163,17 @@ export default function Image({
)}
/>
)}
{/* "Tap to view" overlay when held on blurhash */}
{!revealed && (
<span className="absolute inset-0 flex items-center justify-center z-20">
<span className="rounded-full bg-black/50 px-3 py-1 text-xs text-white/90 select-none pointer-events-none">
{t('Tap to load image')}
</span>
</span>
)}
</span>
)}
{!showErrorState && (
{!showErrorState && revealed && (
<img
src={imageUrl}
alt={finalAlt}
@ -165,7 +194,9 @@ export default function Image({ @@ -165,7 +194,9 @@ export default function Image({
/>
)}
{showErrorState && (
<div
// All children are <span> so this block is inline-safe when Image is placed
// inside a <p> by MarkdownArticle (avoids validateDOMNesting violations).
<span
role="alert"
className={cn(
'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',
@ -174,9 +205,9 @@ export default function Image({ @@ -174,9 +205,9 @@ export default function Image({
)}
>
<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>
<span className="text-sm leading-snug">{t('This image could not be loaded.')}</span>
{badSrc && !hasError ? (
<p className="text-xs opacity-80 break-all max-w-full">{t('Invalid or unsupported image address.')}</p>
<span className="text-xs opacity-80 break-all max-w-full block">{t('Invalid or unsupported image address.')}</span>
) : null}
{openLinkHref ? (
<a
@ -189,7 +220,7 @@ export default function Image({ @@ -189,7 +220,7 @@ export default function Image({
{t('Open image link')}
</a>
) : null}
</div>
</span>
)}
</span>
)

12
src/components/PostEditor/Uploader.tsx

@ -10,7 +10,8 @@ export default function Uploader({ @@ -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({ @@ -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<HTMLInputElement>(null)
@ -34,6 +37,13 @@ export default function Uploader({ @@ -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, {

5
src/components/UserAvatar/index.tsx

@ -1,5 +1,6 @@ @@ -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( @@ -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(() => {

97
src/lib/compress-image.ts

@ -0,0 +1,97 @@ @@ -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<Blob | null> {
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<File> {
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
}

50
src/lib/nostr-build.ts

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/**
* Utilities for nostr.build CDN URLs.
*
* nostr.build generates a lightweight thumbnail at /thumb/<filename> 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
}
}

32
src/pages/secondary/ProfileEditorPage/index.tsx

@ -32,6 +32,7 @@ import { @@ -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) => { @@ -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}
>
<Avatar className="w-full h-full">
<AvatarImage src={avatar} className="object-cover object-center" />
@ -935,32 +937,12 @@ function buildTagListFromEvent(event: Event | null): string[][] { @@ -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 ───────────────────────────────────────────────────────────

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

@ -1,3 +1,4 @@ @@ -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 { @@ -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) {

Loading…
Cancel
Save