feat: add media upload progress bar (#479)

This commit is contained in:
Taxil Kathiriya
2025-08-12 02:42:57 +01:00
committed by GitHub
parent e78e2c2078
commit 9969ab2414
5 changed files with 229 additions and 49 deletions

View File

@@ -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) {