Browse Source

bug-fixes and expansion of the media

imwald
Silberengel 3 weeks ago
parent
commit
e5e85f033f
  1. 11
      src/components/ImageGallery/index.tsx
  2. 13
      src/components/ImageWithLightbox/index.tsx
  3. 4
      src/components/MediaPlayer/index.tsx
  4. 2
      src/components/NormalFeed/index.tsx
  5. 16
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  6. 16
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  7. 38
      src/components/PostEditor/PostContent.tsx
  8. 4
      src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts
  9. 8
      src/components/PostEditor/PostTextarea/index.tsx
  10. 14
      src/components/PostEditor/Uploader.tsx
  11. 18
      src/components/ProfileBanner/index.tsx
  12. 1
      src/i18n/locales/ar.ts
  13. 1
      src/i18n/locales/de.ts
  14. 1
      src/i18n/locales/en.ts
  15. 1
      src/i18n/locales/es.ts
  16. 1
      src/i18n/locales/fa.ts
  17. 1
      src/i18n/locales/fr.ts
  18. 1
      src/i18n/locales/hi.ts
  19. 1
      src/i18n/locales/it.ts
  20. 1
      src/i18n/locales/ja.ts
  21. 1
      src/i18n/locales/ko.ts
  22. 1
      src/i18n/locales/pl.ts
  23. 1
      src/i18n/locales/pt-BR.ts
  24. 1
      src/i18n/locales/pt-PT.ts
  25. 1
      src/i18n/locales/ru.ts
  26. 1
      src/i18n/locales/th.ts
  27. 1
      src/i18n/locales/zh.ts
  28. 41
      src/lib/compress-image.ts
  29. 73
      src/lib/compress-upload-media.ts
  30. 83
      src/lib/lightbox-slides.ts
  31. 70
      src/lib/upload-nip94-imeta.test.ts
  32. 225
      src/lib/upload-nip94-imeta.ts
  33. 36
      src/lib/url.ts
  34. 41
      src/pages/secondary/ProfileEditorPage/index.tsx
  35. 65
      src/services/media-upload.service.ts

11
src/components/ImageGallery/index.tsx

@ -1,5 +1,4 @@
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { preferBlossomPrimalDisplayUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -7,8 +6,10 @@ 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, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Captions from 'yet-another-react-lightbox/plugins/captions' import Captions from 'yet-another-react-lightbox/plugins/captions'
import Video from 'yet-another-react-lightbox/plugins/video'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import 'yet-another-react-lightbox/plugins/captions.css' import 'yet-another-react-lightbox/plugins/captions.css'
import Image from '../Image' import Image from '../Image'
@ -118,15 +119,11 @@ export default function ImageGallery({
<Lightbox <Lightbox
index={index} index={index}
slides={(() => { slides={(() => {
const slides = images.map(({ url, alt }) => ({ const slides = images.map((img) => lightboxSlideFromImeta(img))
src: preferBlossomPrimalDisplayUrl(url),
alt: alt || url,
title: alt || undefined
}))
logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides }) logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides })
return slides return slides
})()} })()}
plugins={[Zoom, Captions]} plugins={[Video, Zoom, Captions]}
open={index >= 0} open={index >= 0}
close={() => setIndex(-1)} close={() => setIndex(-1)}
controller={{ controller={{

13
src/components/ImageWithLightbox/index.tsx

@ -5,10 +5,11 @@ import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { preferBlossomPrimalDisplayUrl } from '@/lib/url' import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Captions from 'yet-another-react-lightbox/plugins/captions' import Captions from 'yet-another-react-lightbox/plugins/captions'
import Video from 'yet-another-react-lightbox/plugins/video'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import 'yet-another-react-lightbox/plugins/captions.css' import 'yet-another-react-lightbox/plugins/captions.css'
import Image from '../Image' import Image from '../Image'
@ -91,14 +92,8 @@ export default function ImageWithLightbox({
> >
<Lightbox <Lightbox
index={index} index={index}
slides={[ slides={[lightboxSlideFromImeta(image)]}
{ plugins={[Video, Zoom, Captions]}
src: preferBlossomPrimalDisplayUrl(image.url),
alt: image.alt || image.url,
title: image.alt || undefined
}
]}
plugins={[Zoom, Captions]}
open={index >= 0} open={index >= 0}
close={() => setIndex(-1)} close={() => setIndex(-1)}
controller={{ controller={{

4
src/components/MediaPlayer/index.tsx

@ -50,8 +50,8 @@ export default function MediaPlayer({
return return
} }
// Matroska is video-first for feeds; avoids waiting on metadata probe (codec support still browser-dependent). // Matroska / Ogg Theora: treat as video first (codec support is browser-dependent).
if (extension === 'mkv') { if (extension === 'mkv' || extension === 'ogv') {
setMediaType('video') setMediaType('video')
return return
} }

2
src/components/NormalFeed/index.tsx

@ -127,7 +127,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
{ value: 'posts', label: 'Notes' }, { value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' } { value: 'postsAndReplies', label: 'Replies' }
] ]
if (isMainFeed) base.push({ value: 'media', label: 'Media' }) if (isMainFeed) base.push({ value: 'media', label: 'Gallery' })
return base return base
}, },
[isMainFeed] [isMainFeed]

16
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -11,15 +11,16 @@ import {
isMedia, isMedia,
isVideo, isVideo,
isAudio, isAudio,
isWebsocketUrl, isWebsocketUrl
preferBlossomPrimalDisplayUrl
} from '@/lib/url' } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { createRoot, Root } from 'react-dom/client' import { createRoot, Root } from 'react-dom/client'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Video from 'yet-another-react-lightbox/plugins/video'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import EmbeddedCitation from '@/components/EmbeddedCitation' import EmbeddedCitation from '@/components/EmbeddedCitation'
@ -505,14 +506,14 @@ export default function AsciidocArticle({
// Get all images for gallery (deduplicated) // Get all images for gallery (deduplicated)
const allImages = useMemo(() => { const allImages = useMemo(() => {
const seenUrls = new Set<string>() const seenUrls = new Set<string>()
const images: Array<{ url: string; alt?: string }> = [] const images: Array<{ url: string; alt?: string; m?: string; image?: string }> = []
// Add images from extractedMedia // Add images from extractedMedia
extractedMedia.images.forEach(img => { extractedMedia.images.forEach(img => {
const cleaned = cleanUrl(img.url) const cleaned = cleanUrl(img.url)
if (cleaned && !seenUrls.has(cleaned)) { if (cleaned && !seenUrls.has(cleaned)) {
seenUrls.add(cleaned) seenUrls.add(cleaned)
images.push({ url: img.url, alt: img.alt }) images.push({ url: img.url, alt: img.alt, m: img.m, image: img.image })
} }
}) })
@ -2131,11 +2132,8 @@ export default function AsciidocArticle({
> >
<Lightbox <Lightbox
index={lightboxIndex} index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({ slides={allImages.map((img) => lightboxSlideFromImeta(img))}
src: preferBlossomPrimalDisplayUrl(url), plugins={[Video, Zoom]}
alt: alt || url
}))}
plugins={[Zoom]}
open={lightboxIndex >= 0} open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)} close={() => setLightboxIndex(-1)}
controller={{ controller={{

16
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -16,8 +16,7 @@ import {
isAudio, isAudio,
isWebsocketUrl, isWebsocketUrl,
isPseudoNostrHttpsUrl, isPseudoNostrHttpsUrl,
isSafeMediaUrl, isSafeMediaUrl
preferBlossomPrimalDisplayUrl
} from '@/lib/url' } from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
@ -31,7 +30,9 @@ 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, useRef } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Video from 'yet-another-react-lightbox/plugins/video'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import CalendarEventContent from '@/components/CalendarEventContent' import CalendarEventContent from '@/components/CalendarEventContent'
import { EmbeddedNote, EmbeddedMention, HttpNostrAwareUrl } from '@/components/Embedded' import { EmbeddedNote, EmbeddedMention, HttpNostrAwareUrl } from '@/components/Embedded'
@ -4544,14 +4545,14 @@ export default function MarkdownArticle({
// Get all images for gallery (deduplicated) // Get all images for gallery (deduplicated)
const allImages = useMemo(() => { const allImages = useMemo(() => {
const seenUrls = new Set<string>() const seenUrls = new Set<string>()
const images: Array<{ url: string; alt?: string }> = [] const images: Array<Pick<TImetaInfo, 'url' | 'alt' | 'm' | 'image'>> = []
// Add images from extractedMedia // Add images from extractedMedia
extractedMedia.images.forEach(img => { extractedMedia.images.forEach(img => {
const cleaned = cleanUrl(img.url) const cleaned = cleanUrl(img.url)
if (cleaned && !seenUrls.has(cleaned)) { if (cleaned && !seenUrls.has(cleaned)) {
seenUrls.add(cleaned) seenUrls.add(cleaned)
images.push({ url: img.url, alt: img.alt }) images.push({ url: img.url, alt: img.alt, m: img.m, image: img.image })
} }
}) })
@ -5093,11 +5094,8 @@ export default function MarkdownArticle({
> >
<Lightbox <Lightbox
index={lightboxIndex} index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({ slides={allImages.map((img) => lightboxSlideFromImeta(img))}
src: preferBlossomPrimalDisplayUrl(url), plugins={[Video, Zoom]}
alt: alt || url
}))}
plugins={[Zoom]}
open={lightboxOpen} open={lightboxOpen}
close={() => setLightboxOpen(false)} close={() => setLightboxOpen(false)}
on={{ on={{

38
src/components/PostEditor/PostContent.tsx

@ -73,6 +73,7 @@ import {
Laugh Laugh
} from 'lucide-react' } from 'lucide-react'
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
import { nip94PairsToImetaTag } from '@/lib/upload-nip94-imeta'
import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
@ -1492,7 +1493,20 @@ export default function PostContent({
const handleUploadCompressPhase = useCallback((file: File, phase: 'compressing' | 'uploading') => { const handleUploadCompressPhase = useCallback((file: File, phase: 'compressing' | 'uploading') => {
setUploadProgresses((prev) => setUploadProgresses((prev) =>
prev.map((row) => (row.file === file ? { ...row, phase } : row)) prev.map((row) =>
row.file === file
? { ...row, phase, progress: phase === 'uploading' ? 0 : row.progress }
: row
)
)
}, [])
const handleUploadCompressProgress = useCallback((file: File, percent: number) => {
const p = Math.max(0, Math.min(100, Math.round(percent)))
setUploadProgresses((prev) =>
prev.map((row) =>
row.file === file && row.phase === 'compressing' ? { ...row, progress: p } : row
)
) )
}, []) }, [])
@ -1556,9 +1570,7 @@ export default function PostContent({
const handleUploadProgress = (file: File, progress: number) => { const handleUploadProgress = (file: File, progress: number) => {
setUploadProgresses((prev) => setUploadProgresses((prev) =>
prev.map((item) => prev.map((item) =>
item.file === file item.file === file ? { ...item, progress } : item
? { ...item, progress, phase: progress > 0 ? 'uploading' : item.phase }
: item
) )
) )
} }
@ -1648,8 +1660,8 @@ export default function PostContent({
let newImetaTag: string[] let newImetaTag: string[]
if (imetaTag) { if (imetaTag) {
newImetaTag = imetaTag newImetaTag = imetaTag
} else if (tags && tags.length > 0 && tags[0]) { } else if (tags && tags.length > 0) {
newImetaTag = tags[0] newImetaTag = nip94PairsToImetaTag(tags)
} else { } else {
// Create a basic imeta tag if none exists // Create a basic imeta tag if none exists
newImetaTag = ['imeta', `url ${url}`] newImetaTag = ['imeta', `url ${url}`]
@ -1692,7 +1704,7 @@ export default function PostContent({
if (imetaTag) { if (imetaTag) {
setMediaImetaTags([imetaTag]) setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) { } else if (tags && tags.length > 0) {
setMediaImetaTags(tags) setMediaImetaTags([nip94PairsToImetaTag(tags)])
} else { } else {
const basicImetaTag: string[] = ['imeta', `url ${url}`] const basicImetaTag: string[] = ['imeta', `url ${url}`]
// Update MIME type based on selected kind // Update MIME type based on selected kind
@ -1835,7 +1847,7 @@ export default function PostContent({
if (imetaTag) { if (imetaTag) {
setMediaImetaTags([imetaTag]) setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) { } else if (tags && tags.length > 0) {
setMediaImetaTags(tags) setMediaImetaTags([nip94PairsToImetaTag(tags)])
} else { } else {
const basicImetaTag: string[] = ['imeta', `url ${url}`] const basicImetaTag: string[] = ['imeta', `url ${url}`]
// For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/* // For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/*
@ -2782,6 +2794,7 @@ export default function PostContent({
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onUploadSuccess={handleMediaUploadSuccess} onUploadSuccess={handleMediaUploadSuccess}
onUploadCompressPhase={handleUploadCompressPhase} onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
kind={getDeterminedKind} kind={getDeterminedKind}
highlightData={isHighlight ? highlightData : undefined} highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined} pollCreateData={isPoll ? pollCreateData : undefined}
@ -3055,7 +3068,7 @@ export default function PostContent({
: t('Uploading to media server…')} : t('Uploading to media server…')}
</div> </div>
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden"> <div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
{phase === 'compressing' ? ( {phase === 'compressing' && progress <= 0 ? (
<div <div
className="h-full w-1/3 max-w-[45%] animate-pulse rounded-full bg-primary motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-60" className="h-full w-1/3 max-w-[45%] animate-pulse rounded-full bg-primary motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-60"
aria-hidden aria-hidden
@ -3063,7 +3076,9 @@ export default function PostContent({
) : ( ) : (
<div <div
className="h-full bg-primary transition-[width] duration-200 ease-out" className="h-full bg-primary transition-[width] duration-200 ease-out"
style={{ width: `${progress}%` }} style={{
width: `${phase === 'compressing' ? Math.max(2, progress) : progress}%`
}}
/> />
)} )}
</div> </div>
@ -3110,6 +3125,7 @@ export default function PostContent({
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase} onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska" accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska"
className="sr-only" className="sr-only"
> >
@ -3126,6 +3142,7 @@ export default function PostContent({
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase} onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="audio/*,.mka,audio/x-matroska" accept="audio/*,.mka,audio/x-matroska"
> >
<Button <Button
@ -3145,6 +3162,7 @@ export default function PostContent({
onUploadEnd={handleUploadEnd} onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress} onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase} onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="image/*" accept="image/*"
> >
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}> <Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>

4
src/components/PostEditor/PostTextarea/ClipboardAndDropHandler.ts

@ -21,6 +21,7 @@ export interface ClipboardAndDropHandlerOptions {
onUploadProgress?: (file: File, progress: number) => void onUploadProgress?: (file: File, progress: number) => void
/** Same as `Uploader.onUploadCompressPhase` — keeps the post editor progress row in sync during local compression. */ /** Same as `Uploader.onUploadCompressPhase` — keeps the post editor progress row in sync during local compression. */
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
onUploadCompressProgress?: (file: File, percent: number) => void
} }
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
@ -142,7 +143,8 @@ async function uploadFiles(
onProgress: (p) => options.onUploadProgress?.(file, p), onProgress: (p) => options.onUploadProgress?.(file, p),
signal: abortController?.signal, signal: abortController?.signal,
onCompressStart: () => options.onUploadCompressPhase?.(file, 'compressing'), onCompressStart: () => options.onUploadCompressPhase?.(file, 'compressing'),
onCompressEnd: () => options.onUploadCompressPhase?.(file, 'uploading') onCompressEnd: () => options.onUploadCompressPhase?.(file, 'uploading'),
onCompressProgress: (p) => options.onUploadCompressProgress?.(file, p)
}) })
.then((result) => { .then((result) => {
options.onUploadEnd?.(file) options.onUploadEnd?.(file)

8
src/components/PostEditor/PostTextarea/index.tsx

@ -55,6 +55,7 @@ const PostTextarea = forwardRef<
onUploadEnd?: (file: File) => void onUploadEnd?: (file: File) => void
onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void onUploadSuccess?: (result: { url: string; tags: string[][]; file: File }) => void
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
onUploadCompressProgress?: (file: File, percent: number) => void
kind?: number kind?: number
highlightData?: HighlightData highlightData?: HighlightData
pollCreateData?: import('@/types').TPollCreateData pollCreateData?: import('@/types').TPollCreateData
@ -86,6 +87,7 @@ const PostTextarea = forwardRef<
onUploadEnd, onUploadEnd,
onUploadSuccess, onUploadSuccess,
onUploadCompressPhase, onUploadCompressPhase,
onUploadCompressProgress,
kind = 1, kind = 1,
highlightData, highlightData,
pollCreateData, pollCreateData,
@ -104,6 +106,8 @@ const PostTextarea = forwardRef<
onUploadSuccessRef.current = onUploadSuccess onUploadSuccessRef.current = onUploadSuccess
const onUploadCompressPhaseRef = useRef(onUploadCompressPhase) const onUploadCompressPhaseRef = useRef(onUploadCompressPhase)
onUploadCompressPhaseRef.current = onUploadCompressPhase onUploadCompressPhaseRef.current = onUploadCompressPhase
const onUploadCompressProgressRef = useRef(onUploadCompressProgress)
onUploadCompressProgressRef.current = onUploadCompressProgress
const [activeTab, setActiveTab] = useState('preview') const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('') const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false) const [isLoadingJson, setIsLoadingJson] = useState(false)
@ -170,7 +174,9 @@ const PostTextarea = forwardRef<
onUploadProgress: (file, p) => onUploadProgress?.(file, p), onUploadProgress: (file, p) => onUploadProgress?.(file, p),
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result), onUploadSuccess: (result) => onUploadSuccessRef.current?.(result),
onUploadCompressPhase: (file, phase) => onUploadCompressPhase: (file, phase) =>
onUploadCompressPhaseRef.current?.(file, phase) onUploadCompressPhaseRef.current?.(file, phase),
onUploadCompressProgress: (file, pct) =>
onUploadCompressProgressRef.current?.(file, pct)
}) })
], ],
editorProps: { editorProps: {

14
src/components/PostEditor/Uploader.tsx

@ -10,9 +10,11 @@ export default function Uploader({
onUploadEnd, onUploadEnd,
onProgress, onProgress,
onUploadCompressPhase, onUploadCompressPhase,
onUploadCompressProgress,
className, className,
accept = 'image/*', accept = 'image/*',
maxFileSizeMb maxFileSizeMb,
maxCompressedSizeMb
}: { }: {
children: React.ReactNode children: React.ReactNode
onUploadSuccess: (result: { url: string; tags: string[][]; file?: File }) => void onUploadSuccess: (result: { url: string; tags: string[][]; file?: File }) => void
@ -21,10 +23,14 @@ export default function Uploader({
onProgress?: (file: File, progress: number) => void onProgress?: (file: File, progress: number) => void
/** After local compression (before network upload). */ /** After local compression (before network upload). */
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
/** 0–100 during local compression only. */
onUploadCompressProgress?: (file: File, percent: number) => void
className?: string className?: string
accept?: string accept?: string
/** Reject files whose size (before compression) exceeds this limit and show a toast. */ /** Reject files whose original size exceeds this limit (before compression). */
maxFileSizeMb?: number maxFileSizeMb?: number
/** Reject when compressed size exceeds this limit (after local encode, before upload). */
maxCompressedSizeMb?: number
}) { }) {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@ -53,7 +59,9 @@ export default function Uploader({
onProgress: (p) => onProgress?.(file, p), onProgress: (p) => onProgress?.(file, p),
signal: abortController?.signal, signal: abortController?.signal,
onCompressStart: () => onUploadCompressPhase?.(file, 'compressing'), onCompressStart: () => onUploadCompressPhase?.(file, 'compressing'),
onCompressEnd: () => onUploadCompressPhase?.(file, 'uploading') onCompressEnd: () => onUploadCompressPhase?.(file, 'uploading'),
onCompressProgress: (p) => onUploadCompressProgress?.(file, p),
maxCompressedSizeMb
}) })
onUploadSuccess({ ...result, file }) onUploadSuccess({ ...result, file })
onUploadEnd?.(file) onUploadEnd?.(file)

18
src/components/ProfileBanner/index.tsx

@ -1,4 +1,5 @@
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { isVideo } from '@/lib/url'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -23,6 +24,23 @@ export default function ProfileBanner({
} }
}, [defaultBanner, banner]) }, [defaultBanner, banner])
if (isVideo(bannerUrl)) {
return (
<div className={cn('overflow-hidden rounded-none', className)}>
<video
src={bannerUrl}
className="h-full w-full object-cover object-center"
autoPlay
muted
loop
playsInline
aria-label={`${pubkey} banner`}
onError={() => setBannerUrl(defaultBanner)}
/>
</div>
)
}
return ( return (
<Image <Image
image={{ url: bannerUrl, pubkey }} image={{ url: bannerUrl, pubkey }}

1
src/i18n/locales/ar.ts

@ -296,6 +296,7 @@ export default {
'Display replies': 'عرض الردود', 'Display replies': 'عرض الردود',
Notes: 'الملاحظات', Notes: 'الملاحظات',
Replies: 'الردود', Replies: 'الردود',
Gallery: 'معرض',
Notifications: 'الإشعارات', Notifications: 'الإشعارات',
'no more notifications': 'لا توجد إشعارات إضافية', 'no more notifications': 'لا توجد إشعارات إضافية',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/de.ts

@ -309,6 +309,7 @@ export default {
'Display replies': 'Antworten anzeigen', 'Display replies': 'Antworten anzeigen',
Notes: 'Notizen', Notes: 'Notizen',
Replies: 'Antworten', Replies: 'Antworten',
Gallery: 'Galerie',
Notifications: 'Benachr.', Notifications: 'Benachr.',
'no more notifications': 'keine weiteren Benachrichtigungen', 'no more notifications': 'keine weiteren Benachrichtigungen',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/en.ts

@ -302,6 +302,7 @@ export default {
'Display replies': 'Display replies', 'Display replies': 'Display replies',
Notes: 'Notes', Notes: 'Notes',
Replies: 'Replies', Replies: 'Replies',
Gallery: 'Gallery',
Notifications: 'Notifications', Notifications: 'Notifications',
'no more notifications': 'no more notifications', 'no more notifications': 'no more notifications',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/es.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Mostrar respuestas', 'Display replies': 'Mostrar respuestas',
Notes: 'Notas', Notes: 'Notas',
Replies: 'Respuestas', Replies: 'Respuestas',
Gallery: 'Galería',
Notifications: 'Notificaciones', Notifications: 'Notificaciones',
'no more notifications': 'no hay más notificaciones', 'no more notifications': 'no hay más notificaciones',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/fa.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'نمایش پاسخها', 'Display replies': 'نمایش پاسخها',
Notes: 'یادداشتها', Notes: 'یادداشتها',
Replies: 'پاسخها', Replies: 'پاسخها',
Gallery: 'گالری',
Notifications: 'اعلانها', Notifications: 'اعلانها',
'no more notifications': 'اعلان بیشتری وجود ندارد', 'no more notifications': 'اعلان بیشتری وجود ندارد',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/fr.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Afficher les réponses', 'Display replies': 'Afficher les réponses',
Notes: 'Notes', Notes: 'Notes',
Replies: 'Réponses', Replies: 'Réponses',
Gallery: 'Galerie',
Notifications: 'Notifications', Notifications: 'Notifications',
'no more notifications': 'plus de notifications', 'no more notifications': 'plus de notifications',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/hi.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'उततर परदरित कर', 'Display replies': 'उततर परदरित कर',
Notes: 'नस', Notes: 'नस',
Replies: 'उततर', Replies: 'उततर',
Gallery: 'गलर',
Notifications: 'सचन', Notifications: 'सचन',
'no more notifications': 'कई और सचन नह', 'no more notifications': 'कई और सचन नह',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/it.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Visualizza repliche', 'Display replies': 'Visualizza repliche',
Notes: 'Note', Notes: 'Note',
Replies: 'Repliche', Replies: 'Repliche',
Gallery: 'Galleria',
Notifications: 'Notifiche', Notifications: 'Notifiche',
'no more notifications': 'niente più notifiche', 'no more notifications': 'niente più notifiche',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/ja.ts

@ -297,6 +297,7 @@ export default {
'Display replies': '返信を表示', 'Display replies': '返信を表示',
Notes: 'ノート', Notes: 'ノート',
Replies: '返信', Replies: '返信',
Gallery: 'ギャラリー',
Notifications: '通知', Notifications: '通知',
'no more notifications': 'これ以上通知はありません', 'no more notifications': 'これ以上通知はありません',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/ko.ts

@ -297,6 +297,7 @@ export default {
'Display replies': '답글 표시', 'Display replies': '답글 표시',
Notes: '노트', Notes: '노트',
Replies: '답글', Replies: '답글',
Gallery: '갤러리',
Notifications: '알림', Notifications: '알림',
'no more notifications': '더 이상 알림 없음', 'no more notifications': '더 이상 알림 없음',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/pl.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Wyświetl komentarze', 'Display replies': 'Wyświetl komentarze',
Notes: 'Wpisy', Notes: 'Wpisy',
Replies: 'Komentarze', Replies: 'Komentarze',
Gallery: 'Galeria',
Notifications: 'Powiadomienia', Notifications: 'Powiadomienia',
'no more notifications': 'Koniec powiadomień', 'no more notifications': 'Koniec powiadomień',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/pt-BR.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Exibir respostas', 'Display replies': 'Exibir respostas',
Notes: 'Notas', Notes: 'Notas',
Replies: 'Respostas', Replies: 'Respostas',
Gallery: 'Galeria',
Notifications: 'Notificações', Notifications: 'Notificações',
'no more notifications': 'não há mais notificações', 'no more notifications': 'não há mais notificações',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/pt-PT.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Exibir respostas', 'Display replies': 'Exibir respostas',
Notes: 'Notas', Notes: 'Notas',
Replies: 'Respostas', Replies: 'Respostas',
Gallery: 'Galeria',
Notifications: 'Notificações', Notifications: 'Notificações',
'no more notifications': 'não há mais notificações', 'no more notifications': 'não há mais notificações',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/ru.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'Показать ответы', 'Display replies': 'Показать ответы',
Notes: 'Заметки', Notes: 'Заметки',
Replies: 'Ответы', Replies: 'Ответы',
Gallery: 'Галерея',
Notifications: 'Уведомления', Notifications: 'Уведомления',
'no more notifications': 'нет новых уведомлений', 'no more notifications': 'нет новых уведомлений',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/th.ts

@ -297,6 +297,7 @@ export default {
'Display replies': 'แสดงการตอบกลบ', 'Display replies': 'แสดงการตอบกลบ',
Notes: 'โนต', Notes: 'โนต',
Replies: 'การตอบกลบ', Replies: 'การตอบกลบ',
Gallery: 'แกลเลอร',
Notifications: 'การแจงเตอน', Notifications: 'การแจงเตอน',
'no more notifications': 'ไมการแจงเตอนเพมเตม', 'no more notifications': 'ไมการแจงเตอนเพมเตม',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

1
src/i18n/locales/zh.ts

@ -296,6 +296,7 @@ export default {
'Display replies': '显示回复', 'Display replies': '显示回复',
Notes: '笔记', Notes: '笔记',
Replies: '回复', Replies: '回复',
Gallery: '画廊',
Notifications: '通知', Notifications: '通知',
'no more notifications': '到底了', 'no more notifications': '到底了',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.':

41
src/lib/compress-image.ts

@ -35,15 +35,29 @@ function canvasToBlob(
* 4. If nothing fits, return the best (smallest) result even if still over limit, * 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. * unless it is bigger than the original in which case return the original.
*/ */
export async function compressImage(file: File, targetMaxBytes: number): Promise<File> { export async function compressImage(
file: File,
targetMaxBytes: number,
onProgress?: (percent: number) => void
): Promise<File> {
const report = (p: number) => onProgress?.(Math.max(0, Math.min(100, Math.round(p))))
if (!file.type.startsWith('image/')) return file if (!file.type.startsWith('image/')) return file
if (file.type === 'image/gif') return file // canvas strips animation if (file.type === 'image/gif') {
if (file.type === 'image/svg+xml') return file report(100)
return file // canvas strips animation
}
if (file.type === 'image/svg+xml') {
report(100)
return file
}
report(5)
let bitmap: ImageBitmap let bitmap: ImageBitmap
try { try {
bitmap = await createImageBitmap(file) bitmap = await createImageBitmap(file)
} catch { } catch {
report(100)
return file return file
} }
@ -60,30 +74,46 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) { if (!ctx) {
bitmap.close() bitmap.close()
report(100)
return file return file
} }
ctx.drawImage(bitmap, 0, 0, width, height) ctx.drawImage(bitmap, 0, 0, width, height)
bitmap.close() bitmap.close()
report(22)
const baseName = file.name.replace(/\.[^.]+$/, '') const baseName = file.name.replace(/\.[^.]+$/, '')
// Try WebP first (always prefer a smaller or cap-compliant WebP) // Try WebP first (always prefer a smaller or cap-compliant WebP)
const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY) const webpBlob = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY)
report(38)
if (webpBlob && webpBlob.size <= targetMaxBytes) { if (webpBlob && webpBlob.size <= targetMaxBytes) {
report(100)
return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' }) return new File([webpBlob], `${baseName}.webp`, { type: 'image/webp' })
} }
// Progressive JPEG quality reduction // Progressive JPEG quality reduction
let bestBlob: Blob | null = webpBlob let bestBlob: Blob | null = webpBlob
let step = 0
const jpegSteps = Math.max(
1,
Math.ceil((JPEG_QUALITY_START - JPEG_QUALITY_MIN) / 0.1) + 1
)
for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { 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) const blob = await canvasToBlob(canvas, 'image/jpeg', q)
if (!blob) continue if (!blob) continue
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob
if (blob.size <= targetMaxBytes) { if (blob.size <= targetMaxBytes) {
report(100)
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' })
} }
step++
if (step % 2 === 0) {
report(40 + Math.min(35, (step / jpegSteps) * 35))
}
} }
report(72)
// If still over budget, shrink canvas further and retry WebP / JPEG // If still over budget, shrink canvas further and retry WebP / JPEG
if (bestBlob && bestBlob.size > targetMaxBytes && (width > 640 || height > 640)) { if (bestBlob && bestBlob.size > targetMaxBytes && (width > 640 || height > 640)) {
const factor = 0.72 const factor = 0.72
@ -94,11 +124,13 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
canvas.height = h2 canvas.height = h2
ctx.drawImage(snap, 0, 0, w2, h2) ctx.drawImage(snap, 0, 0, w2, h2)
snap.close() snap.close()
report(78)
const smallWebp = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY - 0.08) const smallWebp = await canvasToBlob(canvas, 'image/webp', WEBP_QUALITY - 0.08)
if (smallWebp && smallWebp.size < (bestBlob?.size ?? Infinity)) { if (smallWebp && smallWebp.size < (bestBlob?.size ?? Infinity)) {
bestBlob = smallWebp bestBlob = smallWebp
} }
if (smallWebp && smallWebp.size <= targetMaxBytes) { if (smallWebp && smallWebp.size <= targetMaxBytes) {
report(100)
return new File([smallWebp], `${baseName}.webp`, { type: 'image/webp' }) return new File([smallWebp], `${baseName}.webp`, { type: 'image/webp' })
} }
for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) { for (let q = JPEG_QUALITY_START; q >= JPEG_QUALITY_MIN; q = Math.round((q - 0.1) * 10) / 10) {
@ -106,6 +138,7 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
if (!blob) continue if (!blob) continue
if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob if (!bestBlob || blob.size < bestBlob.size) bestBlob = blob
if (blob.size <= targetMaxBytes) { if (blob.size <= targetMaxBytes) {
report(100)
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }) return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' })
} }
} }
@ -114,6 +147,7 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
// Return best effort if smaller than original // Return best effort if smaller than original
if (bestBlob && bestBlob.size < file.size) { if (bestBlob && bestBlob.size < file.size) {
const isWebp = bestBlob.type === 'image/webp' const isWebp = bestBlob.type === 'image/webp'
report(100)
return new File( return new File(
[bestBlob], [bestBlob],
`${baseName}${isWebp ? '.webp' : '.jpg'}`, `${baseName}${isWebp ? '.webp' : '.jpg'}`,
@ -121,5 +155,6 @@ export async function compressImage(file: File, targetMaxBytes: number): Promise
) )
} }
report(100)
return file return file
} }

73
src/lib/compress-upload-media.ts

@ -72,11 +72,25 @@ function fileLooksLikeMatroskaAudio(file: File): boolean {
return /\.mka$/i.test(file.name) || file.type === 'audio/x-matroska' return /\.mka$/i.test(file.name) || file.type === 'audio/x-matroska'
} }
async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<File> { async function compressAudioToMp3(
file: File,
signal?: AbortSignal,
onProgress?: (percent: number) => void
): Promise<File> {
if (!file.type.startsWith('audio/') && !fileLooksLikeMatroskaAudio(file)) return file if (!file.type.startsWith('audio/') && !fileLooksLikeMatroskaAudio(file)) return file
let lastP = -1
const bump = (p: number) => {
const n = Math.max(0, Math.min(100, Math.round(p)))
if (n > lastP) {
lastP = n
onProgress?.(n)
}
}
const ctx = new AudioContext() const ctx = new AudioContext()
try { try {
bump(4)
const ab = await file.arrayBuffer() const ab = await file.arrayBuffer()
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
@ -84,11 +98,16 @@ async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<Fil
try { try {
audioBuffer = await ctx.decodeAudioData(ab.slice(0)) audioBuffer = await ctx.decodeAudioData(ab.slice(0))
} catch { } catch {
bump(100)
return file return file
} }
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (audioBuffer.duration <= 0 || !Number.isFinite(audioBuffer.duration)) return file if (audioBuffer.duration <= 0 || !Number.isFinite(audioBuffer.duration)) {
bump(100)
return file
}
bump(18)
const length = Math.ceil(audioBuffer.duration * AUDIO_TARGET_SAMPLE_RATE) const length = Math.ceil(audioBuffer.duration * AUDIO_TARGET_SAMPLE_RATE)
const offline = new OfflineAudioContext(1, length, AUDIO_TARGET_SAMPLE_RATE) const offline = new OfflineAudioContext(1, length, AUDIO_TARGET_SAMPLE_RATE)
const monoSrc = offline.createBuffer(1, audioBuffer.length, audioBuffer.sampleRate) const monoSrc = offline.createBuffer(1, audioBuffer.length, audioBuffer.sampleRate)
@ -112,6 +131,7 @@ async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<Fil
const rendered = await offline.startRendering() const rendered = await offline.startRendering()
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
bump(32)
const pcm = float32ToInt16(rendered.getChannelData(0)) const pcm = float32ToInt16(rendered.getChannelData(0))
const { Mp3Encoder } = await import('lamejs') const { Mp3Encoder } = await import('lamejs')
const enc = new Mp3Encoder(1, AUDIO_TARGET_SAMPLE_RATE, AUDIO_MP3_KBPS) const enc = new Mp3Encoder(1, AUDIO_TARGET_SAMPLE_RATE, AUDIO_MP3_KBPS)
@ -119,6 +139,9 @@ async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<Fil
for (let i = 0; i < pcm.length; i += MP3_FRAME_SAMPLES) { for (let i = 0; i < pcm.length; i += MP3_FRAME_SAMPLES) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (i % (MP3_FRAME_SAMPLES * 80) === 0) {
bump(34 + Math.floor(62 * (i / Math.max(1, pcm.length))))
}
if (i % (MP3_FRAME_SAMPLES * 200) === 0) { if (i % (MP3_FRAME_SAMPLES * 200) === 0) {
await new Promise((r) => setTimeout(r, 0)) await new Promise((r) => setTimeout(r, 0))
} }
@ -135,12 +158,18 @@ async function compressAudioToMp3(file: File, signal?: AbortSignal): Promise<Fil
chunks.push(new Uint8Array(tail.buffer.slice(tail.byteOffset, tail.byteOffset + tail.byteLength)) as BlobPart) chunks.push(new Uint8Array(tail.buffer.slice(tail.byteOffset, tail.byteOffset + tail.byteLength)) as BlobPart)
} }
bump(96)
const blob = new Blob(chunks, { type: 'audio/mpeg' }) const blob = new Blob(chunks, { type: 'audio/mpeg' })
if (blob.size === 0 || blob.size >= file.size * 0.97) return file if (blob.size === 0 || blob.size >= file.size * 0.97) {
bump(100)
return file
}
const base = file.name.replace(/\.[^.]+$/, '') || 'audio' const base = file.name.replace(/\.[^.]+$/, '') || 'audio'
bump(100)
return new File([blob], `${base}.mp3`, { type: 'audio/mpeg' }) return new File([blob], `${base}.mp3`, { type: 'audio/mpeg' })
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e if (e instanceof DOMException && e.name === 'AbortError') throw e
bump(100)
return file return file
} finally { } finally {
await ctx.close().catch(() => {}) await ctx.close().catch(() => {})
@ -222,7 +251,11 @@ function waitVideoEvent(el: HTMLVideoElement, name: keyof HTMLMediaElementEventM
}) })
} }
async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<File> { async function compressVideoToWebm(
file: File,
signal?: AbortSignal,
onProgress?: (percent: number) => void
): Promise<File> {
if (!fileLooksLikeVideo(file)) return file if (!fileLooksLikeVideo(file)) return file
const mime = pickVideoMime() const mime = pickVideoMime()
if (!mime) { if (!mime) {
@ -239,6 +272,15 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
return file return file
} }
let lastVp = -1
const bump = (p: number) => {
const n = Math.max(0, Math.min(100, Math.round(p)))
if (n > lastVp) {
lastVp = n
onProgress?.(n)
}
}
const objUrl = URL.createObjectURL(file) const objUrl = URL.createObjectURL(file)
const video = document.createElement('video') const video = document.createElement('video')
video.src = objUrl video.src = objUrl
@ -249,12 +291,14 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
try { try {
await waitVideoEvent(video, 'loadedmetadata') await waitVideoEvent(video, 'loadedmetadata')
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
bump(6)
const probeStream = captureStreamFromVideoElement(video) const probeStream = captureStreamFromVideoElement(video)
if (!probeStream) { if (!probeStream) {
uploadCompressionDiag( uploadCompressionDiag(
'video skip: video.captureStream / mozCaptureStream not available (try another browser or disable strict privacy flags)' 'video skip: video.captureStream / mozCaptureStream not available (try another browser or disable strict privacy flags)'
) )
bump(100)
return file return file
} }
probeStream.getTracks().forEach((t) => t.stop()) probeStream.getTracks().forEach((t) => t.stop())
@ -263,10 +307,12 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_VIDEO_DURATION_SEC) { if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_VIDEO_DURATION_SEC) {
uploadCompressionDiag('video skip: bad or too long duration', { duration }) uploadCompressionDiag('video skip: bad or too long duration', { duration })
logger.debug('[compress-upload] video duration skip', { duration }) logger.debug('[compress-upload] video duration skip', { duration })
bump(100)
return file return file
} }
if (videoWidth < 2 || videoHeight < 2) { if (videoWidth < 2 || videoHeight < 2) {
uploadCompressionDiag('video skip: dimensions too small', { videoWidth, videoHeight }) uploadCompressionDiag('video skip: dimensions too small', { videoWidth, videoHeight })
bump(100)
return file return file
} }
@ -397,6 +443,7 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000) const maxFrames = Math.min(Math.ceil(durationSec * 100) + 2000, 500_000)
/** Yield to the event loop so React can paint (compression is CPU-heavy). */ /** Yield to the event loop so React can paint (compression is CPU-heavy). */
const YIELD_EVERY_FRAMES = 30 const YIELD_EVERY_FRAMES = 30
const PROGRESS_EVERY_FRAMES = 6
const step = () => { const step = () => {
if (settled) return if (settled) return
@ -413,6 +460,9 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
return return
} }
frames++ frames++
if (frames === 1 || frames % PROGRESS_EVERY_FRAMES === 0) {
bump(8 + Math.floor(84 * (frames / maxFrames)))
}
if (frames > maxFrames) { if (frames > maxFrames) {
video.pause() video.pause()
finish() finish()
@ -472,6 +522,7 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
maxWidthPx: maxW, maxWidthPx: maxW,
outName: out.name outName: out.name
}) })
bump(100)
return out return out
} }
} }
@ -481,11 +532,13 @@ async function compressVideoToWebm(file: File, signal?: AbortSignal): Promise<Fi
mime mime
}) })
logger.debug('[compress-upload] video re-encode: all passes kept original') logger.debug('[compress-upload] video re-encode: all passes kept original')
bump(100)
return file return file
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') throw e if (e instanceof DOMException && e.name === 'AbortError') throw e
uploadCompressionDiag('video skip: encode pipeline error', { error: String(e) }) uploadCompressionDiag('video skip: encode pipeline error', { error: String(e) })
logger.debug('[compress-upload] video compress failed', { e }) logger.debug('[compress-upload] video compress failed', { e })
bump(100)
return file return file
} finally { } finally {
URL.revokeObjectURL(objUrl) URL.revokeObjectURL(objUrl)
@ -498,6 +551,8 @@ export type CompressMediaOptions = {
signal?: AbortSignal signal?: AbortSignal
/** Raster images are scaled/encoded until under this size when possible (default 2 MiB — fits typical profile `picture` limits). */ /** Raster images are scaled/encoded until under this size when possible (default 2 MiB — fits typical profile `picture` limits). */
imageTargetMaxBytes?: number imageTargetMaxBytes?: number
/** 0–100 during local compression only (not HTTP upload). */
onCompressProgress?: (percent: number) => void
} }
/** Default cap for raster image uploads (profile pics and inline media). */ /** Default cap for raster image uploads (profile pics and inline media). */
@ -515,17 +570,21 @@ export async function compressMediaForUpload(file: File, options?: CompressMedia
let branch: 'image' | 'audio' | 'video' | 'none' = 'none' let branch: 'image' | 'audio' | 'video' | 'none' = 'none'
let out: File = file let out: File = file
const onProg = options?.onCompressProgress
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
branch = 'image' branch = 'image'
out = await compressImage(file, imageTarget) out = await compressImage(file, imageTarget, onProg)
} else if (file.type.startsWith('audio/') || fileLooksLikeMatroskaAudio(file)) { } else if (file.type.startsWith('audio/') || fileLooksLikeMatroskaAudio(file)) {
branch = 'audio' branch = 'audio'
out = await compressAudioToMp3(file, signal) out = await compressAudioToMp3(file, signal, onProg)
} else if (fileLooksLikeVideo(file)) { } else if (fileLooksLikeVideo(file)) {
branch = 'video' branch = 'video'
out = await compressVideoToWebm(file, signal) out = await compressVideoToWebm(file, signal, onProg)
} }
onProg?.(100)
uploadCompressionDiag('compressMediaForUpload result', { uploadCompressionDiag('compressMediaForUpload result', {
branch, branch,
inName: file.name, inName: file.name,

83
src/lib/lightbox-slides.ts

@ -0,0 +1,83 @@
import type { Slide } from 'yet-another-react-lightbox'
import type { TImetaInfo } from '@/types'
import { isAudio, isImage, isMedia, isVideo, preferBlossomPrimalDisplayUrl } from '@/lib/url'
function sourceTypeFromPath(url: string, kind: 'video' | 'audio'): string {
const path = url.split(/[?#]/)[0].toLowerCase()
if (kind === 'audio') {
if (path.endsWith('.mka')) return 'audio/x-matroska'
if (path.endsWith('.opus')) return 'audio/opus'
if (path.endsWith('.webm')) return 'audio/webm'
if (path.endsWith('.ogg')) return 'audio/ogg'
if (path.endsWith('.m4a') || path.endsWith('.aac')) return 'audio/mp4'
if (path.endsWith('.flac')) return 'audio/flac'
if (path.endsWith('.wav')) return 'audio/wav'
return 'audio/mpeg'
}
if (path.endsWith('.webm')) return 'video/webm'
if (path.endsWith('.mkv')) return 'video/x-matroska'
if (path.endsWith('.ogv')) return 'video/ogg'
return 'video/mp4'
}
/**
* Build a Yet Another React Lightbox slide from imeta (or URL + optional MIME).
* Uses the Video plugin for video/audio URLs so the lightbox can play what we upload.
*/
export function lightboxSlideFromImeta(info: Pick<TImetaInfo, 'url' | 'alt' | 'm' | 'image'>): Slide {
const url = preferBlossomPrimalDisplayUrl(info.url)
const title = info.alt || info.url
const m = info.m?.toLowerCase()
if (m?.startsWith('image/')) {
return { src: url, alt: title, title }
}
if (m?.startsWith('video/')) {
return {
type: 'video',
title,
poster: info.image,
sources: [{ src: url, type: info.m as string }]
}
}
if (m?.startsWith('audio/')) {
return {
type: 'video',
width: 400,
height: 64,
title,
sources: [{ src: url, type: info.m as string }]
}
}
if (isVideo(url)) {
return {
type: 'video',
title,
poster: info.image,
sources: [{ src: url, type: sourceTypeFromPath(url, 'video') }]
}
}
if (isAudio(url)) {
return {
type: 'video',
width: 400,
height: 64,
title,
sources: [{ src: url, type: sourceTypeFromPath(url, 'audio') }]
}
}
if (isImage(url)) {
return { src: url, alt: title, title }
}
if (isMedia(url)) {
return {
type: 'video',
title,
poster: info.image,
sources: [{ src: url, type: 'video/mp4' }]
}
}
return { src: url, alt: title, title }
}

70
src/lib/upload-nip94-imeta.test.ts

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { mergeNip94Pairs, nip94PairsToImetaTag } from './upload-nip94-imeta'
describe('mergeNip94Pairs', () => {
it('keeps client fields when server is empty', () => {
const client = [
['url', 'https://cdn.example/a.webp'],
['m', 'image/webp'],
['x', 'abc'],
['size', '1234']
]
const merged = mergeNip94Pairs(client, [])
expect(merged).toEqual(client)
})
it('lets non-empty server values overwrite client', () => {
const client = [
['url', 'https://cdn.example/a.webp'],
['m', 'image/webp'],
['x', 'clienthash'],
['dim', '100x100'],
['blurhash', 'clientbh']
]
const server = [
['url', 'https://cdn.example/a.webp'],
['blurhash', 'serverbh'],
['dim', '200x200']
]
const merged = mergeNip94Pairs(client, server)
const map = Object.fromEntries(merged.filter(([k]) => k !== 'fallback'))
expect(map.url).toBe('https://cdn.example/a.webp')
expect(map.blurhash).toBe('serverbh')
expect(map.dim).toBe('200x200')
expect(map.x).toBe('clienthash')
expect(map.m).toBe('image/webp')
})
it('ignores empty server values so client wins', () => {
const client = [['url', 'https://x.test/u'], ['m', 'image/png'], ['x', 'deadbeef']]
const server = [['blurhash', ''], ['m', '']]
const merged = mergeNip94Pairs(client, server)
const map = Object.fromEntries(merged.filter(([k]) => k !== 'fallback'))
expect(map.m).toBe('image/png')
expect(map.blurhash).toBeUndefined()
})
it('merges fallback with server first then unique client', () => {
const client = [
['url', 'https://a'],
['fallback', 'https://mirror1'],
['fallback', 'https://mirror2']
]
const server = [['fallback', 'https://mirror1'], ['fallback', 'https://srv']]
const merged = mergeNip94Pairs(client, server)
const fbs = merged.filter(([k]) => k === 'fallback').map(([, v]) => v)
expect(fbs).toEqual(['https://mirror1', 'https://srv', 'https://mirror2'])
})
})
describe('nip94PairsToImetaTag', () => {
it('builds a single imeta row', () => {
const row = nip94PairsToImetaTag([
['url', 'https://u'],
['m', 'image/jpeg']
])
expect(row[0]).toBe('imeta')
expect(row).toContain('url https://u')
expect(row).toContain('m image/jpeg')
})
})

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

@ -0,0 +1,225 @@
import { encode as encodeBlurhash } from 'blurhash'
/** NIP-94-style `[name, value]` rows before folding into a single `imeta` tag (NIP-92). */
const EXT_TO_MIME: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.apng': 'image/apng',
'.svg': 'image/svg+xml',
'.heic': 'image/heic',
'.mp4': 'video/mp4',
'.m4v': 'video/x-m4v',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.mka': 'audio/x-matroska',
'.ogg': 'audio/ogg',
'.opus': 'audio/opus',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.aac': 'audio/aac'
}
function extFromName(name: string): string {
const i = name.lastIndexOf('.')
return i >= 0 ? name.slice(i).toLowerCase() : ''
}
export function guessMimeFromFile(file: File): string | undefined {
if (file.type && file.type.trim() !== '') {
return file.type
}
const ext = extFromName(file.name)
return EXT_TO_MIME[ext]
}
export async function sha256HexOfFile(file: File): Promise<string> {
const buf = await file.arrayBuffer()
const digest = await crypto.subtle.digest('SHA-256', buf)
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
async function imageDimensionsFromFile(file: File): Promise<{ width: number; height: number } | null> {
try {
const bmp = await createImageBitmap(file)
const width = bmp.width
const height = bmp.height
bmp.close()
if (width > 0 && height > 0) {
return { width, height }
}
} catch {
// unsupported decode / HEIC / etc.
}
return null
}
function videoDimensionsFromFile(file: File): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
const done = (dims: { width: number; height: number } | null) => {
URL.revokeObjectURL(objectUrl)
resolve(dims)
}
video.onloadedmetadata = () => {
const w = video.videoWidth
const h = video.videoHeight
done(w > 0 && h > 0 ? { width: w, height: h } : null)
}
video.onerror = () => done(null)
video.src = objectUrl
})
}
function shouldTryBlurhash(mime: string | undefined): boolean {
if (!mime || !mime.startsWith('image/')) return false
if (mime === 'image/svg+xml') return false
return true
}
async function blurhashFromRasterFile(file: File): Promise<string | null> {
try {
const bmp = await createImageBitmap(file)
const maxSide = 64
let w = bmp.width
let h = bmp.height
const scale = Math.min(1, maxSide / Math.max(w, h, 1))
w = Math.max(1, Math.round(w * scale))
h = Math.max(1, Math.round(h * scale))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) {
bmp.close()
return null
}
ctx.drawImage(bmp, 0, 0, w, h)
bmp.close()
const { data } = ctx.getImageData(0, 0, w, h)
return encodeBlurhash(data, w, h, 4, 3)
} catch {
return null
}
}
function isNonEmpty(v: string | undefined): boolean {
return v != null && v.trim() !== ''
}
/**
* Merge NIP-94 tag pairs: start from client, then let the server overwrite any key
* it supplies with a non-empty value. `fallback` rows are merged with server first.
*/
export function mergeNip94Pairs(client: string[][], server: string[][]): string[][] {
const mergedSingle: Record<string, string> = {}
const clientFallback: string[] = []
for (const row of client) {
if (!row?.length || !row[0]) continue
const k = row[0]
const v = row[1] ?? ''
if (k === 'fallback') {
if (isNonEmpty(v)) clientFallback.push(v)
} else {
mergedSingle[k] = v
}
}
const serverFallback: string[] = []
for (const row of server) {
if (!row?.length || !row[0]) continue
const k = row[0]
const v = row[1] ?? ''
if (k === 'fallback') {
if (isNonEmpty(v)) serverFallback.push(v)
} else if (isNonEmpty(v)) {
mergedSingle[k] = v
}
}
const preferredOrder = ['url', 'm', 'x', 'ox', 'size', 'dim', 'blurhash', 'thumb', 'image', 'alt']
const used = new Set<string>()
const out: string[][] = []
for (const key of preferredOrder) {
const val = mergedSingle[key]
if (isNonEmpty(val)) {
out.push([key, val!])
used.add(key)
}
}
for (const key of Object.keys(mergedSingle)) {
if (used.has(key)) continue
const val = mergedSingle[key]
if (isNonEmpty(val)) {
out.push([key, val!])
}
}
const seenFb = new Set<string>()
for (const f of serverFallback) {
out.push(['fallback', f])
seenFb.add(f)
}
for (const f of clientFallback) {
if (!seenFb.has(f)) {
out.push(['fallback', f])
seenFb.add(f)
}
}
return out
}
export function nip94PairsToImetaTag(pairs: string[][]): string[] {
const body = pairs.map(([k, v]) => `${k} ${v}`)
return ['imeta', ...body]
}
/**
* Client-side NIP-94 fields for the uploaded bytes (post-compression) and final URL.
*/
export async function buildClientNip94Pairs(file: File, url: string): Promise<string[][]> {
const pairs: string[][] = [['url', url]]
const mime = guessMimeFromFile(file)
if (mime) {
pairs.push(['m', mime])
}
pairs.push(['x', await sha256HexOfFile(file)])
pairs.push(['size', String(file.size)])
if (mime?.startsWith('image/')) {
const dim = await imageDimensionsFromFile(file)
if (dim) {
pairs.push(['dim', `${dim.width}x${dim.height}`])
}
if (shouldTryBlurhash(mime)) {
const bh = await blurhashFromRasterFile(file)
if (bh) {
pairs.push(['blurhash', bh])
}
}
} else if (mime?.startsWith('video/')) {
const dim = await videoDimensionsFromFile(file)
if (dim) {
pairs.push(['dim', `${dim.width}x${dim.height}`])
}
}
return pairs
}

36
src/lib/url.ts

@ -209,7 +209,17 @@ export function isLocalNetworkUrl(urlString: string): boolean {
export function isImage(url: string) { export function isImage(url: string) {
try { try {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg'] const imageExtensions = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.avif',
'.apng',
'.heic',
'.svg'
]
const parsedUrl = new URL(url) const parsedUrl = new URL(url)
// Check pathname for image extensions // Check pathname for image extensions
@ -251,7 +261,10 @@ export function isImage(url: string) {
// Check for image-related query parameters (common in image proxy services) // Check for image-related query parameters (common in image proxy services)
// e.g., output=webp, format=webp, etc. // e.g., output=webp, format=webp, etc.
const outputParam = parsedUrl.searchParams.get('output') || parsedUrl.searchParams.get('format') const outputParam = parsedUrl.searchParams.get('output') || parsedUrl.searchParams.get('format')
if (outputParam && ['webp', 'jpg', 'jpeg', 'png', 'gif'].includes(outputParam.toLowerCase())) { if (
outputParam &&
['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'svg', 'apng'].includes(outputParam.toLowerCase())
) {
return true return true
} }
@ -277,6 +290,7 @@ export function isMedia(url: string) {
'.mp4', '.mp4',
'.webm', '.webm',
'.ogg', '.ogg',
'.ogv',
'.mov', '.mov',
'.mkv', '.mkv',
'.mka', '.mka',
@ -296,6 +310,8 @@ export function isMedia(url: string) {
export function isAudio(url: string) { export function isAudio(url: string) {
try { try {
const path = new URL(url).pathname.toLowerCase()
// Matroska: .mka is audio-only; .mkv is video — do not treat mkv as audio by extension.
const audioExtensions = [ const audioExtensions = [
'.mp3', '.mp3',
'.wav', '.wav',
@ -304,12 +320,12 @@ export function isAudio(url: string) {
'.m4a', '.m4a',
'.opus', '.opus',
'.wma', '.wma',
'.ogg', // ogg can be audio '.mka',
'.webm', // webm can be audio (when uploaded via microphone button) '.ogg',
'.mp4', // mp4 can be audio (m4a files) '.webm',
'.mka' '.mp4'
] ]
return audioExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) return audioExtensions.some((ext) => path.endsWith(ext))
} catch { } catch {
return false return false
} }
@ -317,6 +333,7 @@ export function isAudio(url: string) {
export function isVideo(url: string) { export function isVideo(url: string) {
try { try {
const path = new URL(url).pathname.toLowerCase()
const videoExtensions = [ const videoExtensions = [
'.mp4', '.mp4',
'.webm', '.webm',
@ -326,9 +343,10 @@ export function isVideo(url: string) {
'.flv', '.flv',
'.mkv', '.mkv',
'.m4v', '.m4v',
'.3gp' '.3gp',
'.ogv'
] ]
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) return videoExtensions.some((ext) => path.endsWith(ext))
} catch { } catch {
return false return false
} }

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

@ -73,6 +73,41 @@ const TAG_LABELS: Record<string, string> = {
birthday: 'Birthday', birthday: 'Birthday',
} }
/**
* Profile banner & avatar file picker: all images and videos the OS/browser exposes as
* `image/*` or `video/*`, plus common extensions when `File.type` is empty (e.g. Linux).
* Banner and avatar sizes are limited after compression (`maxCompressedSizeMb` on each uploader).
*/
const PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT = [
'image/*',
'video/*',
'.mkv',
'.m4v',
'.mov',
'.webm',
'.ogv',
'.avi',
'.mpeg',
'.mpg',
'.mp4',
'.3gp',
'.wmv',
'.flv',
'.heic',
'.heif',
'.avif',
'.apng',
'.svg',
'.webp',
'.gif',
'.png',
'.jpg',
'.jpeg',
'.bmp',
'.ico',
'video/x-matroska'
].join(',')
const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pop } = useSecondaryPage() const { pop } = useSecondaryPage()
@ -443,6 +478,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onUploadStart={() => setUploadingBanner(true)} onUploadStart={() => setUploadingBanner(true)}
onUploadEnd={() => setUploadingBanner(false)} onUploadEnd={() => setUploadingBanner(false)}
className="w-full relative cursor-pointer" className="w-full relative cursor-pointer"
accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT}
maxCompressedSizeMb={5}
> >
<ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" /> <ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" />
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center"> <div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center">
@ -458,8 +495,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onUploadStart={() => setUploadingAvatar(true)} onUploadStart={() => setUploadingAvatar(true)}
onUploadEnd={() => setUploadingAvatar(false)} 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" className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full"
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-matroska,.mkv" accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT}
maxFileSizeMb={2} maxCompressedSizeMb={2}
> >
<div className="w-full h-full overflow-hidden rounded-full bg-muted"> <div className="w-full h-full overflow-hidden rounded-full bg-muted">
{isVideo(avatar) ? ( {isVideo(avatar) ? (

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

@ -1,6 +1,11 @@
/** 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 {
buildClientNip94Pairs,
mergeNip94Pairs,
nip94PairsToImetaTag
} from '@/lib/upload-nip94-imeta'
import { simplifyUrl } from '@/lib/url' import { simplifyUrl } from '@/lib/url'
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types' import { TDraftEvent, TMediaUploadServiceConfig } from '@/types'
import { BlossomClient } from 'blossom-client-sdk' import { BlossomClient } from 'blossom-client-sdk'
@ -15,6 +20,13 @@ type UploadOptions = {
onCompressStart?: () => void onCompressStart?: () => void
/** Fires after compression finishes (or throws), before the HTTP upload. */ /** Fires after compression finishes (or throws), before the HTTP upload. */
onCompressEnd?: () => void onCompressEnd?: () => void
/** 0–100 during local compression (encode), not network upload. */
onCompressProgress?: (percent: number) => void
/**
* Reject when the compressed file size (bytes sent to the server) exceeds this limit.
* Checked after `compressMediaForUpload`, before HTTP upload.
*/
maxCompressedSizeMb?: number
} }
export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted' export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted'
@ -41,11 +53,24 @@ class MediaUploadService {
options?.onCompressStart?.() options?.onCompressStart?.()
let toUpload: File let toUpload: File
try { try {
toUpload = await compressMediaForUpload(file, { signal: options?.signal }) toUpload = await compressMediaForUpload(file, {
signal: options?.signal,
onCompressProgress: options?.onCompressProgress
})
} finally { } finally {
options?.onCompressEnd?.() options?.onCompressEnd?.()
} }
if (
options?.maxCompressedSizeMb !== undefined &&
toUpload.size > options.maxCompressedSizeMb * 1024 * 1024
) {
const mb = (toUpload.size / (1024 * 1024)).toFixed(1)
throw new Error(
`After compression the file is ${mb} MB; maximum allowed is ${options.maxCompressedSizeMb} MB.`
)
}
try { try {
const diag = const diag =
import.meta.env.DEV || import.meta.env.DEV ||
@ -69,10 +94,10 @@ class MediaUploadService {
result = await this.uploadByBlossom(toUpload, options) result = await this.uploadByBlossom(toUpload, options)
} }
if (result.tags.length > 0) { const clientPairs = await buildClientNip94Pairs(toUpload, result.url)
this.imetaTagMap.set(result.url, ['imeta', ...result.tags.map(([n, v]) => `${n} ${v}`)]) const mergedTags = mergeNip94Pairs(clientPairs, result.tags)
} this.imetaTagMap.set(result.url, nip94PairsToImetaTag(mergedTags))
return result return { url: result.url, tags: mergedTags }
} }
private async uploadByBlossom(file: File, options?: UploadOptions) { private async uploadByBlossom(file: File, options?: UploadOptions) {
@ -187,7 +212,24 @@ class MediaUploadService {
xhr.responseType = 'json' xhr.responseType = 'json'
xhr.setRequestHeader('Authorization', auth) xhr.setRequestHeader('Authorization', auth)
let pseudoTimer: number | undefined
let pseudo = 0
const stopPseudo = () => {
if (pseudoTimer !== undefined) {
window.clearInterval(pseudoTimer)
pseudoTimer = undefined
}
}
const startPseudo = () => {
if (pseudoTimer !== undefined) return
pseudoTimer = window.setInterval(() => {
pseudo = Math.min(pseudo + 2, 92)
options?.onProgress?.(pseudo)
}, 220)
}
const handleAbort = () => { const handleAbort = () => {
stopPseudo()
try { try {
xhr.abort() xhr.abort()
} catch { } catch {
@ -202,20 +244,29 @@ class MediaUploadService {
options.signal.addEventListener('abort', handleAbort, { once: true }) options.signal.addEventListener('abort', handleAbort, { once: true })
} }
options?.onProgress?.(0)
startPseudo()
xhr.upload.onprogress = (event) => { xhr.upload.onprogress = (event) => {
if (event.lengthComputable) { if (event.lengthComputable && event.total > 0) {
stopPseudo()
const percent = Math.round((event.loaded / event.total) * 100) const percent = Math.round((event.loaded / event.total) * 100)
options?.onProgress?.(percent) options?.onProgress?.(percent)
} }
} }
xhr.onerror = () => reject(new Error('Network error')) xhr.onerror = () => {
stopPseudo()
reject(new Error('Network error'))
}
xhr.onload = () => { xhr.onload = () => {
stopPseudo()
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
const data = xhr.response const data = xhr.response
try { try {
const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? []) const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1] const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1]
if (url) { if (url) {
options?.onProgress?.(100)
resolve({ url, tags }) resolve({ url, tags })
} else { } else {
reject(new Error('No url found')) reject(new Error('No url found'))

Loading…
Cancel
Save