feat: add media upload progress bar (#479)
This commit is contained in:
@@ -5,6 +5,11 @@ import { z } from 'zod'
|
||||
import client from './client.service'
|
||||
import storage from './local-storage.service'
|
||||
|
||||
type UploadOptions = {
|
||||
onProgress?: (progressPercent: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
class MediaUploadService {
|
||||
static instance: MediaUploadService
|
||||
|
||||
@@ -23,12 +28,12 @@ class MediaUploadService {
|
||||
this.serviceConfig = config
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
async upload(file: File, options?: UploadOptions) {
|
||||
let result: { url: string; tags: string[][] }
|
||||
if (this.serviceConfig.type === 'nip96') {
|
||||
result = await this.uploadByNip96(this.serviceConfig.service, file)
|
||||
result = await this.uploadByNip96(this.serviceConfig.service, file, options)
|
||||
} else {
|
||||
result = await this.uploadByBlossom(file)
|
||||
result = await this.uploadByBlossom(file, options)
|
||||
}
|
||||
|
||||
if (result.tags.length > 0) {
|
||||
@@ -37,7 +42,7 @@ class MediaUploadService {
|
||||
return result
|
||||
}
|
||||
|
||||
private async uploadByBlossom(file: File) {
|
||||
private async uploadByBlossom(file: File, options?: UploadOptions) {
|
||||
const pubkey = client.pubkey
|
||||
const signer = async (draft: TDraftEvent) => {
|
||||
if (!client.signer) {
|
||||
@@ -49,6 +54,34 @@ class MediaUploadService {
|
||||
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)
|
||||
if (servers.length === 0) {
|
||||
throw new Error('No Blossom services available')
|
||||
@@ -61,6 +94,9 @@ class MediaUploadService {
|
||||
|
||||
// first upload blob to main server
|
||||
const blob = await BlossomClient.uploadBlob(mainServer, file, { auth })
|
||||
// Main upload finished
|
||||
stopPseudoProgress()
|
||||
options?.onProgress?.(80)
|
||||
|
||||
if (mirrorServers.length > 0) {
|
||||
await Promise.allSettled(
|
||||
@@ -74,10 +110,11 @@ class MediaUploadService {
|
||||
tags = parseResult.data
|
||||
}
|
||||
|
||||
options?.onProgress?.(100)
|
||||
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)
|
||||
if (!uploadUrl) {
|
||||
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
||||
@@ -100,26 +137,58 @@ class MediaUploadService {
|
||||
formData.append('file', file)
|
||||
|
||||
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file')
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: auth
|
||||
|
||||
// Use XMLHttpRequest for upload progress support
|
||||
const result = await new Promise<{ url: string; tags: string[][] }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
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) {
|
||||
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')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getImetaTagByUrl(url: string) {
|
||||
|
||||
Reference in New Issue
Block a user