308 lines
6.8 KiB
TypeScript
308 lines
6.8 KiB
TypeScript
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
|
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
|
import { hashFile } from '@/lib/fileHash'
|
|
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
|
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
|
import type { UploadRejection, UploadRowStatus } from '@/lib/mediaUploadRows'
|
|
|
|
const MAX_CONCURRENT_UPLOADS = 2
|
|
const UPLOAD_URL = '/v1/media/'
|
|
const HTTP_CREATED = 201
|
|
const HTTP_BAD_REQUEST = 400
|
|
const HTTP_FORBIDDEN = 403
|
|
const HTTP_UNAUTHORIZED = 401
|
|
const HTTP_TOO_LARGE = 413
|
|
const HTTP_UNSUPPORTED = 415
|
|
const HTTP_RATE_LIMITED = 429
|
|
const TONE_COUNT = 4
|
|
|
|
export interface UploadRow {
|
|
key: number
|
|
file: File
|
|
name: string
|
|
size: number
|
|
status: UploadRowStatus
|
|
loaded: number
|
|
total: number
|
|
rejection: UploadRejection | null
|
|
existing: MediaItem | null
|
|
previewUrl: string | null
|
|
skipLookup: boolean
|
|
tone: number
|
|
}
|
|
|
|
interface UploadOptions {
|
|
onAdded?: () => void
|
|
}
|
|
|
|
function toneOf(name: string) {
|
|
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
|
|
}
|
|
|
|
export function oldestOf(items: MediaItem[]) {
|
|
return [...items].sort(
|
|
(first, second) =>
|
|
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
|
|
)[0]
|
|
}
|
|
|
|
function mismatchRejection(file: File): UploadRejection | null {
|
|
return extensionMatchesType(file) ? null : 'type'
|
|
}
|
|
|
|
function createPreview(file: File) {
|
|
return typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null
|
|
}
|
|
|
|
function releasePreview(row: UploadRow) {
|
|
if (row.previewUrl && typeof URL.revokeObjectURL === 'function') {
|
|
URL.revokeObjectURL(row.previewUrl)
|
|
}
|
|
}
|
|
|
|
export function useMediaUpload(options: UploadOptions = {}) {
|
|
const { request } = useApi()
|
|
const retry = useRetryAfter()
|
|
const rows = ref<UploadRow[]>([])
|
|
const slots = new Set<number>()
|
|
const requests = new Map<number, XMLHttpRequest>()
|
|
let nextKey = 1
|
|
let halted = false
|
|
|
|
const header = computed(() => describeUpload(rows.value))
|
|
const limited = computed(() => retry.active.value)
|
|
const countdown = computed(() => retry.formatted.value)
|
|
|
|
function createRow(file: File): UploadRow {
|
|
const verdict = validateImageFile(file)
|
|
const rejection = verdict.ok ? mismatchRejection(file) : verdict.kind
|
|
return {
|
|
key: nextKey++,
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
status: rejection ? 'rejected' : 'waiting',
|
|
loaded: 0,
|
|
total: file.size,
|
|
rejection,
|
|
existing: null,
|
|
previewUrl: rejection ? null : createPreview(file),
|
|
skipLookup: false,
|
|
tone: toneOf(file.name),
|
|
}
|
|
}
|
|
|
|
function find(key: number) {
|
|
return rows.value.find((row) => row.key === key)
|
|
}
|
|
|
|
function release(key: number) {
|
|
slots.delete(key)
|
|
requests.delete(key)
|
|
}
|
|
|
|
function settle(key: number) {
|
|
release(key)
|
|
pump()
|
|
}
|
|
|
|
function halt(row: UploadRow) {
|
|
row.status = 'waiting'
|
|
release(row.key)
|
|
halted = true
|
|
}
|
|
|
|
function pump() {
|
|
if (halted || limited.value) {
|
|
return
|
|
}
|
|
for (const row of rows.value) {
|
|
if (slots.size >= MAX_CONCURRENT_UPLOADS) {
|
|
return
|
|
}
|
|
if (row.status === 'waiting' && !slots.has(row.key)) {
|
|
slots.add(row.key)
|
|
void process(row)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findExisting(file: File): Promise<MediaItem | null | 'halted'> {
|
|
const hash = await hashFile(file)
|
|
if (hash === null) {
|
|
return null
|
|
}
|
|
try {
|
|
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
|
return oldestOf(response.items) ?? null
|
|
} catch (error) {
|
|
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
|
}
|
|
}
|
|
|
|
async function process(row: UploadRow) {
|
|
if (!row.skipLookup) {
|
|
const match = await findExisting(row.file)
|
|
if (!find(row.key)) {
|
|
settle(row.key)
|
|
return
|
|
}
|
|
if (match === 'halted') {
|
|
halt(row)
|
|
return
|
|
}
|
|
if (match) {
|
|
row.status = 'duplicate'
|
|
row.existing = match
|
|
settle(row.key)
|
|
return
|
|
}
|
|
}
|
|
send(row)
|
|
}
|
|
|
|
function reject(row: UploadRow, kind: UploadRejection) {
|
|
row.status = 'rejected'
|
|
row.rejection = kind
|
|
settle(row.key)
|
|
}
|
|
|
|
function pause(row: UploadRow, header: string | null) {
|
|
row.status = 'waiting'
|
|
release(row.key)
|
|
retry.start(parseRetryAfter(header))
|
|
}
|
|
|
|
function onLoaded(row: UploadRow, xhr: XMLHttpRequest) {
|
|
requests.delete(row.key)
|
|
switch (xhr.status) {
|
|
case HTTP_CREATED:
|
|
row.status = 'added'
|
|
options.onAdded?.()
|
|
settle(row.key)
|
|
return
|
|
case HTTP_BAD_REQUEST:
|
|
reject(row, 'unreadable')
|
|
return
|
|
case HTTP_TOO_LARGE:
|
|
reject(row, 'size')
|
|
return
|
|
case HTTP_UNSUPPORTED:
|
|
reject(row, 'type')
|
|
return
|
|
case HTTP_UNAUTHORIZED:
|
|
redirectToLogin()
|
|
halt(row)
|
|
return
|
|
case HTTP_FORBIDDEN:
|
|
redirectUnverified()
|
|
halt(row)
|
|
return
|
|
case HTTP_RATE_LIMITED:
|
|
pause(row, xhr.getResponseHeader('Retry-After'))
|
|
return
|
|
default:
|
|
row.status = 'failed'
|
|
settle(row.key)
|
|
}
|
|
}
|
|
|
|
function send(row: UploadRow) {
|
|
row.status = 'uploading'
|
|
row.loaded = 0
|
|
row.total = row.size
|
|
const xhr = new XMLHttpRequest()
|
|
requests.set(row.key, xhr)
|
|
xhr.open('POST', UPLOAD_URL)
|
|
xhr.withCredentials = true
|
|
xhr.upload.addEventListener('progress', (event) => {
|
|
row.loaded = event.loaded
|
|
row.total = event.total || row.size
|
|
})
|
|
xhr.addEventListener('load', () => onLoaded(row, xhr))
|
|
xhr.addEventListener('error', () => {
|
|
row.status = 'failed'
|
|
settle(row.key)
|
|
})
|
|
const form = new FormData()
|
|
form.append('file', row.file)
|
|
xhr.send(form)
|
|
}
|
|
|
|
function addFiles(files: File[]) {
|
|
if (limited.value || files.length === 0) {
|
|
return
|
|
}
|
|
rows.value.push(...files.map(createRow))
|
|
pump()
|
|
}
|
|
|
|
function remove(key: number) {
|
|
const row = find(key)
|
|
if (row) {
|
|
releasePreview(row)
|
|
}
|
|
rows.value = rows.value.filter((entry) => entry.key !== key)
|
|
}
|
|
|
|
function cancel(key: number) {
|
|
requests.get(key)?.abort()
|
|
release(key)
|
|
remove(key)
|
|
pump()
|
|
}
|
|
|
|
function cancelAll() {
|
|
for (const row of [...rows.value]) {
|
|
if (row.status === 'uploading' || row.status === 'waiting') {
|
|
requests.get(row.key)?.abort()
|
|
release(row.key)
|
|
remove(row.key)
|
|
}
|
|
}
|
|
}
|
|
|
|
function retryRow(key: number) {
|
|
const row = find(key)
|
|
if (!row || (row.status !== 'failed' && row.status !== 'duplicate')) {
|
|
return
|
|
}
|
|
row.status = 'waiting'
|
|
row.skipLookup = true
|
|
row.existing = null
|
|
pump()
|
|
}
|
|
|
|
function dismiss() {
|
|
rows.value.forEach(releasePreview)
|
|
rows.value = []
|
|
}
|
|
|
|
watch(limited, (active) => {
|
|
if (!active) {
|
|
pump()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
requests.forEach((xhr) => xhr.abort())
|
|
requests.clear()
|
|
rows.value.forEach(releasePreview)
|
|
})
|
|
|
|
return {
|
|
rows,
|
|
header,
|
|
limited,
|
|
countdown,
|
|
addFiles,
|
|
remove,
|
|
cancel,
|
|
cancelAll,
|
|
retryRow,
|
|
dismiss,
|
|
}
|
|
}
|