|
|
|
|
@ -5,6 +5,11 @@ import { z } from 'zod'
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 {
@@ -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 |
|
|
|
|
} |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
if (!response.ok) { |
|
|
|
|
throw new Error(response.status.toString() + ' ' + response.statusText) |
|
|
|
|
// 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 }) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
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) { |
|
|
|
|
return { url, tags } |
|
|
|
|
resolve({ url, tags }) |
|
|
|
|
} else { |
|
|
|
|
throw new Error('No url found') |
|
|
|
|
reject(new Error('No url found')) |
|
|
|
|
} |
|
|
|
|
} catch (e) { |
|
|
|
|
reject(e as Error) |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
reject(new Error(xhr.status.toString() + ' ' + xhr.statusText)) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
xhr.send(formData) |
|
|
|
|
}) |
|
|
|
|
|
|
|
|
|
return result |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
getImetaTagByUrl(url: string) { |
|
|
|
|
|