feat: add media upload progress bar (#479)
This commit is contained in:
@@ -11,7 +11,7 @@ import { useNostr } from '@/providers/NostrProvider'
|
|||||||
import { useReply } from '@/providers/ReplyProvider'
|
import { useReply } from '@/providers/ReplyProvider'
|
||||||
import postEditorCache from '@/services/post-editor-cache.service'
|
import postEditorCache from '@/services/post-editor-cache.service'
|
||||||
import { TPollCreateData } from '@/types'
|
import { TPollCreateData } from '@/types'
|
||||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile } from 'lucide-react'
|
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -41,6 +41,9 @@ export default function PostContent({
|
|||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||||
|
const [uploadFileName, setUploadFileName] = useState<string | null>(null)
|
||||||
|
const cancelRef = useRef<(() => void) | null>(null)
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||||
const [addClientTag, setAddClientTag] = useState(false)
|
const [addClientTag, setAddClientTag] = useState(false)
|
||||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||||
@@ -175,6 +178,17 @@ export default function PostContent({
|
|||||||
parentEvent={parentEvent}
|
parentEvent={parentEvent}
|
||||||
onSubmit={() => post()}
|
onSubmit={() => post()}
|
||||||
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
className={isPoll ? 'min-h-20' : 'min-h-52'}
|
||||||
|
onUploadStart={(file) => {
|
||||||
|
setUploadFileName(file.name)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}}
|
||||||
|
onUploadProgress={(p) => setUploadProgress(p)}
|
||||||
|
onUploadEnd={() => {
|
||||||
|
setUploadProgress(null)
|
||||||
|
setUploadFileName(null)
|
||||||
|
cancelRef.current = null
|
||||||
|
}}
|
||||||
|
onProvideCancel={(cancel) => (cancelRef.current = cancel)}
|
||||||
/>
|
/>
|
||||||
{isPoll && (
|
{isPoll && (
|
||||||
<PollEditor
|
<PollEditor
|
||||||
@@ -190,6 +204,29 @@ export default function PostContent({
|
|||||||
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{uploadProgress !== null && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs text-muted-foreground mb-1">
|
||||||
|
{uploadFileName ?? t('Uploading...')}
|
||||||
|
</div>
|
||||||
|
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cancelRef.current?.()}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
title={t('Cancel')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Uploader
|
<Uploader
|
||||||
@@ -199,10 +236,21 @@ export default function PostContent({
|
|||||||
onUploadingChange={(uploading) =>
|
onUploadingChange={(uploading) =>
|
||||||
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
|
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
|
||||||
}
|
}
|
||||||
|
onUploadStart={(file) => {
|
||||||
|
setUploadFileName(file.name)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}}
|
||||||
|
onUploadEnd={() => {
|
||||||
|
setUploadProgress(null)
|
||||||
|
setUploadFileName(null)
|
||||||
|
cancelRef.current = null
|
||||||
|
}}
|
||||||
|
onProgress={(p) => setUploadProgress(p)}
|
||||||
|
onProvideCancel={(cancel) => (cancelRef.current = cancel)}
|
||||||
accept="image/*,video/*,audio/*"
|
accept="image/*,video/*,audio/*"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" disabled={uploadingFiles > 0}>
|
<Button variant="ghost" size="icon" disabled={uploadingFiles > 0}>
|
||||||
{uploadingFiles > 0 ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
<ImageUp />
|
||||||
</Button>
|
</Button>
|
||||||
</Uploader>
|
</Uploader>
|
||||||
{/* I'm not sure why, but after triggering the virtual keyboard,
|
{/* I'm not sure why, but after triggering the virtual keyboard,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload from '@/services/media-upload.service'
|
||||||
import { Extension } from '@tiptap/core'
|
import { Extension } from '@tiptap/core'
|
||||||
import { EditorView } from '@tiptap/pm/view'
|
import { EditorView } from '@tiptap/pm/view'
|
||||||
|
import { Slice } from '@tiptap/pm/model'
|
||||||
import { Plugin, TextSelection } from 'prosemirror-state'
|
import { Plugin, TextSelection } from 'prosemirror-state'
|
||||||
|
|
||||||
const DRAGOVER_CLASS_LIST = [
|
const DRAGOVER_CLASS_LIST = [
|
||||||
@@ -15,6 +16,9 @@ export interface ClipboardAndDropHandlerOptions {
|
|||||||
onUploadStart?: (file: File) => void
|
onUploadStart?: (file: File) => void
|
||||||
onUploadSuccess?: (file: File, result: any) => void
|
onUploadSuccess?: (file: File, result: any) => void
|
||||||
onUploadError?: (file: File, error: any) => void
|
onUploadError?: (file: File, error: any) => void
|
||||||
|
onUploadEnd?: () => void
|
||||||
|
onUploadProgress?: (file: File, progress: number) => void
|
||||||
|
onProvideCancel?: (cancel: () => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
|
export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
|
||||||
@@ -24,7 +28,10 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
return {
|
return {
|
||||||
onUploadStart: undefined,
|
onUploadStart: undefined,
|
||||||
onUploadSuccess: undefined,
|
onUploadSuccess: undefined,
|
||||||
onUploadError: undefined
|
onUploadError: undefined,
|
||||||
|
onUploadEnd: undefined,
|
||||||
|
onUploadProgress: undefined,
|
||||||
|
onProvideCancel: undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -35,17 +42,24 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
new Plugin({
|
new Plugin({
|
||||||
props: {
|
props: {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
|
dragenter(view, event) {
|
||||||
|
event.preventDefault()
|
||||||
|
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
||||||
|
return true
|
||||||
|
},
|
||||||
dragover(view, event) {
|
dragover(view, event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
||||||
return false
|
return true
|
||||||
},
|
},
|
||||||
dragleave(view) {
|
dragleave(view) {
|
||||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||||
return false
|
return true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
drop(view, event) {
|
handleDrop(view: EditorView, event: DragEvent, _slice: Slice, _moved: boolean) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||||
|
|
||||||
const items = Array.from(event.dataTransfer?.files ?? [])
|
const items = Array.from(event.dataTransfer?.files ?? [])
|
||||||
@@ -55,9 +69,7 @@ export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerO
|
|||||||
if (!mediaFiles.length) return false
|
if (!mediaFiles.length) return false
|
||||||
|
|
||||||
uploadFile(view, mediaFiles, options)
|
uploadFile(view, mediaFiles, options)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
},
|
},
|
||||||
handlePaste(view, event) {
|
handlePaste(view, event) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
@@ -121,8 +133,14 @@ async function uploadFile(
|
|||||||
tr = tr.insert(tr.selection.from, hardBreakNode)
|
tr = tr.insert(tr.selection.from, hardBreakNode)
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
|
|
||||||
|
const abortController = new AbortController()
|
||||||
|
options.onProvideCancel?.(() => abortController.abort())
|
||||||
|
|
||||||
mediaUpload
|
mediaUpload
|
||||||
.upload(file)
|
.upload(file, {
|
||||||
|
onProgress: (p) => options.onUploadProgress?.(file, p),
|
||||||
|
signal: abortController.signal
|
||||||
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
options.onUploadSuccess?.(file, result)
|
options.onUploadSuccess?.(file, result)
|
||||||
const urlNode = view.state.schema.text(result.url)
|
const urlNode = view.state.schema.text(result.url)
|
||||||
@@ -157,6 +175,7 @@ async function uploadFile(
|
|||||||
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
||||||
view.dispatch(insertTr)
|
view.dispatch(insertTr)
|
||||||
}
|
}
|
||||||
|
options.onUploadEnd?.()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Upload failed:', error)
|
console.error('Upload failed:', error)
|
||||||
@@ -181,7 +200,7 @@ async function uploadFile(
|
|||||||
if (didReplace) {
|
if (didReplace) {
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
}
|
}
|
||||||
|
options.onUploadEnd?.()
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,23 @@ const PostTextarea = forwardRef<
|
|||||||
parentEvent?: Event
|
parentEvent?: Event
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
onUploadStart?: (file: File) => void
|
||||||
|
onUploadProgress?: (progress: number, file: File) => void
|
||||||
|
onUploadEnd?: () => void
|
||||||
|
onProvideCancel?: (cancel: () => void) => void
|
||||||
}
|
}
|
||||||
>(({ text = '', setText, defaultContent, parentEvent, onSubmit, className }, ref) => {
|
>(({
|
||||||
|
text = '',
|
||||||
|
setText,
|
||||||
|
defaultContent,
|
||||||
|
parentEvent,
|
||||||
|
onSubmit,
|
||||||
|
className,
|
||||||
|
onUploadStart,
|
||||||
|
onUploadProgress,
|
||||||
|
onUploadEnd,
|
||||||
|
onProvideCancel
|
||||||
|
}, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { setUploadingFiles } = usePostEditor()
|
const { setUploadingFiles } = usePostEditor()
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
@@ -51,9 +66,15 @@ const PostTextarea = forwardRef<
|
|||||||
suggestion
|
suggestion
|
||||||
}),
|
}),
|
||||||
ClipboardAndDropHandler.configure({
|
ClipboardAndDropHandler.configure({
|
||||||
onUploadStart: () => setUploadingFiles((prev) => prev + 1),
|
onUploadStart: (file) => {
|
||||||
|
setUploadingFiles((prev) => prev + 1)
|
||||||
|
onUploadStart?.(file)
|
||||||
|
},
|
||||||
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
|
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
|
||||||
onUploadError: () => setUploadingFiles((prev) => prev - 1)
|
onUploadError: () => setUploadingFiles((prev) => prev - 1),
|
||||||
|
onUploadEnd: () => onUploadEnd?.(),
|
||||||
|
onUploadProgress: (file, p) => onUploadProgress?.(p, file),
|
||||||
|
onProvideCancel: (cancel) => onProvideCancel?.(cancel)
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
|
|||||||
@@ -6,16 +6,25 @@ export default function Uploader({
|
|||||||
children,
|
children,
|
||||||
onUploadSuccess,
|
onUploadSuccess,
|
||||||
onUploadingChange,
|
onUploadingChange,
|
||||||
|
onUploadStart,
|
||||||
|
onUploadEnd,
|
||||||
|
onProgress,
|
||||||
|
onProvideCancel,
|
||||||
className,
|
className,
|
||||||
accept = 'image/*'
|
accept = 'image/*'
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||||
onUploadingChange?: (uploading: boolean) => void
|
onUploadingChange?: (uploading: boolean) => void
|
||||||
|
onUploadStart?: (file: File) => void
|
||||||
|
onUploadEnd?: () => void
|
||||||
|
onProgress?: (progress: number, file: File) => void
|
||||||
|
onProvideCancel?: (cancel: () => void) => void
|
||||||
className?: string
|
className?: string
|
||||||
accept?: string
|
accept?: string
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return
|
if (!event.target.files) return
|
||||||
@@ -23,15 +32,29 @@ export default function Uploader({
|
|||||||
onUploadingChange?.(true)
|
onUploadingChange?.(true)
|
||||||
try {
|
try {
|
||||||
for (const file of event.target.files) {
|
for (const file of event.target.files) {
|
||||||
const result = await mediaUpload.upload(file)
|
abortControllerRef.current = new AbortController()
|
||||||
|
const cancel = () => abortControllerRef.current?.abort()
|
||||||
|
onProvideCancel?.(cancel)
|
||||||
|
onUploadStart?.(file)
|
||||||
|
const result = await mediaUpload.upload(file, {
|
||||||
|
onProgress: (p) => onProgress?.(p, file),
|
||||||
|
signal: abortControllerRef.current.signal
|
||||||
|
})
|
||||||
onUploadSuccess(result)
|
onUploadSuccess(result)
|
||||||
|
abortControllerRef.current = null
|
||||||
|
onUploadEnd?.()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading file', error)
|
console.error('Error uploading file', error)
|
||||||
toast.error(`Failed to upload file: ${(error as Error).message}`)
|
const message = (error as Error).message
|
||||||
|
if (message !== 'Upload aborted') {
|
||||||
|
toast.error(`Failed to upload file: ${message}`)
|
||||||
|
}
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
|
abortControllerRef.current = null
|
||||||
|
onUploadEnd?.()
|
||||||
} finally {
|
} finally {
|
||||||
onUploadingChange?.(false)
|
onUploadingChange?.(false)
|
||||||
}
|
}
|
||||||
@@ -45,8 +68,8 @@ export default function Uploader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={handleUploadClick} className={className}>
|
<div className={className}>
|
||||||
{children}
|
<div onClick={handleUploadClick}>{children}</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { z } from 'zod'
|
|||||||
import client from './client.service'
|
import client from './client.service'
|
||||||
import storage from './local-storage.service'
|
import storage from './local-storage.service'
|
||||||
|
|
||||||
|
type UploadOptions = {
|
||||||
|
onProgress?: (progressPercent: number) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
class MediaUploadService {
|
class MediaUploadService {
|
||||||
static instance: MediaUploadService
|
static instance: MediaUploadService
|
||||||
|
|
||||||
@@ -23,12 +28,12 @@ class MediaUploadService {
|
|||||||
this.serviceConfig = config
|
this.serviceConfig = config
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(file: File) {
|
async upload(file: File, options?: UploadOptions) {
|
||||||
let result: { url: string; tags: string[][] }
|
let result: { url: string; tags: string[][] }
|
||||||
if (this.serviceConfig.type === 'nip96') {
|
if (this.serviceConfig.type === 'nip96') {
|
||||||
result = await this.uploadByNip96(this.serviceConfig.service, file)
|
result = await this.uploadByNip96(this.serviceConfig.service, file, options)
|
||||||
} else {
|
} else {
|
||||||
result = await this.uploadByBlossom(file)
|
result = await this.uploadByBlossom(file, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.tags.length > 0) {
|
if (result.tags.length > 0) {
|
||||||
@@ -37,7 +42,7 @@ class MediaUploadService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadByBlossom(file: File) {
|
private async uploadByBlossom(file: File, options?: UploadOptions) {
|
||||||
const pubkey = client.pubkey
|
const pubkey = client.pubkey
|
||||||
const signer = async (draft: TDraftEvent) => {
|
const signer = async (draft: TDraftEvent) => {
|
||||||
if (!client.signer) {
|
if (!client.signer) {
|
||||||
@@ -49,6 +54,34 @@ class MediaUploadService {
|
|||||||
throw new Error('You need to be logged in to upload media')
|
throw new Error('You need to be logged in to upload media')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error('Upload aborted')
|
||||||
|
}
|
||||||
|
|
||||||
|
options?.onProgress?.(0)
|
||||||
|
|
||||||
|
// Pseudo-progress: advance gradually until main upload completes
|
||||||
|
let pseudoProgress = 1
|
||||||
|
let pseudoTimer: number | undefined
|
||||||
|
const startPseudoProgress = () => {
|
||||||
|
if (pseudoTimer !== undefined) return
|
||||||
|
pseudoTimer = window.setInterval(() => {
|
||||||
|
// Cap pseudo progress to 90% until we get real completion
|
||||||
|
pseudoProgress = Math.min(pseudoProgress + 3, 90)
|
||||||
|
options?.onProgress?.(pseudoProgress)
|
||||||
|
if (pseudoProgress >= 90) {
|
||||||
|
stopPseudoProgress()
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
const stopPseudoProgress = () => {
|
||||||
|
if (pseudoTimer !== undefined) {
|
||||||
|
clearInterval(pseudoTimer)
|
||||||
|
pseudoTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startPseudoProgress()
|
||||||
|
|
||||||
const servers = await client.fetchBlossomServerList(pubkey)
|
const servers = await client.fetchBlossomServerList(pubkey)
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
throw new Error('No Blossom services available')
|
throw new Error('No Blossom services available')
|
||||||
@@ -61,6 +94,9 @@ class MediaUploadService {
|
|||||||
|
|
||||||
// first upload blob to main server
|
// first upload blob to main server
|
||||||
const blob = await BlossomClient.uploadBlob(mainServer, file, { auth })
|
const blob = await BlossomClient.uploadBlob(mainServer, file, { auth })
|
||||||
|
// Main upload finished
|
||||||
|
stopPseudoProgress()
|
||||||
|
options?.onProgress?.(80)
|
||||||
|
|
||||||
if (mirrorServers.length > 0) {
|
if (mirrorServers.length > 0) {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
@@ -74,10 +110,11 @@ class MediaUploadService {
|
|||||||
tags = parseResult.data
|
tags = parseResult.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options?.onProgress?.(100)
|
||||||
return { url: blob.url, tags }
|
return { url: blob.url, tags }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadByNip96(service: string, file: File) {
|
private async uploadByNip96(service: string, file: File, options?: UploadOptions) {
|
||||||
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
|
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service)
|
||||||
if (!uploadUrl) {
|
if (!uploadUrl) {
|
||||||
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
||||||
@@ -100,26 +137,58 @@ class MediaUploadService {
|
|||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
|
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
|
||||||
const response = await fetch(uploadUrl, {
|
|
||||||
method: 'POST',
|
// Use XMLHttpRequest for upload progress support
|
||||||
body: formData,
|
const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
|
||||||
headers: {
|
const xhr = new XMLHttpRequest()
|
||||||
Authorization: auth
|
xhr.open('POST', uploadUrl as string)
|
||||||
|
xhr.responseType = 'json'
|
||||||
|
xhr.setRequestHeader('Authorization', auth)
|
||||||
|
|
||||||
|
const handleAbort = () => {
|
||||||
|
try {
|
||||||
|
xhr.abort()
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
|
reject(new Error('Upload aborted'))
|
||||||
|
}
|
||||||
|
if (options?.signal) {
|
||||||
|
if (options.signal.aborted) {
|
||||||
|
return handleAbort()
|
||||||
|
}
|
||||||
|
options.signal.addEventListener('abort', handleAbort, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percent = Math.round((event.loaded / event.total) * 100)
|
||||||
|
options?.onProgress?.(percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = () => reject(new Error('Network error'))
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const data = xhr.response
|
||||||
|
try {
|
||||||
|
const tags = z.array(z.array(z.string())).parse(data?.nip94_event?.tags ?? [])
|
||||||
|
const url = tags.find(([tagName]: string[]) => tagName === 'url')?.[1]
|
||||||
|
if (url) {
|
||||||
|
resolve({ url, tags })
|
||||||
|
} else {
|
||||||
|
reject(new Error('No url found'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e as Error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.send(formData)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
return result
|
||||||
throw new Error(response.status.toString() + ' ' + response.statusText)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
|
||||||
const url = tags.find(([tagName]) => tagName === 'url')?.[1]
|
|
||||||
if (url) {
|
|
||||||
return { url, tags }
|
|
||||||
} else {
|
|
||||||
throw new Error('No url found')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getImetaTagByUrl(url: string) {
|
getImetaTagByUrl(url: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user