You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
208 lines
6.3 KiB
208 lines
6.3 KiB
import { simplifyUrl } from '@/lib/url' |
|
import { TDraftEvent, TMediaUploadServiceConfig } from '@/types' |
|
import { BlossomClient } from 'blossom-client-sdk' |
|
import { z } from 'zod' |
|
import client from './client.service' |
|
import storage from './local-storage.service' |
|
|
|
type UploadOptions = { |
|
onProgress?: (progressPercent: number) => void |
|
signal?: AbortSignal |
|
} |
|
|
|
export const UPLOAD_ABORTED_ERROR_MSG = 'Upload aborted' |
|
|
|
class MediaUploadService { |
|
static instance: MediaUploadService |
|
|
|
private serviceConfig: TMediaUploadServiceConfig = storage.getMediaUploadServiceConfig() |
|
private nip96ServiceUploadUrlMap = new Map<string, string | undefined>() |
|
private imetaTagMap = new Map<string, string[]>() |
|
|
|
constructor() { |
|
if (!MediaUploadService.instance) { |
|
MediaUploadService.instance = this |
|
} |
|
return MediaUploadService.instance |
|
} |
|
|
|
setServiceConfig(config: TMediaUploadServiceConfig) { |
|
this.serviceConfig = config |
|
} |
|
|
|
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, options) |
|
} else { |
|
result = await this.uploadByBlossom(file, options) |
|
} |
|
|
|
if (result.tags.length > 0) { |
|
this.imetaTagMap.set(result.url, ['imeta', ...result.tags.map(([n, v]) => `${n} ${v}`)]) |
|
} |
|
return result |
|
} |
|
|
|
private async uploadByBlossom(file: File, options?: UploadOptions) { |
|
const pubkey = client.pubkey |
|
const signer = async (draft: TDraftEvent) => { |
|
if (!client.signer) { |
|
throw new Error('You need to be logged in to upload media') |
|
} |
|
return client.signer.signEvent(draft) |
|
} |
|
if (!pubkey) { |
|
throw new Error('You need to be logged in to upload media') |
|
} |
|
|
|
if (options?.signal?.aborted) { |
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG) |
|
} |
|
|
|
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') |
|
} |
|
const [mainServer, ...mirrorServers] = servers |
|
|
|
const auth = await BlossomClient.createUploadAuth(signer, file, { |
|
message: 'Uploading media file' |
|
}) |
|
|
|
// 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( |
|
mirrorServers.map((server) => BlossomClient.mirrorBlob(server, blob, { auth })) |
|
) |
|
} |
|
|
|
let tags: string[][] = [] |
|
const parseResult = z.array(z.array(z.string())).safeParse((blob as any).nip94 ?? []) |
|
if (parseResult.success) { |
|
tags = parseResult.data |
|
} |
|
|
|
options?.onProgress?.(100) |
|
return { url: blob.url, tags } |
|
} |
|
|
|
private async uploadByNip96(service: string, file: File, options?: UploadOptions) { |
|
if (options?.signal?.aborted) { |
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG) |
|
} |
|
let uploadUrl = this.nip96ServiceUploadUrlMap.get(service) |
|
if (!uploadUrl) { |
|
const response = await fetch(`${service}/.well-known/nostr/nip96.json`) |
|
if (!response.ok) { |
|
throw new Error( |
|
`${simplifyUrl(service)} does not work, please try another service in your settings` |
|
) |
|
} |
|
const data = await response.json() |
|
uploadUrl = data?.api_url |
|
if (!uploadUrl) { |
|
throw new Error( |
|
`${simplifyUrl(service)} does not work, please try another service in your settings` |
|
) |
|
} |
|
this.nip96ServiceUploadUrlMap.set(service, uploadUrl) |
|
} |
|
|
|
if (options?.signal?.aborted) { |
|
throw new Error(UPLOAD_ABORTED_ERROR_MSG) |
|
} |
|
const formData = new FormData() |
|
formData.append('file', file) |
|
|
|
const auth = await client.signHttpAuth(uploadUrl, 'POST', 'Uploading media file') |
|
|
|
// 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_ERROR_MSG)) |
|
} |
|
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) |
|
}) |
|
|
|
return result |
|
} |
|
|
|
getImetaTagByUrl(url: string) { |
|
return this.imetaTagMap.get(url) |
|
} |
|
} |
|
|
|
const instance = new MediaUploadService() |
|
export default instance
|
|
|