瀏覽代碼

Merge branch 'main'

Antoine du Hamel 1 年之前
父節點
當前提交
63b999dabd
共有 52 個文件被更改,包括 3933 次插入2372 次删除
  1. 427 0
      packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts
  2. 97 56
      packages/@uppy/aws-s3-multipart/src/MultipartUploader.ts
  3. 0 113
      packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js
  4. 141 0
      packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts
  5. 55 31
      packages/@uppy/aws-s3-multipart/src/createSignedURL.ts
  6. 0 934
      packages/@uppy/aws-s3-multipart/src/index.js
  7. 199 128
      packages/@uppy/aws-s3-multipart/src/index.test.ts
  8. 1007 0
      packages/@uppy/aws-s3-multipart/src/index.ts
  9. 28 0
      packages/@uppy/aws-s3-multipart/src/utils.ts
  10. 30 0
      packages/@uppy/aws-s3-multipart/tsconfig.build.json
  11. 26 0
      packages/@uppy/aws-s3-multipart/tsconfig.json
  12. 1 1
      packages/@uppy/companion/src/config/companion.js
  13. 4 1
      packages/@uppy/core/src/Uppy.ts
  14. 1 0
      packages/@uppy/dashboard/.npmignore
  15. 376 183
      packages/@uppy/dashboard/src/Dashboard.tsx
  16. 0 325
      packages/@uppy/dashboard/src/components/AddFiles.jsx
  17. 450 0
      packages/@uppy/dashboard/src/components/AddFiles.tsx
  18. 10 3
      packages/@uppy/dashboard/src/components/AddFilesPanel.tsx
  19. 91 57
      packages/@uppy/dashboard/src/components/Dashboard.tsx
  20. 15 4
      packages/@uppy/dashboard/src/components/EditorPanel.tsx
  21. 0 46
      packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx
  22. 54 0
      packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx
  23. 36 21
      packages/@uppy/dashboard/src/components/FileCard/index.tsx
  24. 58 27
      packages/@uppy/dashboard/src/components/FileItem/Buttons/index.tsx
  25. 31 26
      packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.tsx
  26. 0 39
      packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.jsx
  27. 40 0
      packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.tsx
  28. 49 22
      packages/@uppy/dashboard/src/components/FileItem/FileProgress/index.tsx
  29. 12 9
      packages/@uppy/dashboard/src/components/FileItem/MetaErrorMessage.tsx
  30. 29 16
      packages/@uppy/dashboard/src/components/FileItem/index.tsx
  31. 50 24
      packages/@uppy/dashboard/src/components/FileList.tsx
  32. 15 4
      packages/@uppy/dashboard/src/components/FilePreview.tsx
  33. 16 3
      packages/@uppy/dashboard/src/components/PickerPanelContent.tsx
  34. 57 21
      packages/@uppy/dashboard/src/components/PickerPanelTopBar.tsx
  35. 0 99
      packages/@uppy/dashboard/src/components/Slide.jsx
  36. 96 0
      packages/@uppy/dashboard/src/components/Slide.tsx
  37. 0 1
      packages/@uppy/dashboard/src/index.js
  38. 31 16
      packages/@uppy/dashboard/src/index.test.ts
  39. 1 0
      packages/@uppy/dashboard/src/index.ts
  40. 0 0
      packages/@uppy/dashboard/src/locale.ts
  41. 1 1
      packages/@uppy/dashboard/src/utils/copyToClipboard.test.ts
  42. 10 4
      packages/@uppy/dashboard/src/utils/copyToClipboard.ts
  43. 1 1
      packages/@uppy/dashboard/src/utils/createSuperFocus.test.ts
  44. 10 4
      packages/@uppy/dashboard/src/utils/createSuperFocus.ts
  45. 0 11
      packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js
  46. 18 0
      packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts
  47. 0 127
      packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx
  48. 212 0
      packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx
  49. 4 3
      packages/@uppy/dashboard/src/utils/ignoreEvent.ts
  50. 28 11
      packages/@uppy/dashboard/src/utils/trapFocus.ts
  51. 60 0
      packages/@uppy/dashboard/tsconfig.build.json
  52. 56 0
      packages/@uppy/dashboard/tsconfig.json

+ 427 - 0
packages/@uppy/aws-s3-multipart/src/HTTPCommunicationQueue.ts

@@ -0,0 +1,427 @@
+import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type {
+  RateLimitedQueue,
+  WrapPromiseFunctionType,
+} from '@uppy/utils/lib/RateLimitedQueue'
+import { pausingUploadReason, type Chunk } from './MultipartUploader.ts'
+import type AwsS3Multipart from './index.ts'
+import { throwIfAborted } from './utils.ts'
+import type { Body, UploadPartBytesResult, UploadResult } from './utils.ts'
+import type { AwsS3MultipartOptions, uploadPartBytes } from './index.ts'
+
+function removeMetadataFromURL(urlString: string) {
+  const urlObject = new URL(urlString)
+  urlObject.search = ''
+  urlObject.hash = ''
+  return urlObject.href
+}
+
+export class HTTPCommunicationQueue<M extends Meta, B extends Body> {
+  #abortMultipartUpload: WrapPromiseFunctionType<
+    AwsS3Multipart<M, B>['abortMultipartUpload']
+  >
+
+  #cache = new WeakMap()
+
+  #createMultipartUpload: WrapPromiseFunctionType<
+    AwsS3Multipart<M, B>['createMultipartUpload']
+  >
+
+  #fetchSignature: WrapPromiseFunctionType<AwsS3Multipart<M, B>['signPart']>
+
+  #getUploadParameters: WrapPromiseFunctionType<
+    AwsS3Multipart<M, B>['getUploadParameters']
+  >
+
+  #listParts: WrapPromiseFunctionType<AwsS3Multipart<M, B>['listParts']>
+
+  #previousRetryDelay: number
+
+  #requests
+
+  #retryDelays: { values: () => Iterator<number> }
+
+  #sendCompletionRequest: WrapPromiseFunctionType<
+    AwsS3Multipart<M, B>['completeMultipartUpload']
+  >
+
+  #setS3MultipartState
+
+  #uploadPartBytes: WrapPromiseFunctionType<uploadPartBytes>
+
+  #getFile
+
+  constructor(
+    requests: RateLimitedQueue,
+    options: AwsS3MultipartOptions<M, B>,
+    setS3MultipartState: (file: UppyFile<M, B>, result: UploadResult) => void,
+    getFile: (file: UppyFile<M, B>) => UppyFile<M, B>,
+  ) {
+    this.#requests = requests
+    this.#setS3MultipartState = setS3MultipartState
+    this.#getFile = getFile
+    this.setOptions(options)
+  }
+
+  setOptions(options: Partial<AwsS3MultipartOptions<M, B>>): void {
+    const requests = this.#requests
+
+    if ('abortMultipartUpload' in options) {
+      this.#abortMultipartUpload = requests.wrapPromiseFunction(
+        options.abortMultipartUpload as any,
+        { priority: 1 },
+      )
+    }
+    if ('createMultipartUpload' in options) {
+      this.#createMultipartUpload = requests.wrapPromiseFunction(
+        options.createMultipartUpload as any,
+        { priority: -1 },
+      )
+    }
+    if ('signPart' in options) {
+      this.#fetchSignature = requests.wrapPromiseFunction(
+        options.signPart as any,
+      )
+    }
+    if ('listParts' in options) {
+      this.#listParts = requests.wrapPromiseFunction(options.listParts as any)
+    }
+    if ('completeMultipartUpload' in options) {
+      this.#sendCompletionRequest = requests.wrapPromiseFunction(
+        options.completeMultipartUpload as any,
+        { priority: 1 },
+      )
+    }
+    if ('retryDelays' in options) {
+      this.#retryDelays = options.retryDelays ?? []
+    }
+    if ('uploadPartBytes' in options) {
+      this.#uploadPartBytes = requests.wrapPromiseFunction(
+        options.uploadPartBytes as any,
+        { priority: Infinity },
+      )
+    }
+    if ('getUploadParameters' in options) {
+      this.#getUploadParameters = requests.wrapPromiseFunction(
+        options.getUploadParameters as any,
+      )
+    }
+  }
+
+  async #shouldRetry(err: any, retryDelayIterator: Iterator<number>) {
+    const requests = this.#requests
+    const status = err?.source?.status
+
+    // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
+    // perhaps the rate limited queue, and dedupe all plugins with that.
+    if (status == null) {
+      return false
+    }
+    if (status === 403 && err.message === 'Request has expired') {
+      if (!requests.isPaused) {
+        // We don't want to exhaust the retryDelayIterator as long as there are
+        // more than one request in parallel, to give slower connection a chance
+        // to catch up with the expiry set in Companion.
+        if (requests.limit === 1 || this.#previousRetryDelay == null) {
+          const next = retryDelayIterator.next()
+          if (next == null || next.done) {
+            return false
+          }
+          // If there are more than 1 request done in parallel, the RLQ limit is
+          // decreased and the failed request is requeued after waiting for a bit.
+          // If there is only one request in parallel, the limit can't be
+          // decreased, so we iterate over `retryDelayIterator` as we do for
+          // other failures.
+          // `#previousRetryDelay` caches the value so we can re-use it next time.
+          this.#previousRetryDelay = next.value
+        }
+        // No need to stop the other requests, we just want to lower the limit.
+        requests.rateLimit(0)
+        await new Promise((resolve) =>
+          setTimeout(resolve, this.#previousRetryDelay),
+        )
+      }
+    } else if (status === 429) {
+      // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
+      if (!requests.isPaused) {
+        const next = retryDelayIterator.next()
+        if (next == null || next.done) {
+          return false
+        }
+        requests.rateLimit(next.value)
+      }
+    } else if (status > 400 && status < 500 && status !== 409) {
+      // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
+      return false
+    } else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
+      // The navigator is offline, let's wait for it to come back online.
+      if (!requests.isPaused) {
+        requests.pause()
+        window.addEventListener(
+          'online',
+          () => {
+            requests.resume()
+          },
+          { once: true },
+        )
+      }
+    } else {
+      // Other error code means the request can be retried later.
+      const next = retryDelayIterator.next()
+      if (next == null || next.done) {
+        return false
+      }
+      await new Promise((resolve) => setTimeout(resolve, next.value))
+    }
+    return true
+  }
+
+  async getUploadId(
+    file: UppyFile<M, B>,
+    signal: AbortSignal,
+  ): Promise<UploadResult> {
+    let cachedResult
+    // As the cache is updated asynchronously, there could be a race condition
+    // where we just miss a new result so we loop here until we get nothing back,
+    // at which point it's out turn to create a new cache entry.
+    // eslint-disable-next-line no-cond-assign
+    while ((cachedResult = this.#cache.get(file.data)) != null) {
+      try {
+        return await cachedResult
+      } catch {
+        // In case of failure, we want to ignore the cached error.
+        // At this point, either there's a new cached value, or we'll exit the loop a create a new one.
+      }
+    }
+
+    const promise = this.#createMultipartUpload(this.#getFile(file), signal)
+
+    const abortPromise = () => {
+      promise.abort(signal.reason)
+      this.#cache.delete(file.data)
+    }
+    signal.addEventListener('abort', abortPromise, { once: true })
+    this.#cache.set(file.data, promise)
+    promise.then(
+      async (result) => {
+        signal.removeEventListener('abort', abortPromise)
+        this.#setS3MultipartState(file, result)
+        this.#cache.set(file.data, result)
+      },
+      () => {
+        signal.removeEventListener('abort', abortPromise)
+        this.#cache.delete(file.data)
+      },
+    )
+
+    return promise
+  }
+
+  async abortFileUpload(file: UppyFile<M, B>): Promise<void> {
+    const result = this.#cache.get(file.data)
+    if (result == null) {
+      // If the createMultipartUpload request never was made, we don't
+      // need to send the abortMultipartUpload request.
+      return
+    }
+    // Remove the cache entry right away for follow-up requests do not try to
+    // use the soon-to-be aborted cached values.
+    this.#cache.delete(file.data)
+    this.#setS3MultipartState(file, Object.create(null))
+    let awaitedResult
+    try {
+      awaitedResult = await result
+    } catch {
+      // If the cached result rejects, there's nothing to abort.
+      return
+    }
+    await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
+  }
+
+  async #nonMultipartUpload(
+    file: UppyFile<M, B>,
+    chunk: Chunk,
+    signal?: AbortSignal,
+  ): Promise<UploadPartBytesResult & B> {
+    const {
+      method = 'POST',
+      url,
+      fields,
+      headers,
+    } = await this.#getUploadParameters(this.#getFile(file), {
+      signal,
+    }).abortOn(signal)
+
+    let body
+    const data = chunk.getData()
+    if (method.toUpperCase() === 'POST') {
+      const formData = new FormData()
+      Object.entries(fields!).forEach(([key, value]) =>
+        formData.set(key, value),
+      )
+      formData.set('file', data)
+      body = formData
+    } else {
+      body = data
+    }
+
+    const { onProgress, onComplete } = chunk
+
+    const result = await this.#uploadPartBytes({
+      signature: { url, headers, method } as any,
+      body,
+      size: data.size,
+      onProgress,
+      onComplete,
+      signal,
+    }).abortOn(signal)
+
+    return 'location' in result ?
+        (result as UploadPartBytesResult & B)
+      : ({
+          location: removeMetadataFromURL(url),
+          ...result,
+        } as any)
+  }
+
+  async uploadFile(
+    file: UppyFile<M, B>,
+    chunks: Chunk[],
+    signal: AbortSignal,
+  ): Promise<B & Partial<UploadPartBytesResult>> {
+    throwIfAborted(signal)
+    if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
+      return this.#nonMultipartUpload(file, chunks[0], signal)
+    }
+    const { uploadId, key } = await this.getUploadId(file, signal)
+    throwIfAborted(signal)
+    try {
+      const parts = await Promise.all(
+        chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)),
+      )
+      throwIfAborted(signal)
+      return await this.#sendCompletionRequest(
+        this.#getFile(file),
+        { key, uploadId, parts, signal },
+        signal,
+      ).abortOn(signal)
+    } catch (err) {
+      if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
+        // We purposefully don't wait for the promise and ignore its status,
+        // because we want the error `err` to bubble up ASAP to report it to the
+        // user. A failure to abort is not that big of a deal anyway.
+        this.abortFileUpload(file)
+      }
+      throw err
+    }
+  }
+
+  restoreUploadFile(file: UppyFile<M, B>, uploadIdAndKey: UploadResult): void {
+    this.#cache.set(file.data, uploadIdAndKey)
+  }
+
+  async resumeUploadFile(
+    file: UppyFile<M, B>,
+    chunks: Array<Chunk | null>,
+    signal: AbortSignal,
+  ): Promise<B> {
+    throwIfAborted(signal)
+    if (
+      chunks.length === 1 &&
+      chunks[0] != null &&
+      !chunks[0].shouldUseMultipart
+    ) {
+      return this.#nonMultipartUpload(file, chunks[0], signal)
+    }
+    const { uploadId, key } = await this.getUploadId(file, signal)
+    throwIfAborted(signal)
+    const alreadyUploadedParts = await this.#listParts(
+      this.#getFile(file),
+      { uploadId, key, signal },
+      signal,
+    ).abortOn(signal)
+    throwIfAborted(signal)
+    const parts = await Promise.all(
+      chunks.map((chunk, i) => {
+        const partNumber = i + 1
+        const alreadyUploadedInfo = alreadyUploadedParts.find(
+          ({ PartNumber }) => PartNumber === partNumber,
+        )
+        if (alreadyUploadedInfo == null) {
+          return this.uploadChunk(file, partNumber, chunk!, signal)
+        }
+        // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
+        chunk?.setAsUploaded?.()
+        return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
+      }),
+    )
+    throwIfAborted(signal)
+    return this.#sendCompletionRequest(
+      this.#getFile(file),
+      { key, uploadId, parts, signal },
+      signal,
+    ).abortOn(signal)
+  }
+
+  async uploadChunk(
+    file: UppyFile<M, B>,
+    partNumber: number,
+    chunk: Chunk,
+    signal: AbortSignal,
+  ): Promise<UploadPartBytesResult & { PartNumber: number }> {
+    throwIfAborted(signal)
+    const { uploadId, key } = await this.getUploadId(file, signal)
+
+    const signatureRetryIterator = this.#retryDelays.values()
+    const chunkRetryIterator = this.#retryDelays.values()
+    const shouldRetrySignature = () => {
+      const next = signatureRetryIterator.next()
+      if (next == null || next.done) {
+        return null
+      }
+      return next.value
+    }
+
+    for (;;) {
+      throwIfAborted(signal)
+      const chunkData = chunk.getData()
+      const { onProgress, onComplete } = chunk
+      let signature
+
+      try {
+        signature = await this.#fetchSignature(this.#getFile(file), {
+          uploadId,
+          key,
+          partNumber,
+          body: chunkData,
+          signal,
+        }).abortOn(signal)
+      } catch (err) {
+        const timeout = shouldRetrySignature()
+        if (timeout == null || signal.aborted) {
+          throw err
+        }
+        await new Promise((resolve) => setTimeout(resolve, timeout))
+        // eslint-disable-next-line no-continue
+        continue
+      }
+
+      throwIfAborted(signal)
+      try {
+        return {
+          PartNumber: partNumber,
+          ...(await this.#uploadPartBytes({
+            signature,
+            body: chunkData,
+            size: chunkData.size,
+            onProgress,
+            onComplete,
+            signal,
+          }).abortOn(signal)),
+        }
+      } catch (err) {
+        if (!(await this.#shouldRetry(err, chunkRetryIterator))) throw err
+      }
+    }
+  }
+}

+ 97 - 56
packages/@uppy/aws-s3-multipart/src/MultipartUploader.js → packages/@uppy/aws-s3-multipart/src/MultipartUploader.ts

@@ -1,24 +1,53 @@
+import type { Uppy } from '@uppy/core'
 import { AbortController } from '@uppy/utils/lib/AbortController'
+import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue'
+import type { Body } from './utils'
 
 const MB = 1024 * 1024
 
+interface MultipartUploaderOptions<M extends Meta, B extends Body> {
+  getChunkSize?: (file: { size: number }) => number
+  onProgress?: (bytesUploaded: number, bytesTotal: number) => void
+  onPartComplete?: (part: { PartNumber: number; ETag: string }) => void
+  shouldUseMultipart?: boolean | ((file: UppyFile<M, B>) => boolean)
+  onSuccess?: (result: B) => void
+  onError?: (err: unknown) => void
+  companionComm: HTTPCommunicationQueue<M, B>
+  file: UppyFile<M, B>
+  log: Uppy<M, B>['log']
+
+  uploadId: string
+  key: string
+}
+
 const defaultOptions = {
-  getChunkSize (file) {
+  getChunkSize(file: { size: number }) {
     return Math.ceil(file.size / 10000)
   },
-  onProgress () {},
-  onPartComplete () {},
-  onSuccess () {},
-  onError (err) {
+  onProgress() {},
+  onPartComplete() {},
+  onSuccess() {},
+  onError(err: unknown) {
     throw err
   },
+} satisfies Partial<MultipartUploaderOptions<any, any>>
+
+export interface Chunk {
+  getData: () => Blob
+  onProgress: (ev: ProgressEvent) => void
+  onComplete: (etag: string) => void
+  shouldUseMultipart: boolean
+  setAsUploaded?: () => void
 }
 
-function ensureInt (value) {
+function ensureInt<T>(value: T): T extends number | string ? number : never {
   if (typeof value === 'string') {
+    // @ts-expect-error TS is not able to recognize it's fine.
     return parseInt(value, 10)
   }
   if (typeof value === 'number') {
+    // @ts-expect-error TS is not able to recognize it's fine.
     return value
   }
   throw new TypeError('Expected a number')
@@ -32,47 +61,41 @@ export const pausingUploadReason = Symbol('pausing upload, not an actual error')
  * (based on the user-provided `shouldUseMultipart` option value) and to manage
  * the chunk splitting.
  */
-class MultipartUploader {
+class MultipartUploader<M extends Meta, B extends Body> {
+  options: MultipartUploaderOptions<M, B> &
+    Required<Pick<MultipartUploaderOptions<M, B>, keyof typeof defaultOptions>>
+
   #abortController = new AbortController()
 
-  /** @type {import("../types/chunk").Chunk[]} */
-  #chunks
+  #chunks: Array<Chunk | null>
 
-  /** @type {{ uploaded: number, etag?: string, done?: boolean }[]} */
-  #chunkState
+  #chunkState: { uploaded: number; etag?: string; done?: boolean }[]
 
   /**
    * The (un-chunked) data to upload.
-   *
-   * @type {Blob}
    */
-  #data
+  #data: Blob
 
-  /** @type {import("@uppy/core").UppyFile} */
-  #file
+  #file: UppyFile<M, B>
 
-  /** @type {boolean} */
   #uploadHasStarted = false
 
-  /** @type {(err?: Error | any) => void} */
-  #onError
+  #onError: (err: unknown) => void
 
-  /** @type {() => void} */
-  #onSuccess
+  #onSuccess: (result: B) => void
 
-  /** @type {import('../types/index').AwsS3MultipartOptions["shouldUseMultipart"]} */
-  #shouldUseMultipart
+  #shouldUseMultipart: MultipartUploaderOptions<M, B>['shouldUseMultipart']
 
-  /** @type {boolean} */
-  #isRestoring
+  #isRestoring: boolean
 
-  #onReject = (err) => (err?.cause === pausingUploadReason ? null : this.#onError(err))
+  #onReject = (err: unknown) =>
+    (err as any)?.cause === pausingUploadReason ? null : this.#onError(err)
 
   #maxMultipartParts = 10_000
 
   #minPartSize = 5 * MB
 
-  constructor (data, options) {
+  constructor(data: Blob, options: MultipartUploaderOptions<M, B>) {
     this.options = {
       ...defaultOptions,
       ...options,
@@ -89,7 +112,7 @@ class MultipartUploader {
     // When we are restoring an upload, we already have an UploadId and a Key. Otherwise
     // we need to call `createMultipartUpload` to get an `uploadId` and a `key`.
     // Non-multipart uploads are not restorable.
-    this.#isRestoring = options.uploadId && options.key
+    this.#isRestoring = (options.uploadId && options.key) as any as boolean
 
     this.#initChunks()
   }
@@ -98,15 +121,19 @@ class MultipartUploader {
   // and calculates the optimal part size. When using multipart part uploads every part except for the last has
   // to be at least 5 MB and there can be no more than 10K parts.
   // This means we sometimes need to change the preferred part size from the user in order to meet these requirements.
-  #initChunks () {
+  #initChunks() {
     const fileSize = this.#data.size
-    const shouldUseMultipart = typeof this.#shouldUseMultipart === 'function'
-      ? this.#shouldUseMultipart(this.#file)
+    const shouldUseMultipart =
+      typeof this.#shouldUseMultipart === 'function' ?
+        this.#shouldUseMultipart(this.#file)
       : Boolean(this.#shouldUseMultipart)
 
     if (shouldUseMultipart && fileSize > this.#minPartSize) {
       // At least 5MB per request:
-      let chunkSize = Math.max(this.options.getChunkSize(this.#data), this.#minPartSize)
+      let chunkSize = Math.max(
+        this.options.getChunkSize(this.#data),
+        this.#minPartSize,
+      )
       let arraySize = Math.floor(fileSize / chunkSize)
 
       // At most 10k requests per file:
@@ -132,41 +159,48 @@ class MultipartUploader {
           shouldUseMultipart,
         }
         if (this.#isRestoring) {
-          const size = offset + chunkSize > fileSize ? fileSize - offset : chunkSize
+          const size =
+            offset + chunkSize > fileSize ? fileSize - offset : chunkSize
           // setAsUploaded is called by listPart, to keep up-to-date the
           // quantity of data that is left to actually upload.
-          this.#chunks[j].setAsUploaded = () => {
+          this.#chunks[j]!.setAsUploaded = () => {
             this.#chunks[j] = null
             this.#chunkState[j].uploaded = size
           }
         }
       }
     } else {
-      this.#chunks = [{
-        getData: () => this.#data,
-        onProgress: this.#onPartProgress(0),
-        onComplete: this.#onPartComplete(0),
-        shouldUseMultipart,
-      }]
+      this.#chunks = [
+        {
+          getData: () => this.#data,
+          onProgress: this.#onPartProgress(0),
+          onComplete: this.#onPartComplete(0),
+          shouldUseMultipart,
+        },
+      ]
     }
 
     this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 }))
   }
 
-  #createUpload () {
-    this
-      .options.companionComm.uploadFile(this.#file, this.#chunks, this.#abortController.signal)
+  #createUpload() {
+    this.options.companionComm
+      .uploadFile(
+        this.#file,
+        this.#chunks as Chunk[],
+        this.#abortController.signal,
+      )
       .then(this.#onSuccess, this.#onReject)
     this.#uploadHasStarted = true
   }
 
-  #resumeUpload () {
-    this
-      .options.companionComm.resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
+  #resumeUpload() {
+    this.options.companionComm
+      .resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
       .then(this.#onSuccess, this.#onReject)
   }
 
-  #onPartProgress = (index) => (ev) => {
+  #onPartProgress = (index: number) => (ev: ProgressEvent) => {
     if (!ev.lengthComputable) return
 
     this.#chunkState[index].uploaded = ensureInt(ev.loaded)
@@ -175,7 +209,7 @@ class MultipartUploader {
     this.options.onProgress(totalUploaded, this.#data.size)
   }
 
-  #onPartComplete = (index) => (etag) => {
+  #onPartComplete = (index: number) => (etag: string) => {
     // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers.
     this.#chunks[index] = null
     this.#chunkState[index].etag = etag
@@ -188,37 +222,44 @@ class MultipartUploader {
     this.options.onPartComplete(part)
   }
 
-  #abortUpload () {
+  #abortUpload() {
     this.#abortController.abort()
-    this.options.companionComm.abortFileUpload(this.#file).catch((err) => this.options.log(err))
+    this.options.companionComm
+      .abortFileUpload(this.#file)
+      .catch((err: unknown) => this.options.log(err as Error))
   }
 
-  start () {
+  start(): void {
     if (this.#uploadHasStarted) {
-      if (!this.#abortController.signal.aborted) this.#abortController.abort(pausingUploadReason)
+      if (!this.#abortController.signal.aborted)
+        this.#abortController.abort(pausingUploadReason)
       this.#abortController = new AbortController()
       this.#resumeUpload()
     } else if (this.#isRestoring) {
-      this.options.companionComm.restoreUploadFile(this.#file, { uploadId: this.options.uploadId, key: this.options.key })
+      this.options.companionComm.restoreUploadFile(this.#file, {
+        uploadId: this.options.uploadId,
+        key: this.options.key,
+      })
       this.#resumeUpload()
     } else {
       this.#createUpload()
     }
   }
 
-  pause () {
+  pause(): void {
     this.#abortController.abort(pausingUploadReason)
     // Swap it out for a new controller, because this instance may be resumed later.
     this.#abortController = new AbortController()
   }
 
-  abort (opts = undefined) {
+  abort(opts?: { really?: boolean }): void {
     if (opts?.really) this.#abortUpload()
     else this.pause()
   }
 
   // TODO: remove this in the next major
-  get chunkState () {
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  get chunkState() {
     return this.#chunkState
   }
 }

+ 0 - 113
packages/@uppy/aws-s3-multipart/src/createSignedURL.test.js

@@ -1,113 +0,0 @@
-import { describe, it, beforeEach, afterEach } from 'vitest'
-import assert from 'node:assert'
-import { S3Client, UploadPartCommand, PutObjectCommand } from '@aws-sdk/client-s3'
-import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
-import createSignedURL from './createSignedURL.js'
-
-const bucketName = 'some-bucket'
-const s3ClientOptions = {
-  region: 'us-bar-1',
-  credentials: {
-    accessKeyId: 'foo',
-    secretAccessKey: 'bar',
-    sessionToken: 'foobar',
-  },
-}
-const { Date: OriginalDate } = globalThis
-
-describe('createSignedURL', () => {
-  beforeEach(() => {
-    const now_ms = OriginalDate.now()
-    globalThis.Date = function Date () {
-      if (new.target) {
-        return Reflect.construct(OriginalDate, [now_ms])
-      }
-      return Reflect.apply(OriginalDate, this, [now_ms])
-    }
-    globalThis.Date.now = function now () {
-      return now_ms
-    }
-  })
-  afterEach(() => {
-    globalThis.Date = OriginalDate
-  })
-  it('should be able to sign non-multipart upload', async () => {
-    const client = new S3Client(s3ClientOptions)
-    assert.strictEqual(
-      (await createSignedURL({
-        accountKey: s3ClientOptions.credentials.accessKeyId,
-        accountSecret: s3ClientOptions.credentials.secretAccessKey,
-        sessionToken: s3ClientOptions.credentials.sessionToken,
-        bucketName,
-        Key: 'some/key',
-        Region: s3ClientOptions.region,
-        expires: 900,
-      })).searchParams.get('X-Amz-Signature'),
-      new URL(await getSignedUrl(client, new PutObjectCommand({
-        Bucket: bucketName,
-        Fields: {},
-        Key: 'some/key',
-      }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'),
-    )
-  })
-  it('should be able to sign multipart upload', async () => {
-    const client = new S3Client(s3ClientOptions)
-    const partNumber = 99
-    const uploadId = 'dummyUploadId'
-    assert.strictEqual(
-      (await createSignedURL({
-        accountKey: s3ClientOptions.credentials.accessKeyId,
-        accountSecret: s3ClientOptions.credentials.secretAccessKey,
-        sessionToken: s3ClientOptions.credentials.sessionToken,
-        uploadId,
-        partNumber,
-        bucketName,
-        Key: 'some/key',
-        Region: s3ClientOptions.region,
-        expires: 900,
-      })).searchParams.get('X-Amz-Signature'),
-      new URL(await getSignedUrl(client, new UploadPartCommand({
-        Bucket: bucketName,
-        UploadId: uploadId,
-        PartNumber: partNumber,
-        Key: 'some/key',
-      }), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'),
-    )
-  })
-  it('should escape path and query as restricted to RFC 3986', async () => {
-    const client = new S3Client(s3ClientOptions)
-    const partNumber = 99
-    const specialChars = ';?:@&=+$,#!\'()'
-    const uploadId = `Upload${specialChars}Id`
-    // '.*' chars of path should be encoded
-    const Key = `${specialChars}.*/${specialChars}.*.ext`
-    const implResult =
-      await createSignedURL({
-        accountKey: s3ClientOptions.credentials.accessKeyId,
-        accountSecret: s3ClientOptions.credentials.secretAccessKey,
-        sessionToken: s3ClientOptions.credentials.sessionToken,
-        uploadId,
-        partNumber,
-        bucketName,
-        Key,
-        Region: s3ClientOptions.region,
-        expires: 900,
-      })
-    const sdkResult =
-      new URL(
-        await getSignedUrl(client, new UploadPartCommand({
-          Bucket: bucketName,
-          UploadId: uploadId,
-          PartNumber: partNumber,
-          Key,
-        }), { expiresIn: 900 }
-      )
-    )
-    assert.strictEqual(implResult.pathname, sdkResult.pathname)
-
-    const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/
-    const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/
-    assert.strictEqual(implResult.search.match(extractUploadId)[2], sdkResult.search.match(extractUploadId)[2])
-    assert.strictEqual(implResult.search.match(extractSignature)[2], sdkResult.search.match(extractSignature)[2])
-  })
-})

+ 141 - 0
packages/@uppy/aws-s3-multipart/src/createSignedURL.test.ts

@@ -0,0 +1,141 @@
+import { describe, it, beforeEach, afterEach } from 'vitest'
+import assert from 'node:assert'
+import {
+  S3Client,
+  UploadPartCommand,
+  PutObjectCommand,
+} from '@aws-sdk/client-s3'
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
+import createSignedURL from './createSignedURL.ts'
+
+const bucketName = 'some-bucket'
+const s3ClientOptions = {
+  region: 'us-bar-1',
+  credentials: {
+    accessKeyId: 'foo',
+    secretAccessKey: 'bar',
+    sessionToken: 'foobar',
+  },
+}
+const { Date: OriginalDate } = globalThis
+
+describe('createSignedURL', () => {
+  beforeEach(() => {
+    const now_ms = OriginalDate.now()
+    // @ts-expect-error we're touching globals for test purposes.
+    globalThis.Date = function Date() {
+      if (new.target) {
+        return Reflect.construct(OriginalDate, [now_ms])
+      }
+      return Reflect.apply(OriginalDate, this, [now_ms])
+    }
+    globalThis.Date.now = function now() {
+      return now_ms
+    }
+  })
+  afterEach(() => {
+    globalThis.Date = OriginalDate
+  })
+  it('should be able to sign non-multipart upload', async () => {
+    const client = new S3Client(s3ClientOptions)
+    assert.strictEqual(
+      (
+        await createSignedURL({
+          accountKey: s3ClientOptions.credentials.accessKeyId,
+          accountSecret: s3ClientOptions.credentials.secretAccessKey,
+          sessionToken: s3ClientOptions.credentials.sessionToken,
+          bucketName,
+          Key: 'some/key',
+          Region: s3ClientOptions.region,
+          expires: 900,
+        })
+      ).searchParams.get('X-Amz-Signature'),
+      new URL(
+        await getSignedUrl(
+          client,
+          new PutObjectCommand({
+            Bucket: bucketName,
+            Key: 'some/key',
+          }),
+          { expiresIn: 900 },
+        ),
+      ).searchParams.get('X-Amz-Signature'),
+    )
+  })
+  it('should be able to sign multipart upload', async () => {
+    const client = new S3Client(s3ClientOptions)
+    const partNumber = 99
+    const uploadId = 'dummyUploadId'
+    assert.strictEqual(
+      (
+        await createSignedURL({
+          accountKey: s3ClientOptions.credentials.accessKeyId,
+          accountSecret: s3ClientOptions.credentials.secretAccessKey,
+          sessionToken: s3ClientOptions.credentials.sessionToken,
+          uploadId,
+          partNumber,
+          bucketName,
+          Key: 'some/key',
+          Region: s3ClientOptions.region,
+          expires: 900,
+        })
+      ).searchParams.get('X-Amz-Signature'),
+      new URL(
+        await getSignedUrl(
+          client,
+          new UploadPartCommand({
+            Bucket: bucketName,
+            UploadId: uploadId,
+            PartNumber: partNumber,
+            Key: 'some/key',
+          }),
+          { expiresIn: 900 },
+        ),
+      ).searchParams.get('X-Amz-Signature'),
+    )
+  })
+
+  it('should escape path and query as restricted to RFC 3986', async () => {
+    const client = new S3Client(s3ClientOptions)
+    const partNumber = 99
+    const specialChars = ";?:@&=+$,#!'()"
+    const uploadId = `Upload${specialChars}Id`
+    // '.*' chars of path should be encoded
+    const Key = `${specialChars}.*/${specialChars}.*.ext`
+    const implResult = await createSignedURL({
+      accountKey: s3ClientOptions.credentials.accessKeyId,
+      accountSecret: s3ClientOptions.credentials.secretAccessKey,
+      sessionToken: s3ClientOptions.credentials.sessionToken,
+      uploadId,
+      partNumber,
+      bucketName,
+      Key,
+      Region: s3ClientOptions.region,
+      expires: 900,
+    })
+    const sdkResult = new URL(
+      await getSignedUrl(
+        client,
+        new UploadPartCommand({
+          Bucket: bucketName,
+          UploadId: uploadId,
+          PartNumber: partNumber,
+          Key,
+        }),
+        { expiresIn: 900 },
+      ),
+    )
+    assert.strictEqual(implResult.pathname, sdkResult.pathname)
+
+    const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/
+    const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/
+    assert.strictEqual(
+      implResult.search.match(extractUploadId)![2],
+      sdkResult.search.match(extractUploadId)![2],
+    )
+    assert.strictEqual(
+      implResult.search.match(extractSignature)![2],
+      sdkResult.search.match(extractSignature)![2],
+    )
+  })
+})

+ 55 - 31
packages/@uppy/aws-s3-multipart/src/createSignedURL.js → packages/@uppy/aws-s3-multipart/src/createSignedURL.ts

@@ -5,44 +5,51 @@
  *
  * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
  *
- * @param {object} param0
- * @param {string} param0.method – The HTTP method.
- * @param {string} param0.CanonicalUri – The URI-encoded version of the absolute
+ * @param param0
+ * @param param0.method – The HTTP method.
+ * @param param0.CanonicalUri – The URI-encoded version of the absolute
  * path component URL (everything between the host and the question mark
  * character (?) that starts the query string parameters). If the absolute path
  * is empty, use a forward slash character (/).
- * @param {string} param0.CanonicalQueryString – The URL-encoded query string
+ * @param param0.CanonicalQueryString – The URL-encoded query string
  * parameters, separated by ampersands (&). Percent-encode reserved characters,
  * including the space character. Encode names and values separately. If there
  * are empty parameters, append the equals sign to the parameter name before
  * encoding. After encoding, sort the parameters alphabetically by key name. If
  * there is no query string, use an empty string ("").
- * @param {Record<string, string>} param0.SignedHeaders – The request headers,
+ * @param param0.SignedHeaders – The request headers,
  * that will be signed, and their values, separated by newline characters.
  * For the values, trim any leading or trailing spaces, convert sequential
  * spaces to a single space, and separate the values for a multi-value header
  * using commas. You must include the host header (HTTP/1.1), and any x-amz-*
  * headers in the signature. You can optionally include other standard headers
  * in the signature, such as content-type.
- * @param {string} param0.HashedPayload – A string created using the payload in
+ * @param param0.HashedPayload – A string created using the payload in
  * the body of the HTTP request as input to a hash function. This string uses
  * lowercase hexadecimal characters. If the payload is empty, use an empty
  * string as the input to the hash function.
- * @returns {string}
  */
-function createCanonicalRequest ({
+function createCanonicalRequest({
   method = 'PUT',
   CanonicalUri = '/',
   CanonicalQueryString = '',
   SignedHeaders,
   HashedPayload,
-}) {
-  const headerKeys = Object.keys(SignedHeaders).map(k => k.toLowerCase()).sort()
+}: {
+  method?: string
+  CanonicalUri: string
+  CanonicalQueryString: string
+  SignedHeaders: Record<string, string>
+  HashedPayload: string
+}): string {
+  const headerKeys = Object.keys(SignedHeaders)
+    .map((k) => k.toLowerCase())
+    .sort()
   return [
     method,
     CanonicalUri,
     CanonicalQueryString,
-    ...headerKeys.map(k => `${k}:${SignedHeaders[k]}`),
+    ...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`),
     '',
     headerKeys.join(';'),
     HashedPayload,
@@ -52,17 +59,23 @@ function createCanonicalRequest ({
 const ec = new TextEncoder()
 const algorithm = { name: 'HMAC', hash: 'SHA-256' }
 
-async function digest (data) {
+async function digest(data: string): ReturnType<SubtleCrypto['digest']> {
   const { subtle } = globalThis.crypto
   return subtle.digest(algorithm.hash, ec.encode(data))
 }
 
-async function generateHmacKey (secret) {
+async function generateHmacKey(secret: string | Uint8Array | ArrayBuffer) {
   const { subtle } = globalThis.crypto
-  return subtle.importKey('raw', typeof secret === 'string' ? ec.encode(secret) : secret, algorithm, false, ['sign'])
+  return subtle.importKey(
+    'raw',
+    typeof secret === 'string' ? ec.encode(secret) : secret,
+    algorithm,
+    false,
+    ['sign'],
+  )
 }
 
-function arrayBufferToHexString (arrayBuffer) {
+function arrayBufferToHexString(arrayBuffer: ArrayBuffer) {
   const byteArray = new Uint8Array(arrayBuffer)
   let hexString = ''
   for (let i = 0; i < byteArray.length; i++) {
@@ -71,27 +84,35 @@ function arrayBufferToHexString (arrayBuffer) {
   return hexString
 }
 
-async function hash (key, data) {
+async function hash(key: Parameters<typeof generateHmacKey>[0], data: string) {
   const { subtle } = globalThis.crypto
   return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data))
 }
 
-function percentEncode(c) {
-  return `%${c.charCodeAt(0).toString(16).toUpperCase()}`
-}
-
 /**
  * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
- * @param {Record<string,string>} param0
- * @returns {Promise<URL>} the signed URL
  */
-export default async function createSignedURL ({
-  accountKey, accountSecret, sessionToken,
+export default async function createSignedURL({
+  accountKey,
+  accountSecret,
+  sessionToken,
   bucketName,
-  Key, Region,
+  Key,
+  Region,
   expires,
-  uploadId, partNumber,
-}) {
+  uploadId,
+  partNumber,
+}: {
+  accountKey: string
+  accountSecret: string
+  sessionToken: string
+  bucketName: string
+  Key: string
+  Region: string
+  expires: string | number
+  uploadId?: string
+  partNumber?: string | number
+}): Promise<URL> {
   const Service = 's3'
   const host = `${bucketName}.${Service}.${Region}.amazonaws.com`
   /**
@@ -100,7 +121,7 @@ export default async function createSignedURL ({
    *
    * @see https://tc39.es/ecma262/#sec-encodeuri-uri
    */
-  const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, percentEncode)}`
+  const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`
   const payload = 'UNSIGNED-PAYLOAD'
 
   const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ
@@ -113,14 +134,17 @@ export default async function createSignedURL ({
   url.searchParams.set('X-Amz-Content-Sha256', payload)
   url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`)
   url.searchParams.set('X-Amz-Date', requestDateTime)
-  url.searchParams.set('X-Amz-Expires', expires)
+  url.searchParams.set('X-Amz-Expires', expires as string)
   // We are signing on the client, so we expect there's going to be a session token:
   url.searchParams.set('X-Amz-Security-Token', sessionToken)
   url.searchParams.set('X-Amz-SignedHeaders', 'host')
   // Those two are present only for Multipart Uploads:
-  if (partNumber) url.searchParams.set('partNumber', partNumber)
+  if (partNumber) url.searchParams.set('partNumber', partNumber as string)
   if (uploadId) url.searchParams.set('uploadId', uploadId)
-  url.searchParams.set('x-id', partNumber && uploadId ? 'UploadPart' : 'PutObject')
+  url.searchParams.set(
+    'x-id',
+    partNumber && uploadId ? 'UploadPart' : 'PutObject',
+  )
 
   // Step 1: Create a canonical request
   const canonical = createCanonicalRequest({

+ 0 - 934
packages/@uppy/aws-s3-multipart/src/index.js

@@ -1,934 +0,0 @@
-import BasePlugin from '@uppy/core/lib/BasePlugin.js'
-import { RequestClient } from '@uppy/companion-client'
-import EventManager from '@uppy/utils/lib/EventManager'
-import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
-import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters'
-import { createAbortError } from '@uppy/utils/lib/AbortController'
-
-import MultipartUploader, { pausingUploadReason } from './MultipartUploader.js'
-import createSignedURL from './createSignedURL.js'
-import packageJson from '../package.json'
-
-function assertServerError (res) {
-  if (res && res.error) {
-    const error = new Error(res.message)
-    Object.assign(error, res.error)
-    throw error
-  }
-  return res
-}
-
-function removeMetadataFromURL (urlString) {
-  const urlObject = new URL(urlString)
-  urlObject.search = ''
-  urlObject.hash = ''
-  return urlObject.href
-}
-
-/**
- * Computes the expiry time for a request signed with temporary credentials. If
- * no expiration was provided, or an invalid value (e.g. in the past) is
- * provided, undefined is returned. This function assumes the client clock is in
- * sync with the remote server, which is a requirement for the signature to be
- * validated for AWS anyway.
- *
- * @param {import('../types/index.js').AwsS3STSResponse['credentials']} credentials
- * @returns {number | undefined}
- */
-function getExpiry (credentials) {
-  const expirationDate = credentials.Expiration
-  if (expirationDate) {
-    const timeUntilExpiry = Math.floor((new Date(expirationDate) - Date.now()) / 1000)
-    if (timeUntilExpiry > 9) {
-      return timeUntilExpiry
-    }
-  }
-  return undefined
-}
-
-function getAllowedMetadata ({ meta, allowedMetaFields, querify = false }) {
-  const metaFields = allowedMetaFields ?? Object.keys(meta)
-
-  if (!meta) return {}
-
-  return Object.fromEntries(
-    metaFields
-      .filter(key => meta[key] != null)
-      .map((key) => {
-        const realKey = querify ? `metadata[${key}]` : key
-        const value = String(meta[key])
-        return [realKey, value]
-      }),
-  )
-}
-
-function throwIfAborted (signal) {
-  if (signal?.aborted) { throw createAbortError('The operation was aborted', { cause: signal.reason }) }
-}
-
-class HTTPCommunicationQueue {
-  #abortMultipartUpload
-
-  #cache = new WeakMap()
-
-  #createMultipartUpload
-
-  #fetchSignature
-
-  #getUploadParameters
-
-  #listParts
-
-  #previousRetryDelay
-
-  #requests
-
-  #retryDelays
-
-  #sendCompletionRequest
-
-  #setS3MultipartState
-
-  #uploadPartBytes
-
-  #getFile
-
-  constructor (requests, options, setS3MultipartState, getFile) {
-    this.#requests = requests
-    this.#setS3MultipartState = setS3MultipartState
-    this.#getFile = getFile
-    this.setOptions(options)
-  }
-
-  setOptions (options) {
-    const requests = this.#requests
-
-    if ('abortMultipartUpload' in options) {
-      this.#abortMultipartUpload = requests.wrapPromiseFunction(options.abortMultipartUpload, { priority:1 })
-    }
-    if ('createMultipartUpload' in options) {
-      this.#createMultipartUpload = requests.wrapPromiseFunction(options.createMultipartUpload, { priority:-1 })
-    }
-    if ('signPart' in options) {
-      this.#fetchSignature = requests.wrapPromiseFunction(options.signPart)
-    }
-    if ('listParts' in options) {
-      this.#listParts = requests.wrapPromiseFunction(options.listParts)
-    }
-    if ('completeMultipartUpload' in options) {
-      this.#sendCompletionRequest = requests.wrapPromiseFunction(options.completeMultipartUpload, { priority:1 })
-    }
-    if ('retryDelays' in options) {
-      this.#retryDelays = options.retryDelays ?? []
-    }
-    if ('uploadPartBytes' in options) {
-      this.#uploadPartBytes = requests.wrapPromiseFunction(options.uploadPartBytes, { priority:Infinity })
-    }
-    if ('getUploadParameters' in options) {
-      this.#getUploadParameters = requests.wrapPromiseFunction(options.getUploadParameters)
-    }
-  }
-
-  async #shouldRetry (err, retryDelayIterator) {
-    const requests = this.#requests
-    const status = err?.source?.status
-
-    // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
-    // perhaps the rate limited queue, and dedupe all plugins with that.
-    if (status == null) {
-      return false
-    }
-    if (status === 403 && err.message === 'Request has expired') {
-      if (!requests.isPaused) {
-        // We don't want to exhaust the retryDelayIterator as long as there are
-        // more than one request in parallel, to give slower connection a chance
-        // to catch up with the expiry set in Companion.
-        if (requests.limit === 1 || this.#previousRetryDelay == null) {
-          const next = retryDelayIterator.next()
-          if (next == null || next.done) {
-            return false
-          }
-          // If there are more than 1 request done in parallel, the RLQ limit is
-          // decreased and the failed request is requeued after waiting for a bit.
-          // If there is only one request in parallel, the limit can't be
-          // decreased, so we iterate over `retryDelayIterator` as we do for
-          // other failures.
-          // `#previousRetryDelay` caches the value so we can re-use it next time.
-          this.#previousRetryDelay = next.value
-        }
-        // No need to stop the other requests, we just want to lower the limit.
-        requests.rateLimit(0)
-        await new Promise(resolve => setTimeout(resolve, this.#previousRetryDelay))
-      }
-    } else if (status === 429) {
-      // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
-      if (!requests.isPaused) {
-        const next = retryDelayIterator.next()
-        if (next == null || next.done) {
-          return false
-        }
-        requests.rateLimit(next.value)
-      }
-    } else if (status > 400 && status < 500 && status !== 409) {
-      // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
-      return false
-    } else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
-      // The navigator is offline, let's wait for it to come back online.
-      if (!requests.isPaused) {
-        requests.pause()
-        window.addEventListener('online', () => {
-          requests.resume()
-        }, { once: true })
-      }
-    } else {
-      // Other error code means the request can be retried later.
-      const next = retryDelayIterator.next()
-      if (next == null || next.done) {
-        return false
-      }
-      await new Promise(resolve => setTimeout(resolve, next.value))
-    }
-    return true
-  }
-
-  async getUploadId (file, signal) {
-    let cachedResult
-    // As the cache is updated asynchronously, there could be a race condition
-    // where we just miss a new result so we loop here until we get nothing back,
-    // at which point it's out turn to create a new cache entry.
-    while ((cachedResult = this.#cache.get(file.data)) != null) {
-      try {
-        return await cachedResult
-      } catch {
-        // In case of failure, we want to ignore the cached error.
-        // At this point, either there's a new cached value, or we'll exit the loop a create a new one.
-      }
-    }
-
-    const promise = this.#createMultipartUpload(this.#getFile(file), signal)
-
-    const abortPromise = () => {
-      promise.abort(signal.reason)
-      this.#cache.delete(file.data)
-    }
-    signal.addEventListener('abort', abortPromise, { once: true })
-    this.#cache.set(file.data, promise)
-    promise.then(async (result) => {
-      signal.removeEventListener('abort', abortPromise)
-      this.#setS3MultipartState(file, result)
-      this.#cache.set(file.data, result)
-    }, () => {
-      signal.removeEventListener('abort', abortPromise)
-      this.#cache.delete(file.data)
-    })
-
-    return promise
-  }
-
-  async abortFileUpload (file) {
-    const result = this.#cache.get(file.data)
-    if (result == null) {
-      // If the createMultipartUpload request never was made, we don't
-      // need to send the abortMultipartUpload request.
-      return
-    }
-    // Remove the cache entry right away for follow-up requests do not try to
-    // use the soon-to-be aborted chached values.
-    this.#cache.delete(file.data)
-    this.#setS3MultipartState(file, Object.create(null))
-    let awaitedResult
-    try {
-      awaitedResult = await result
-    } catch {
-      // If the cached result rejects, there's nothing to abort.
-      return
-    }
-    await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
-  }
-
-  async #nonMultipartUpload (file, chunk, signal) {
-    const {
-      method = 'POST',
-      url,
-      fields,
-      headers,
-    } = await this.#getUploadParameters(this.#getFile(file), { signal }).abortOn(signal)
-
-    let body
-    const data = chunk.getData()
-    if (method.toUpperCase() === 'POST') {
-      const formData = new FormData()
-      Object.entries(fields).forEach(([key, value]) => formData.set(key, value))
-      formData.set('file', data)
-      body = formData
-    } else {
-      body = data
-    }
-
-    const { onProgress, onComplete } = chunk
-
-    const result = await this.#uploadPartBytes({
-      signature: { url, headers, method },
-      body,
-      size: data.size,
-      onProgress,
-      onComplete,
-      signal,
-    }).abortOn(signal)
-
-    return 'location' in result ? result : {
-      location: removeMetadataFromURL(url),
-      ...result,
-    }
-  }
-
-  /**
-   * @param {import("@uppy/core").UppyFile} file
-   * @param {import("../types/chunk").Chunk[]} chunks
-   * @param {AbortSignal} signal
-   * @returns {Promise<void>}
-   */
-  async uploadFile (file, chunks, signal) {
-    throwIfAborted(signal)
-    if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
-      return this.#nonMultipartUpload(file, chunks[0], signal)
-    }
-    const { uploadId, key } = await this.getUploadId(file, signal)
-    throwIfAborted(signal)
-    try {
-      const parts = await Promise.all(chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)))
-      throwIfAborted(signal)
-      return await this.#sendCompletionRequest(
-        this.#getFile(file),
-        { key, uploadId, parts, signal },
-        signal,
-      ).abortOn(signal)
-    } catch (err) {
-      if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
-        // We purposefully don't wait for the promise and ignore its status,
-        // because we want the error `err` to bubble up ASAP to report it to the
-        // user. A failure to abort is not that big of a deal anyway.
-        this.abortFileUpload(file)
-      }
-      throw err
-    }
-  }
-
-  restoreUploadFile (file, uploadIdAndKey) {
-    this.#cache.set(file.data, uploadIdAndKey)
-  }
-
-  async resumeUploadFile (file, chunks, signal) {
-    throwIfAborted(signal)
-    if (chunks.length === 1 && chunks[0] != null && !chunks[0].shouldUseMultipart) {
-      return this.#nonMultipartUpload(file, chunks[0], signal)
-    }
-    const { uploadId, key } = await this.getUploadId(file, signal)
-    throwIfAborted(signal)
-    const alreadyUploadedParts = await this.#listParts(
-      this.#getFile(file),
-      { uploadId, key, signal },
-      signal,
-    ).abortOn(signal)
-    throwIfAborted(signal)
-    const parts = await Promise.all(
-      chunks
-        .map((chunk, i) => {
-          const partNumber = i + 1
-          const alreadyUploadedInfo = alreadyUploadedParts.find(({ PartNumber }) => PartNumber === partNumber)
-          if (alreadyUploadedInfo == null) {
-            return this.uploadChunk(file, partNumber, chunk, signal)
-          }
-          // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
-          chunk?.setAsUploaded?.()
-          return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
-        }),
-    )
-    throwIfAborted(signal)
-    return this.#sendCompletionRequest(
-      this.#getFile(file),
-      { key, uploadId, parts, signal },
-      signal,
-    ).abortOn(signal)
-  }
-
-  /**
-   *
-   * @param {import("@uppy/core").UppyFile} file
-   * @param {number} partNumber
-   * @param {import("../types/chunk").Chunk} chunk
-   * @param {AbortSignal} signal
-   * @returns {Promise<object>}
-   */
-  async uploadChunk (file, partNumber, chunk, signal) {
-    throwIfAborted(signal)
-    const { uploadId, key } = await this.getUploadId(file, signal)
-
-    const signatureRetryIterator = this.#retryDelays.values()
-    const chunkRetryIterator = this.#retryDelays.values()
-    const shouldRetrySignature = () => {
-      const next = signatureRetryIterator.next()
-      if (next == null || next.done) {
-        return null
-      }
-      return next.value
-    }
-
-    for (;;) {
-      throwIfAborted(signal)
-      const chunkData = chunk.getData()
-      const { onProgress, onComplete } = chunk
-      let signature
-
-      try {
-        signature = await this.#fetchSignature(this.#getFile(file), {
-          uploadId, key, partNumber, body: chunkData, signal,
-        }).abortOn(signal)
-      } catch (err) {
-        const timeout = shouldRetrySignature()
-        if (timeout == null || signal.aborted) {
-          throw err
-        }
-        await new Promise(resolve => setTimeout(resolve, timeout))
-        // eslint-disable-next-line no-continue
-        continue
-      }
-
-      throwIfAborted(signal)
-      try {
-        return {
-          PartNumber: partNumber,
-          ...await this.#uploadPartBytes({
-            signature, body: chunkData, size: chunkData.size, onProgress, onComplete, signal,
-          }).abortOn(signal),
-        }
-      } catch (err) {
-        if (!await this.#shouldRetry(err, chunkRetryIterator)) throw err
-      }
-    }
-  }
-}
-
-export default class AwsS3Multipart extends BasePlugin {
-  static VERSION = packageJson.version
-
-  #companionCommunicationQueue
-
-  #client
-
-  constructor (uppy, opts) {
-    super(uppy, opts)
-    this.type = 'uploader'
-    this.id = this.opts.id || 'AwsS3Multipart'
-    this.title = 'AWS S3 Multipart'
-    this.#client = new RequestClient(uppy, opts)
-
-    const defaultOptions = {
-      // TODO: null here means “include all”, [] means include none.
-      // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit
-      allowedMetaFields: null,
-      limit: 6,
-      shouldUseMultipart: (file) => file.size !== 0, // TODO: Switch default to:
-      // eslint-disable-next-line no-bitwise
-      // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100,
-      retryDelays: [0, 1000, 3000, 5000],
-      createMultipartUpload: this.createMultipartUpload.bind(this),
-      listParts: this.listParts.bind(this),
-      abortMultipartUpload: this.abortMultipartUpload.bind(this),
-      completeMultipartUpload: this.completeMultipartUpload.bind(this),
-      getTemporarySecurityCredentials: false,
-      signPart: opts?.getTemporarySecurityCredentials ? this.createSignedURL.bind(this) : this.signPart.bind(this),
-      uploadPartBytes: AwsS3Multipart.uploadPartBytes,
-      getUploadParameters: opts?.getTemporarySecurityCredentials
-        ? this.createSignedURL.bind(this)
-        : this.getUploadParameters.bind(this),
-      companionHeaders: {},
-    }
-
-    this.opts = { ...defaultOptions, ...opts }
-    if (opts?.prepareUploadParts != null && opts.signPart == null) {
-      this.opts.signPart = async (file, { uploadId, key, partNumber, body, signal }) => {
-        const { presignedUrls, headers } = await opts
-          .prepareUploadParts(file, { uploadId, key, parts: [{ number: partNumber, chunk: body }], signal })
-        return { url: presignedUrls?.[partNumber], headers: headers?.[partNumber] }
-      }
-    }
-
-    /**
-     * Simultaneous upload limiting is shared across all uploads with this plugin.
-     *
-     * @type {RateLimitedQueue}
-     */
-    this.requests = this.opts.rateLimitedQueue ?? new RateLimitedQueue(this.opts.limit)
-    this.#companionCommunicationQueue = new HTTPCommunicationQueue(
-      this.requests,
-      this.opts,
-      this.#setS3MultipartState,
-      this.#getFile,
-    )
-
-    this.uploaders = Object.create(null)
-    this.uploaderEvents = Object.create(null)
-    this.uploaderSockets = Object.create(null)
-  }
-
-  [Symbol.for('uppy test: getClient')] () { return this.#client }
-
-  setOptions (newOptions) {
-    this.#companionCommunicationQueue.setOptions(newOptions)
-    super.setOptions(newOptions)
-    this.#setCompanionHeaders()
-  }
-
-  /**
-   * Clean up all references for a file's upload: the MultipartUploader instance,
-   * any events related to the file, and the Companion WebSocket connection.
-   *
-   * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
-   * This should be done when the user cancels the upload, not when the upload is completed or errored.
-   */
-  resetUploaderReferences (fileID, opts = {}) {
-    if (this.uploaders[fileID]) {
-      this.uploaders[fileID].abort({ really: opts.abort || false })
-      this.uploaders[fileID] = null
-    }
-    if (this.uploaderEvents[fileID]) {
-      this.uploaderEvents[fileID].remove()
-      this.uploaderEvents[fileID] = null
-    }
-    if (this.uploaderSockets[fileID]) {
-      this.uploaderSockets[fileID].close()
-      this.uploaderSockets[fileID] = null
-    }
-  }
-
-  // TODO: make this a private method in the next major
-  assertHost (method) {
-    if (!this.opts.companionUrl) {
-      throw new Error(`Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`)
-    }
-  }
-
-  createMultipartUpload (file, signal) {
-    this.assertHost('createMultipartUpload')
-    throwIfAborted(signal)
-
-    const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields: this.opts.allowedMetaFields })
-
-    return this.#client.post('s3/multipart', {
-      filename: file.name,
-      type: file.type,
-      metadata,
-    }, { signal }).then(assertServerError)
-  }
-
-  listParts (file, { key, uploadId }, signal) {
-    this.assertHost('listParts')
-    throwIfAborted(signal)
-
-    const filename = encodeURIComponent(key)
-    return this.#client.get(`s3/multipart/${uploadId}?key=${filename}`, { signal })
-      .then(assertServerError)
-  }
-
-  completeMultipartUpload (file, { key, uploadId, parts }, signal) {
-    this.assertHost('completeMultipartUpload')
-    throwIfAborted(signal)
-
-    const filename = encodeURIComponent(key)
-    const uploadIdEnc = encodeURIComponent(uploadId)
-    return this.#client.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts }, { signal })
-      .then(assertServerError)
-  }
-
-  /**
-   * @type {import("../types").AwsS3STSResponse | Promise<import("../types").AwsS3STSResponse>}
-   */
-  #cachedTemporaryCredentials
-
-  async #getTemporarySecurityCredentials (options) {
-    throwIfAborted(options?.signal)
-
-    if (this.#cachedTemporaryCredentials == null) {
-      // We do not await it just yet, so concurrent calls do not try to override it:
-      if (this.opts.getTemporarySecurityCredentials === true) {
-        this.assertHost('getTemporarySecurityCredentials')
-        this.#cachedTemporaryCredentials = this.#client.get('s3/sts', null, options).then(assertServerError)
-      } else {
-        this.#cachedTemporaryCredentials = this.opts.getTemporarySecurityCredentials(options)
-      }
-      this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
-      setTimeout(() => {
-        // At half the time left before expiration, we clear the cache. That's
-        // an arbitrary tradeoff to limit the number of requests made to the
-        // remote while limiting the risk of using an expired token in case the
-        // clocks are not exactly synced.
-        // The HTTP cache should be configured to ensure a client doesn't request
-        // more tokens than it needs, but this timeout provides a second layer of
-        // security in case the HTTP cache is disabled or misconfigured.
-        this.#cachedTemporaryCredentials = null
-      }, (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500)
-    }
-
-    return this.#cachedTemporaryCredentials
-  }
-
-  async createSignedURL (file, options) {
-    const data = await this.#getTemporarySecurityCredentials(options)
-    const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
-
-    const { uploadId, key, partNumber, signal } = options
-
-    // Return an object in the correct shape.
-    return {
-      method: 'PUT',
-      expires,
-      fields: {},
-      url: `${await createSignedURL({
-        accountKey: data.credentials.AccessKeyId,
-        accountSecret: data.credentials.SecretAccessKey,
-        sessionToken: data.credentials.SessionToken,
-        expires,
-        bucketName: data.bucket,
-        Region: data.region,
-        Key: key ?? `${crypto.randomUUID()}-${file.name}`,
-        uploadId,
-        partNumber,
-        signal,
-      })}`,
-      // Provide content type header required by S3
-      headers: {
-        'Content-Type': file.type,
-      },
-    }
-  }
-
-  signPart (file, { uploadId, key, partNumber, signal }) {
-    this.assertHost('signPart')
-    throwIfAborted(signal)
-
-    if (uploadId == null || key == null || partNumber == null) {
-      throw new Error('Cannot sign without a key, an uploadId, and a partNumber')
-    }
-
-    const filename = encodeURIComponent(key)
-    return this.#client.get(`s3/multipart/${uploadId}/${partNumber}?key=${filename}`, { signal })
-      .then(assertServerError)
-  }
-
-  abortMultipartUpload (file, { key, uploadId }, signal) {
-    this.assertHost('abortMultipartUpload')
-
-    const filename = encodeURIComponent(key)
-    const uploadIdEnc = encodeURIComponent(uploadId)
-    return this.#client.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { signal })
-      .then(assertServerError)
-  }
-
-  getUploadParameters (file, options) {
-    const { meta } = file
-    const { type, name: filename } = meta
-    const metadata = getAllowedMetadata({ meta, allowedMetaFields: this.opts.allowedMetaFields, querify: true })
-
-    const query = new URLSearchParams({ filename, type, ...metadata })
-
-    return this.#client.get(`s3/params?${query}`, options)
-  }
-
-  static async uploadPartBytes ({ signature: { url, expires, headers, method = 'PUT' }, body, size = body.size, onProgress, onComplete, signal }) {
-    throwIfAborted(signal)
-
-    if (url == null) {
-      throw new Error('Cannot upload to an undefined URL')
-    }
-
-    return new Promise((resolve, reject) => {
-      const xhr = new XMLHttpRequest()
-      xhr.open(method, url, true)
-      if (headers) {
-        Object.keys(headers).forEach((key) => {
-          xhr.setRequestHeader(key, headers[key])
-        })
-      }
-      xhr.responseType = 'text'
-      if (typeof expires === 'number') {
-        xhr.timeout = expires * 1000
-      }
-
-      function onabort () {
-        xhr.abort()
-      }
-      function cleanup () {
-        signal.removeEventListener('abort', onabort)
-      }
-      signal.addEventListener('abort', onabort)
-
-      xhr.upload.addEventListener('progress', (ev) => {
-        onProgress(ev)
-      })
-
-      xhr.addEventListener('abort', () => {
-        cleanup()
-
-        reject(createAbortError())
-      })
-
-      xhr.addEventListener('timeout', () => {
-        cleanup()
-
-        const error = new Error('Request has expired')
-        error.source = { status: 403 }
-        reject(error)
-      })
-      xhr.addEventListener('load', (ev) => {
-        cleanup()
-
-        if (ev.target.status === 403 && ev.target.responseText.includes('<Message>Request has expired</Message>')) {
-          const error = new Error('Request has expired')
-          error.source = ev.target
-          reject(error)
-          return
-        } if (ev.target.status < 200 || ev.target.status >= 300) {
-          const error = new Error('Non 2xx')
-          error.source = ev.target
-          reject(error)
-          return
-        }
-
-        // todo make a proper onProgress API (breaking change)
-        onProgress?.({ loaded: size, lengthComputable: true })
-
-        // NOTE This must be allowed by CORS.
-        const etag = ev.target.getResponseHeader('ETag')
-        const location = ev.target.getResponseHeader('Location')
-
-        if (method.toUpperCase() === 'POST' && location === null) {
-          // Not being able to read the Location header is not a fatal error.
-          // eslint-disable-next-line no-console
-          console.warn('AwsS3/Multipart: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.')
-        }
-        if (etag === null) {
-          reject(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
-          return
-        }
-
-        onComplete?.(etag)
-        resolve({
-          ETag: etag,
-          ...(location ? { location } : undefined),
-        })
-      })
-
-      xhr.addEventListener('error', (ev) => {
-        cleanup()
-
-        const error = new Error('Unknown error')
-        error.source = ev.target
-        reject(error)
-      })
-
-      xhr.send(body)
-    })
-  }
-
-  #setS3MultipartState = (file, { key, uploadId }) => {
-    const cFile = this.uppy.getFile(file.id)
-    if (cFile == null) {
-      // file was removed from store
-      return
-    }
-
-    this.uppy.setFileState(file.id, {
-      s3Multipart: {
-        ...cFile.s3Multipart,
-        key,
-        uploadId,
-      },
-    })
-  }
-
-  #getFile = (file) => {
-    return this.uppy.getFile(file.id) || file
-  }
-
-  #uploadLocalFile (file) {
-    return new Promise((resolve, reject) => {
-      const onProgress = (bytesUploaded, bytesTotal) => {
-        this.uppy.emit('upload-progress', this.uppy.getFile(file.id), {
-          uploader: this,
-          bytesUploaded,
-          bytesTotal,
-        })
-      }
-
-      const onError = (err) => {
-        this.uppy.log(err)
-        this.uppy.emit('upload-error', file, err)
-
-        this.resetUploaderReferences(file.id)
-        reject(err)
-      }
-
-      const onSuccess = (result) => {
-        const uploadResp = {
-          body: {
-            ...result,
-          },
-          uploadURL: result.location,
-        }
-
-        this.resetUploaderReferences(file.id)
-
-        this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
-
-        if (result.location) {
-          this.uppy.log(`Download ${file.name} from ${result.location}`)
-        }
-
-        resolve()
-      }
-
-      const onPartComplete = (part) => {
-        this.uppy.emit('s3-multipart:part-uploaded', this.#getFile(file), part)
-      }
-
-      const upload = new MultipartUploader(file.data, {
-        // .bind to pass the file object to each handler.
-        companionComm: this.#companionCommunicationQueue,
-
-        log: (...args) => this.uppy.log(...args),
-        getChunkSize: this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null,
-
-        onProgress,
-        onError,
-        onSuccess,
-        onPartComplete,
-
-        file,
-        shouldUseMultipart: this.opts.shouldUseMultipart,
-
-        ...file.s3Multipart,
-      })
-
-      this.uploaders[file.id] = upload
-      const eventManager = new EventManager(this.uppy)
-      this.uploaderEvents[file.id] = eventManager
-
-      eventManager.onFileRemove(file.id, (removed) => {
-        upload.abort()
-        this.resetUploaderReferences(file.id, { abort: true })
-        resolve(`upload ${removed.id} was removed`)
-      })
-
-      eventManager.onCancelAll(file.id, ({ reason } = {}) => {
-        if (reason === 'user') {
-          upload.abort()
-          this.resetUploaderReferences(file.id, { abort: true })
-        }
-        resolve(`upload ${file.id} was canceled`)
-      })
-
-      eventManager.onFilePause(file.id, (isPaused) => {
-        if (isPaused) {
-          upload.pause()
-        } else {
-          upload.start()
-        }
-      })
-
-      eventManager.onPauseAll(file.id, () => {
-        upload.pause()
-      })
-
-      eventManager.onResumeAll(file.id, () => {
-        upload.start()
-      })
-
-      upload.start()
-    })
-  }
-
-  // eslint-disable-next-line class-methods-use-this
-  #getCompanionClientArgs (file) {
-    return {
-      ...file.remote.body,
-      protocol: 's3-multipart',
-      size: file.data.size,
-      metadata: file.meta,
-    }
-  }
-
-  #upload = async (fileIDs) => {
-    if (fileIDs.length === 0) return undefined
-
-    const files = this.uppy.getFilesByIds(fileIDs)
-    const filesFiltered = filterNonFailedFiles(files)
-    const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
-
-    this.uppy.emit('upload-start', filesToEmit)
-
-    const promises = filesFiltered.map((file) => {
-      if (file.isRemote) {
-        const getQueue = () => this.requests
-        this.#setResumableUploadsCapability(false)
-        const controller = new AbortController()
-
-        const removedHandler = (removedFile) => {
-          if (removedFile.id === file.id) controller.abort()
-        }
-        this.uppy.on('file-removed', removedHandler)
-
-        const uploadPromise = this.uppy.getRequestClientForFile(file).uploadRemoteFile(
-          file,
-          this.#getCompanionClientArgs(file),
-          { signal: controller.signal, getQueue },
-        )
-
-        this.requests.wrapSyncFunction(() => {
-          this.uppy.off('file-removed', removedHandler)
-        }, { priority: -1 })()
-
-        return uploadPromise
-      }
-
-      return this.#uploadLocalFile(file)
-    })
-
-    const upload = await Promise.all(promises)
-    // After the upload is done, another upload may happen with only local files.
-    // We reset the capability so that the next upload can use resumable uploads.
-    this.#setResumableUploadsCapability(true)
-    return upload
-  }
-
-  #setCompanionHeaders = () => {
-    this.#client.setCompanionHeaders(this.opts.companionHeaders)
-  }
-
-  #setResumableUploadsCapability = (boolean) => {
-    const { capabilities } = this.uppy.getState()
-    this.uppy.setState({
-      capabilities: {
-        ...capabilities,
-        resumableUploads: boolean,
-      },
-    })
-  }
-
-  #resetResumableCapability = () => {
-    this.#setResumableUploadsCapability(true)
-  }
-
-  install () {
-    this.#setResumableUploadsCapability(true)
-    this.uppy.addPreProcessor(this.#setCompanionHeaders)
-    this.uppy.addUploader(this.#upload)
-    this.uppy.on('cancel-all', this.#resetResumableCapability)
-  }
-
-  uninstall () {
-    this.uppy.removePreProcessor(this.#setCompanionHeaders)
-    this.uppy.removeUploader(this.#upload)
-    this.uppy.off('cancel-all', this.#resetResumableCapability)
-  }
-}

+ 199 - 128
packages/@uppy/aws-s3-multipart/src/index.test.js → packages/@uppy/aws-s3-multipart/src/index.test.ts

@@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import 'whatwg-fetch'
 import nock from 'nock'
 import Core from '@uppy/core'
-import AwsS3Multipart from './index.js'
+import AwsS3Multipart from './index.ts'
+import type { Body } from './utils.ts'
 
 const KB = 1024
 const MB = KB * KB
@@ -12,36 +13,41 @@ describe('AwsS3Multipart', () => {
   beforeEach(() => nock.disableNetConnect())
 
   it('Registers AwsS3Multipart upload plugin', () => {
-    const core = new Core()
+    const core = new Core<any, Body>()
     core.use(AwsS3Multipart)
 
-    const pluginNames = core[Symbol.for('uppy test: getPlugins')]('uploader').map((plugin) => plugin.constructor.name)
+    // @ts-expect-error private property
+    const pluginNames = core[Symbol.for('uppy test: getPlugins')](
+      'uploader',
+    ).map((plugin: AwsS3Multipart<any, Body>) => plugin.constructor.name)
     expect(pluginNames).toContain('AwsS3Multipart')
   })
 
   describe('companionUrl assertion', () => {
     it('Throws an error for main functions if configured without companionUrl', () => {
-      const core = new Core()
+      const core = new Core<any, Body>()
       core.use(AwsS3Multipart)
-      const awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
 
       const err = 'Expected a `companionUrl` option'
       const file = {}
       const opts = {}
 
-      expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(
-        err,
-      )
+      expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err)
       expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err)
-      expect(() => awsS3Multipart.opts.completeMultipartUpload(file, opts)).toThrow(err)
-      expect(() => awsS3Multipart.opts.abortMultipartUpload(file, opts)).toThrow(err)
+      expect(() =>
+        awsS3Multipart.opts.completeMultipartUpload(file, opts),
+      ).toThrow(err)
+      expect(() =>
+        awsS3Multipart.opts.abortMultipartUpload(file, opts),
+      ).toThrow(err)
       expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err)
     })
   })
 
   describe('non-multipart upload', () => {
     it('should handle POST uploads', async () => {
-      const core = new Core()
+      const core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
         shouldUseMultipart: false,
         limit: 0,
@@ -71,7 +77,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -87,6 +93,7 @@ describe('AwsS3Multipart', () => {
           ETag: 'test',
           location: 'http://example.com',
         },
+        status: 200,
         uploadURL: 'http://example.com',
       })
 
@@ -95,11 +102,11 @@ describe('AwsS3Multipart', () => {
   })
 
   describe('without companionUrl (custom main functions)', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, Body>
+    let awsS3Multipart: AwsS3Multipart<any, Body>
 
     beforeEach(() => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
         limit: 0,
         createMultipartUpload: vi.fn(() => {
@@ -110,17 +117,19 @@ describe('AwsS3Multipart', () => {
         }),
         completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
         abortMultipartUpload: vi.fn(),
-        prepareUploadParts: vi.fn(async (file, { parts }) => {
-          const presignedUrls = {}
-          parts.forEach(({ number }) => {
-            presignedUrls[
-              number
-            ] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`
-          })
-          return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } }
-        }),
+        prepareUploadParts: vi.fn(
+          async (file, { parts }: { parts: { number: number }[] }) => {
+            const presignedUrls: Record<number, string> = {}
+            parts.forEach(({ number }) => {
+              presignedUrls[number] =
+                `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`
+            })
+            return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } }
+          },
+        ),
+        listParts: undefined as any,
       })
-      awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
     })
 
     it('Calls the prepareUploadParts function totalChunks / limit times', async () => {
@@ -137,15 +146,23 @@ describe('AwsS3Multipart', () => {
       const fileSize = 5 * MB + 1 * MB
 
       scope
-        .options((uri) => uri.includes('test/upload/multitest.dat?partNumber=1'))
-        .reply(function replyFn () {
-          expect(this.req.headers['access-control-request-headers']).toEqual('Content-MD5')
+        .options((uri) =>
+          uri.includes('test/upload/multitest.dat?partNumber=1'),
+        )
+        .reply(function replyFn() {
+          expect(this.req.headers['access-control-request-headers']).toEqual(
+            'Content-MD5',
+          )
           return [200, '']
         })
       scope
-        .options((uri) => uri.includes('test/upload/multitest.dat?partNumber=2'))
-        .reply(function replyFn () {
-          expect(this.req.headers['access-control-request-headers']).toBeUndefined()
+        .options((uri) =>
+          uri.includes('test/upload/multitest.dat?partNumber=2'),
+        )
+        .reply(function replyFn() {
+          expect(
+            this.req.headers['access-control-request-headers'],
+          ).toBeUndefined()
           return [200, '']
         })
       scope
@@ -159,7 +176,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -167,7 +184,7 @@ describe('AwsS3Multipart', () => {
       await core.upload()
 
       expect(
-        awsS3Multipart.opts.prepareUploadParts.mock.calls.length,
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
       ).toEqual(2)
 
       scope.done()
@@ -201,14 +218,17 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
 
       await core.upload()
 
-      function validatePartData ({ parts }, expected) {
+      function validatePartData(
+        { parts }: { parts: { number: number; chunk: unknown }[] },
+        expected: number[],
+      ) {
         expect(parts.map((part) => part.number)).toEqual(expected)
 
         for (const part of parts) {
@@ -216,13 +236,25 @@ describe('AwsS3Multipart', () => {
         }
       }
 
-      expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10)
+      expect(
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
+      ).toEqual(10)
 
-      validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[0][1], [1])
-      validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[1][1], [2])
-      validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[2][1], [3])
+      validatePartData(
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[0][1],
+        [1],
+      )
+      validatePartData(
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[1][1],
+        [2],
+      )
+      validatePartData(
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls[2][1],
+        [3],
+      )
 
-      const completeCall = awsS3Multipart.opts.completeMultipartUpload.mock.calls[0][1]
+      const completeCall = (awsS3Multipart.opts as any).completeMultipartUpload
+        .mock.calls[0][1]
 
       expect(completeCall.parts).toEqual([
         { ETag: 'test', PartNumber: 1 },
@@ -254,13 +286,21 @@ describe('AwsS3Multipart', () => {
         .options((uri) => uri.includes('test/upload/multitest.dat'))
         .reply(200, '')
       scope
-        .put((uri) => uri.includes('test/upload/multitest.dat') && !uri.includes('partNumber=7'))
+        .put(
+          (uri) =>
+            uri.includes('test/upload/multitest.dat') &&
+            !uri.includes('partNumber=7'),
+        )
         .reply(200, '', { ETag: 'test' })
 
       // Fail the part 7 upload once, then let it succeed
       let calls = 0
       scope
-        .put((uri) => uri.includes('test/upload/multitest.dat') && uri.includes('partNumber=7'))
+        .put(
+          (uri) =>
+            uri.includes('test/upload/multitest.dat') &&
+            uri.includes('partNumber=7'),
+        )
         .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }]))
 
       scope.persist()
@@ -271,14 +311,25 @@ describe('AwsS3Multipart', () => {
       awsS3Multipart.setOptions({
         retryDelays: [10],
         createMultipartUpload: vi.fn((file) => {
-          const multipartUploader = awsS3Multipart.uploaders[file.id]
+          // @ts-expect-error protected property
+          const multipartUploader = awsS3Multipart.uploaders[file.id]!
           const testChunkState = multipartUploader.chunkState[6]
           let busy = false
           let done = false
-          busySpy = vi.fn((value) => { busy = value })
-          doneSpy = vi.fn((value) => { done = value })
-          Object.defineProperty(testChunkState, 'busy', { get: () => busy, set: busySpy })
-          Object.defineProperty(testChunkState, 'done', { get: () => done, set: doneSpy })
+          busySpy = vi.fn((value) => {
+            busy = value
+          })
+          doneSpy = vi.fn((value) => {
+            done = value
+          })
+          Object.defineProperty(testChunkState, 'busy', {
+            get: () => busy,
+            set: busySpy,
+          })
+          Object.defineProperty(testChunkState, 'done', {
+            get: () => done,
+            set: doneSpy,
+          })
 
           return {
             uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
@@ -291,7 +342,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -299,19 +350,23 @@ describe('AwsS3Multipart', () => {
       await core.upload()
 
       // The chunk should be marked as done once
-      expect(doneSpy.mock.calls.length).toEqual(1)
-      expect(doneSpy.mock.calls[0][0]).toEqual(true)
+      expect(doneSpy!.mock.calls.length).toEqual(1)
+      expect(doneSpy!.mock.calls[0][0]).toEqual(true)
 
       // Any changes that set busy to false should only happen after the chunk has been marked done,
       // otherwise a race condition occurs (see PR #3955)
-      const doneCallOrderNumber = doneSpy.mock.invocationCallOrder[0]
-      for (const [index, callArgs] of busySpy.mock.calls.entries()) {
+      const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0]
+      for (const [index, callArgs] of busySpy!.mock.calls.entries()) {
         if (callArgs[0] === false) {
-          expect(busySpy.mock.invocationCallOrder[index]).toBeGreaterThan(doneCallOrderNumber)
+          expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan(
+            doneCallOrderNumber,
+          )
         }
       }
 
-      expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10)
+      expect(
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
+      ).toEqual(10)
     })
   })
 
@@ -323,36 +378,41 @@ describe('AwsS3Multipart', () => {
       }
     })
 
-    const signPart = vi
-      .fn(async (file, { partNumber }) => {
-        return { url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` }
-      })
+    const signPart = vi.fn(async (file, { partNumber }) => {
+      return {
+        url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
+      }
+    })
 
     const uploadPartBytes = vi.fn()
 
-    afterEach(() => vi.clearAllMocks())
+    afterEach(() => {
+      vi.clearAllMocks()
+    })
 
     it('retries uploadPartBytes when it fails once', async () => {
-      const core = new Core()
-        .use(AwsS3Multipart, {
-          createMultipartUpload,
-          completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
+      const core = new Core<any, Body>().use(AwsS3Multipart, {
+        createMultipartUpload,
+        completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
+        abortMultipartUpload: vi.fn(() => {
           // eslint-disable-next-line no-throw-literal
-          abortMultipartUpload: vi.fn(() => { throw 'should ignore' }),
-          signPart,
-          uploadPartBytes:
-            uploadPartBytes
-              // eslint-disable-next-line prefer-promise-reject-errors
-              .mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })),
-        })
-      const awsS3Multipart = core.getPlugin('AwsS3Multipart')
+          throw 'should ignore'
+        }),
+        signPart,
+        uploadPartBytes: uploadPartBytes.mockImplementationOnce(() =>
+          // eslint-disable-next-line prefer-promise-reject-errors
+          Promise.reject({ source: { status: 500 } }),
+        ),
+        listParts: undefined as any,
+      })
+      const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
       const fileSize = 5 * MB + 1 * MB
 
       core.addFile({
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -363,18 +423,19 @@ describe('AwsS3Multipart', () => {
     })
 
     it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
-      const core = new Core()
-        .use(AwsS3Multipart, {
-          retryDelays: [10],
-          createMultipartUpload,
-          completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
-          abortMultipartUpload: vi.fn(),
-          signPart,
-          uploadPartBytes: uploadPartBytes
-            // eslint-disable-next-line prefer-promise-reject-errors
-            .mockImplementation(() => Promise.reject({ source: { status: 500 } })),
-        })
-      const awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      const core = new Core<any, Body>().use(AwsS3Multipart, {
+        retryDelays: [10],
+        createMultipartUpload,
+        completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
+        abortMultipartUpload: vi.fn(),
+        signPart,
+        uploadPartBytes: uploadPartBytes.mockImplementation(() =>
+          // eslint-disable-next-line prefer-promise-reject-errors
+          Promise.reject({ source: { status: 500 } }),
+        ),
+        listParts: undefined as any,
+      })
+      const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
       const fileSize = 5 * MB + 1 * MB
       const mock = vi.fn()
       core.on('upload-error', mock)
@@ -383,7 +444,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -396,19 +457,20 @@ describe('AwsS3Multipart', () => {
   })
 
   describe('dynamic companionHeader', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, any>
+    let awsS3Multipart: AwsS3Multipart<any, any>
     const oldToken = 'old token'
     const newToken = 'new token'
 
     beforeEach(() => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
+        companionUrl: '',
         companionHeaders: {
           authorization: oldToken,
         },
       })
-      awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
     })
 
     it('companionHeader is updated before uploading file', async () => {
@@ -420,23 +482,29 @@ describe('AwsS3Multipart', () => {
 
       await core.upload()
 
+      // @ts-expect-error private property
       const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
 
-      expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken)
+      expect(
+        client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
+      ).toEqual(newToken)
     })
   })
 
   describe('dynamic companionHeader using setOption', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, Body>
+    let awsS3Multipart: AwsS3Multipart<any, Body>
     const newToken = 'new token'
 
     it('companionHeader is updated before uploading file', async () => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart)
       /* Set up preprocessor */
       core.addPreProcessor(() => {
-        awsS3Multipart = core.getPlugin('AwsS3Multipart')
+        awsS3Multipart = core.getPlugin('AwsS3Multipart') as AwsS3Multipart<
+          any,
+          Body
+        >
         awsS3Multipart.setOptions({
           companionHeaders: {
             authorization: newToken,
@@ -446,15 +514,18 @@ describe('AwsS3Multipart', () => {
 
       await core.upload()
 
+      // @ts-expect-error private property
       const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
 
-      expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken)
+      expect(
+        client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
+      ).toEqual(newToken)
     })
   })
 
   describe('file metadata across custom main functions', () => {
-    let core
-    const createMultipartUpload = vi.fn(file => {
+    let core: Core<any, Body>
+    const createMultipartUpload = vi.fn((file) => {
       core.setFileMeta(file.id, {
         ...file.meta,
         createMultipartUpload: true,
@@ -487,8 +558,10 @@ describe('AwsS3Multipart', () => {
         listParts: true,
       })
 
-      const partKeys = Object.keys(file.meta).filter(metaKey => metaKey.startsWith('part'))
-      return partKeys.map(metaKey => ({
+      const partKeys = Object.keys(file.meta).filter((metaKey) =>
+        metaKey.startsWith('part'),
+      )
+      return partKeys.map((metaKey) => ({
         PartNumber: file.meta[metaKey],
         ETag: metaKey,
         Size: 5 * MB,
@@ -508,7 +581,6 @@ describe('AwsS3Multipart', () => {
       expect(file.meta.createMultipartUpload).toBe(true)
       expect(file.meta.signPart).toBe(true)
       expect(file.meta.abortingPart).toBe(5)
-      return {}
     })
 
     beforeEach(() => {
@@ -520,14 +592,13 @@ describe('AwsS3Multipart', () => {
     })
 
     it('preserves file metadata if upload is completed', async () => {
-      core = new Core()
-        .use(AwsS3Multipart, {
-          createMultipartUpload,
-          signPart,
-          listParts,
-          completeMultipartUpload,
-          abortMultipartUpload,
-        })
+      core = new Core<any, Body>().use(AwsS3Multipart, {
+        createMultipartUpload,
+        signPart,
+        listParts,
+        completeMultipartUpload,
+        abortMultipartUpload,
+      })
 
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
@@ -545,7 +616,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -565,7 +636,9 @@ describe('AwsS3Multipart', () => {
             abortingPart: partData.partNumber,
           })
           core.removeFile(file.id)
-          return {}
+          return {
+            url: undefined as any as string,
+          }
         }
 
         core.setFileMeta(file.id, {
@@ -579,14 +652,13 @@ describe('AwsS3Multipart', () => {
         }
       })
 
-      core = new Core()
-        .use(AwsS3Multipart, {
-          createMultipartUpload,
-          signPart: signPartWithAbort,
-          listParts,
-          completeMultipartUpload,
-          abortMultipartUpload,
-        })
+      core = new Core<any, Body>().use(AwsS3Multipart, {
+        createMultipartUpload,
+        signPart: signPartWithAbort,
+        listParts,
+        completeMultipartUpload,
+        abortMultipartUpload,
+      })
 
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
@@ -604,7 +676,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })
@@ -649,14 +721,13 @@ describe('AwsS3Multipart', () => {
         }
       })
 
-      core = new Core()
-        .use(AwsS3Multipart, {
-          createMultipartUpload,
-          signPart: signPartWithPause,
-          listParts,
-          completeMultipartUpload: completeMultipartUploadAfterPause,
-          abortMultipartUpload,
-        })
+      core = new Core<any, Body>().use(AwsS3Multipart, {
+        createMultipartUpload,
+        signPart: signPartWithPause,
+        listParts,
+        completeMultipartUpload: completeMultipartUploadAfterPause,
+        abortMultipartUpload,
+      })
 
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
@@ -674,7 +745,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         name: 'multitest.dat',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
         }),
       })

+ 1007 - 0
packages/@uppy/aws-s3-multipart/src/index.ts

@@ -0,0 +1,1007 @@
+import BasePlugin, {
+  type DefinePluginOpts,
+  type PluginOpts,
+} from '@uppy/core/lib/BasePlugin.js'
+import { RequestClient } from '@uppy/companion-client'
+import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider.ts'
+import type { Body as _Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
+import type { Uppy } from '@uppy/core'
+import EventManager from '@uppy/core/lib/EventManager.js'
+import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
+import {
+  filterNonFailedFiles,
+  filterFilesToEmitUploadStarted,
+} from '@uppy/utils/lib/fileFilters'
+import { createAbortError } from '@uppy/utils/lib/AbortController'
+
+import MultipartUploader from './MultipartUploader.ts'
+import { throwIfAborted } from './utils.ts'
+import type {
+  UploadResult,
+  UploadResultWithSignal,
+  MultipartUploadResultWithSignal,
+  UploadPartBytesResult,
+  Body,
+} from './utils.ts'
+import createSignedURL from './createSignedURL.ts'
+import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.ts'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore We don't want TS to generate types for the package.json
+import packageJson from '../package.json'
+
+interface MultipartFile<M extends Meta, B extends Body> extends UppyFile<M, B> {
+  s3Multipart: UploadResult
+}
+
+type PartUploadedCallback<M extends Meta, B extends _Body> = (
+  file: UppyFile<M, B>,
+  part: { PartNumber: number; ETag: string },
+) => void
+
+declare module '@uppy/core' {
+  export interface UppyEventMap<M extends Meta, B extends _Body> {
+    's3-multipart:part-uploaded': PartUploadedCallback<M, B>
+  }
+}
+
+function assertServerError<T>(res: T): T {
+  if ((res as any)?.error) {
+    const error = new Error((res as any).message)
+    Object.assign(error, (res as any).error)
+    throw error
+  }
+  return res
+}
+
+export interface AwsS3STSResponse {
+  credentials: {
+    AccessKeyId: string
+    SecretAccessKey: string
+    SessionToken: string
+    Expiration?: string
+  }
+  bucket: string
+  region: string
+}
+
+/**
+ * Computes the expiry time for a request signed with temporary credentials. If
+ * no expiration was provided, or an invalid value (e.g. in the past) is
+ * provided, undefined is returned. This function assumes the client clock is in
+ * sync with the remote server, which is a requirement for the signature to be
+ * validated for AWS anyway.
+ */
+function getExpiry(
+  credentials: AwsS3STSResponse['credentials'],
+): number | undefined {
+  const expirationDate = credentials.Expiration
+  if (expirationDate) {
+    const timeUntilExpiry = Math.floor(
+      ((new Date(expirationDate) as any as number) - Date.now()) / 1000,
+    )
+    if (timeUntilExpiry > 9) {
+      return timeUntilExpiry
+    }
+  }
+  return undefined
+}
+
+function getAllowedMetadata<M extends Record<string, any>>({
+  meta,
+  allowedMetaFields,
+  querify = false,
+}: {
+  meta: M
+  allowedMetaFields?: string[] | null
+  querify?: boolean
+}) {
+  const metaFields = allowedMetaFields ?? Object.keys(meta)
+
+  if (!meta) return {}
+
+  return Object.fromEntries(
+    metaFields
+      .filter((key) => meta[key] != null)
+      .map((key) => {
+        const realKey = querify ? `metadata[${key}]` : key
+        const value = String(meta[key])
+        return [realKey, value]
+      }),
+  )
+}
+
+type MaybePromise<T> = T | Promise<T>
+
+type SignPartOptions = {
+  uploadId: string
+  key: string
+  partNumber: number
+  body: Blob
+  signal?: AbortSignal
+}
+
+export type AwsS3UploadParameters =
+  | {
+      method: 'POST'
+      url: string
+      fields: Record<string, string>
+      expires?: number
+      headers?: Record<string, string>
+    }
+  | {
+      method?: 'PUT'
+      url: string
+      fields?: Record<string, never>
+      expires?: number
+      headers?: Record<string, string>
+    }
+
+export interface AwsS3Part {
+  PartNumber?: number
+  Size?: number
+  ETag?: string
+}
+
+type AWSS3WithCompanion = {
+  companionUrl: string
+  companionHeaders?: Record<string, string>
+  companionCookiesRule?: string
+  getTemporarySecurityCredentials?: true
+}
+type AWSS3WithoutCompanion = {
+  getTemporarySecurityCredentials?: (options?: {
+    signal?: AbortSignal
+  }) => MaybePromise<AwsS3STSResponse>
+  uploadPartBytes?: (options: {
+    signature: AwsS3UploadParameters
+    body: FormData | Blob
+    size?: number
+    onProgress: any
+    onComplete: any
+    signal?: AbortSignal
+  }) => Promise<UploadPartBytesResult>
+}
+
+type AWSS3NonMultipartWithCompanionMandatory = {
+  // No related options
+}
+
+type AWSS3NonMultipartWithoutCompanionMandatory<
+  M extends Meta,
+  B extends Body,
+> = {
+  getUploadParameters: (
+    file: UppyFile<M, B>,
+    options: RequestOptions,
+  ) => MaybePromise<AwsS3UploadParameters>
+}
+type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion &
+  AWSS3NonMultipartWithCompanionMandatory & {
+    shouldUseMultipart: false
+  }
+
+type AWSS3NonMultipartWithoutCompanion<
+  M extends Meta,
+  B extends Body,
+> = AWSS3WithoutCompanion &
+  AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
+    shouldUseMultipart: false
+  }
+
+type AWSS3MultipartWithoutCompanionMandatorySignPart<
+  M extends Meta,
+  B extends Body,
+> = {
+  signPart: (
+    file: UppyFile<M, B>,
+    opts: SignPartOptions,
+  ) => MaybePromise<AwsS3UploadParameters>
+}
+/** @deprecated Use signPart instead */
+type AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<
+  M extends Meta,
+  B extends Body,
+> = {
+  /** @deprecated Use signPart instead */
+  prepareUploadParts: (
+    file: UppyFile<M, B>,
+    partData: {
+      uploadId: string
+      key: string
+      parts: [{ number: number; chunk: Blob }]
+      signal?: AbortSignal
+    },
+  ) => MaybePromise<{
+    presignedUrls: Record<number, string>
+    headers?: Record<number, Record<string, string>>
+  }>
+}
+type AWSS3MultipartWithoutCompanionMandatory<M extends Meta, B extends Body> = {
+  getChunkSize?: (file: UppyFile<M, B>) => number
+  createMultipartUpload: (file: UppyFile<M, B>) => MaybePromise<UploadResult>
+  listParts: (
+    file: UppyFile<M, B>,
+    opts: UploadResultWithSignal,
+  ) => MaybePromise<AwsS3Part[]>
+  abortMultipartUpload: (
+    file: UppyFile<M, B>,
+    opts: UploadResultWithSignal,
+  ) => MaybePromise<void>
+  completeMultipartUpload: (
+    file: UppyFile<M, B>,
+    opts: {
+      uploadId: string
+      key: string
+      parts: AwsS3Part[]
+      signal: AbortSignal
+    },
+  ) => MaybePromise<{ location?: string }>
+} & (
+  | AWSS3MultipartWithoutCompanionMandatorySignPart<M, B>
+  | AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<M, B>
+)
+
+type AWSS3MultipartWithoutCompanion<
+  M extends Meta,
+  B extends Body,
+> = AWSS3WithoutCompanion &
+  AWSS3MultipartWithoutCompanionMandatory<M, B> & {
+    shouldUseMultipart?: true
+  }
+
+type AWSS3MultipartWithCompanion<
+  M extends Meta,
+  B extends Body,
+> = AWSS3WithCompanion &
+  Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> & {
+    shouldUseMultipart?: true
+  }
+
+type AWSS3MaybeMultipartWithCompanion<
+  M extends Meta,
+  B extends Body,
+> = AWSS3WithCompanion &
+  Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> &
+  AWSS3NonMultipartWithCompanionMandatory & {
+    shouldUseMultipart: (file: UppyFile<M, B>) => boolean
+  }
+
+type AWSS3MaybeMultipartWithoutCompanion<
+  M extends Meta,
+  B extends Body,
+> = AWSS3WithoutCompanion &
+  AWSS3MultipartWithoutCompanionMandatory<M, B> &
+  AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
+    shouldUseMultipart: (file: UppyFile<M, B>) => boolean
+  }
+
+type RequestClientOptions = Partial<
+  ConstructorParameters<typeof RequestClient<any, any>>[1]
+>
+
+interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions {
+  allowedMetaFields?: string[] | null
+  limit?: number
+  retryDelays?: number[] | null
+}
+
+export type AwsS3MultipartOptions<
+  M extends Meta,
+  B extends Body,
+> = _AwsS3MultipartOptions &
+  (
+    | AWSS3NonMultipartWithCompanion
+    | AWSS3NonMultipartWithoutCompanion<M, B>
+    | AWSS3MultipartWithCompanion<M, B>
+    | AWSS3MultipartWithoutCompanion<M, B>
+    | AWSS3MaybeMultipartWithCompanion<M, B>
+    | AWSS3MaybeMultipartWithoutCompanion<M, B>
+  )
+
+const defaultOptions = {
+  // TODO: null here means “include all”, [] means include none.
+  // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit
+  allowedMetaFields: null,
+  limit: 6,
+  getTemporarySecurityCredentials: false as any,
+  shouldUseMultipart: ((file: UppyFile<any, any>) =>
+    file.size !== 0) as any as true, // TODO: Switch default to:
+  // eslint-disable-next-line no-bitwise
+  // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100,
+  retryDelays: [0, 1000, 3000, 5000],
+  companionHeaders: {},
+} satisfies Partial<AwsS3MultipartOptions<any, any>>
+
+export default class AwsS3Multipart<
+  M extends Meta,
+  B extends Body,
+> extends BasePlugin<
+  DefinePluginOpts<AwsS3MultipartOptions<M, B>, keyof typeof defaultOptions> &
+    // We also have a few dynamic options defined below:
+    Pick<
+      AWSS3MultipartWithoutCompanionMandatory<M, B>,
+      | 'getChunkSize'
+      | 'createMultipartUpload'
+      | 'listParts'
+      | 'abortMultipartUpload'
+      | 'completeMultipartUpload'
+    > &
+    Required<Pick<AWSS3WithoutCompanion, 'uploadPartBytes'>> &
+    AWSS3MultipartWithoutCompanionMandatorySignPart<M, B> &
+    AWSS3NonMultipartWithoutCompanionMandatory<M, B>,
+  M,
+  B
+> {
+  static VERSION = packageJson.version
+
+  #companionCommunicationQueue
+
+  #client: RequestClient<M, B>
+
+  protected requests: any
+
+  protected uploaderEvents: Record<string, EventManager<M, B> | null>
+
+  protected uploaders: Record<string, MultipartUploader<M, B> | null>
+
+  protected uploaderSockets: Record<string, never>
+
+  constructor(uppy: Uppy<M, B>, opts: AwsS3MultipartOptions<M, B>) {
+    super(uppy, {
+      ...defaultOptions,
+      uploadPartBytes: AwsS3Multipart.uploadPartBytes,
+      createMultipartUpload: null as any,
+      listParts: null as any,
+      abortMultipartUpload: null as any,
+      completeMultipartUpload: null as any,
+      signPart: null as any,
+      getUploadParameters: null as any,
+      ...opts,
+    })
+    // We need the `as any` here because of the dynamic default options.
+    this.type = 'uploader'
+    this.id = this.opts.id || 'AwsS3Multipart'
+    // @ts-expect-error TODO: remove unused
+    this.title = 'AWS S3 Multipart'
+    // TODO: only initiate `RequestClient` is `companionUrl` is defined.
+    this.#client = new RequestClient(uppy, opts as any)
+
+    const dynamicDefaultOptions = {
+      createMultipartUpload: this.createMultipartUpload,
+      listParts: this.listParts,
+      abortMultipartUpload: this.abortMultipartUpload,
+      completeMultipartUpload: this.completeMultipartUpload,
+      signPart:
+        opts?.getTemporarySecurityCredentials ?
+          this.createSignedURL
+        : this.signPart,
+      getUploadParameters:
+        opts?.getTemporarySecurityCredentials ?
+          (this.createSignedURL as any)
+        : this.getUploadParameters,
+    } satisfies Partial<AwsS3MultipartOptions<M, B>>
+
+    for (const key of Object.keys(dynamicDefaultOptions)) {
+      if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) {
+        this.opts[key as keyof typeof dynamicDefaultOptions] =
+          dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind(
+            this,
+          )
+      }
+    }
+    if (
+      (opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<M, B>)
+        ?.prepareUploadParts != null &&
+      (opts as AWSS3MultipartWithoutCompanionMandatorySignPart<M, B>)
+        .signPart == null
+    ) {
+      this.opts.signPart = async (
+        file: UppyFile<M, B>,
+        { uploadId, key, partNumber, body, signal }: SignPartOptions,
+      ) => {
+        const { presignedUrls, headers } = await (
+          opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<
+            M,
+            B
+          >
+        ).prepareUploadParts(file, {
+          uploadId,
+          key,
+          parts: [{ number: partNumber, chunk: body }],
+          signal,
+        })
+        return {
+          url: presignedUrls?.[partNumber],
+          headers: headers?.[partNumber],
+        }
+      }
+    }
+
+    /**
+     * Simultaneous upload limiting is shared across all uploads with this plugin.
+     *
+     * @type {RateLimitedQueue}
+     */
+    this.requests =
+      (this.opts as any).rateLimitedQueue ??
+      new RateLimitedQueue(this.opts.limit)
+    this.#companionCommunicationQueue = new HTTPCommunicationQueue(
+      this.requests,
+      this.opts,
+      this.#setS3MultipartState,
+      this.#getFile,
+    )
+
+    this.uploaders = Object.create(null)
+    this.uploaderEvents = Object.create(null)
+    this.uploaderSockets = Object.create(null)
+  }
+
+  private [Symbol.for('uppy test: getClient')]() {
+    return this.#client
+  }
+
+  setOptions(newOptions: Partial<AwsS3MultipartOptions<M, B>>): void {
+    this.#companionCommunicationQueue.setOptions(newOptions)
+    super.setOptions(newOptions)
+    this.#setCompanionHeaders()
+  }
+
+  /**
+   * Clean up all references for a file's upload: the MultipartUploader instance,
+   * any events related to the file, and the Companion WebSocket connection.
+   *
+   * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
+   * This should be done when the user cancels the upload, not when the upload is completed or errored.
+   */
+  resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void {
+    if (this.uploaders[fileID]) {
+      this.uploaders[fileID]!.abort({ really: opts?.abort || false })
+      this.uploaders[fileID] = null
+    }
+    if (this.uploaderEvents[fileID]) {
+      this.uploaderEvents[fileID]!.remove()
+      this.uploaderEvents[fileID] = null
+    }
+    if (this.uploaderSockets[fileID]) {
+      // @ts-expect-error TODO: remove this block in the next major
+      this.uploaderSockets[fileID].close()
+      // @ts-expect-error TODO: remove this block in the next major
+      this.uploaderSockets[fileID] = null
+    }
+  }
+
+  // TODO: make this a private method in the next major
+  assertHost(method: string): void {
+    if (!this.opts.companionUrl) {
+      throw new Error(
+        `Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`,
+      )
+    }
+  }
+
+  createMultipartUpload(
+    file: UppyFile<M, B>,
+    signal?: AbortSignal,
+  ): Promise<UploadResult> {
+    this.assertHost('createMultipartUpload')
+    throwIfAborted(signal)
+
+    const metadata = getAllowedMetadata({
+      meta: file.meta,
+      allowedMetaFields: this.opts.allowedMetaFields,
+    })
+
+    return this.#client
+      .post<UploadResult>(
+        's3/multipart',
+        {
+          filename: file.name,
+          type: file.type,
+          metadata,
+        },
+        { signal },
+      )
+      .then(assertServerError)
+  }
+
+  listParts(
+    file: UppyFile<M, B>,
+    { key, uploadId, signal }: UploadResultWithSignal,
+    oldSignal?: AbortSignal,
+  ): Promise<AwsS3Part[]> {
+    signal ??= oldSignal // eslint-disable-line no-param-reassign
+    this.assertHost('listParts')
+    throwIfAborted(signal)
+
+    const filename = encodeURIComponent(key)
+    return this.#client
+      .get<AwsS3Part[]>(`s3/multipart/${uploadId}?key=${filename}`, { signal })
+      .then(assertServerError)
+  }
+
+  completeMultipartUpload(
+    file: UppyFile<M, B>,
+    { key, uploadId, parts, signal }: MultipartUploadResultWithSignal,
+    oldSignal?: AbortSignal,
+  ): Promise<B> {
+    signal ??= oldSignal // eslint-disable-line no-param-reassign
+    this.assertHost('completeMultipartUpload')
+    throwIfAborted(signal)
+
+    const filename = encodeURIComponent(key)
+    const uploadIdEnc = encodeURIComponent(uploadId)
+    return this.#client
+      .post<B>(
+        `s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
+        { parts },
+        { signal },
+      )
+      .then(assertServerError)
+  }
+
+  #cachedTemporaryCredentials: MaybePromise<AwsS3STSResponse>
+
+  async #getTemporarySecurityCredentials(options?: RequestOptions) {
+    throwIfAborted(options?.signal)
+
+    if (this.#cachedTemporaryCredentials == null) {
+      // We do not await it just yet, so concurrent calls do not try to override it:
+      if (this.opts.getTemporarySecurityCredentials === true) {
+        this.assertHost('getTemporarySecurityCredentials')
+        this.#cachedTemporaryCredentials = this.#client
+          .get<AwsS3STSResponse>('s3/sts', options)
+          .then(assertServerError)
+      } else {
+        this.#cachedTemporaryCredentials =
+          this.opts.getTemporarySecurityCredentials(options)
+      }
+      this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
+      setTimeout(
+        () => {
+          // At half the time left before expiration, we clear the cache. That's
+          // an arbitrary tradeoff to limit the number of requests made to the
+          // remote while limiting the risk of using an expired token in case the
+          // clocks are not exactly synced.
+          // The HTTP cache should be configured to ensure a client doesn't request
+          // more tokens than it needs, but this timeout provides a second layer of
+          // security in case the HTTP cache is disabled or misconfigured.
+          this.#cachedTemporaryCredentials = null as any
+        },
+        (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500,
+      )
+    }
+
+    return this.#cachedTemporaryCredentials
+  }
+
+  async createSignedURL(
+    file: UppyFile<M, B>,
+    options: SignPartOptions,
+  ): Promise<AwsS3UploadParameters> {
+    const data = await this.#getTemporarySecurityCredentials(options)
+    const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
+
+    const { uploadId, key, partNumber } = options
+
+    // Return an object in the correct shape.
+    return {
+      method: 'PUT',
+      expires,
+      fields: {},
+      url: `${await createSignedURL({
+        accountKey: data.credentials.AccessKeyId,
+        accountSecret: data.credentials.SecretAccessKey,
+        sessionToken: data.credentials.SessionToken,
+        expires,
+        bucketName: data.bucket,
+        Region: data.region,
+        Key: key ?? `${crypto.randomUUID()}-${file.name}`,
+        uploadId,
+        partNumber,
+      })}`,
+      // Provide content type header required by S3
+      headers: {
+        'Content-Type': file.type as string,
+      },
+    }
+  }
+
+  signPart(
+    file: UppyFile<M, B>,
+    { uploadId, key, partNumber, signal }: SignPartOptions,
+  ): Promise<AwsS3UploadParameters> {
+    this.assertHost('signPart')
+    throwIfAborted(signal)
+
+    if (uploadId == null || key == null || partNumber == null) {
+      throw new Error(
+        'Cannot sign without a key, an uploadId, and a partNumber',
+      )
+    }
+
+    const filename = encodeURIComponent(key)
+    return this.#client
+      .get<AwsS3UploadParameters>(
+        `s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
+        { signal },
+      )
+      .then(assertServerError)
+  }
+
+  abortMultipartUpload(
+    file: UppyFile<M, B>,
+    { key, uploadId, signal }: UploadResultWithSignal,
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    oldSignal?: AbortSignal, // TODO: remove in next major
+  ): Promise<void> {
+    signal ??= oldSignal // eslint-disable-line no-param-reassign
+    this.assertHost('abortMultipartUpload')
+
+    const filename = encodeURIComponent(key)
+    const uploadIdEnc = encodeURIComponent(uploadId)
+    return this.#client
+      .delete<void>(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, {
+        signal,
+      })
+      .then(assertServerError)
+  }
+
+  getUploadParameters(
+    file: UppyFile<M, B>,
+    options: RequestOptions,
+  ): Promise<AwsS3UploadParameters> {
+    const { meta } = file
+    const { type, name: filename } = meta
+    const metadata = getAllowedMetadata({
+      meta,
+      allowedMetaFields: this.opts.allowedMetaFields,
+      querify: true,
+    })
+
+    const query = new URLSearchParams({ filename, type, ...metadata } as Record<
+      string,
+      string
+    >)
+
+    return this.#client.get(`s3/params?${query}`, options)
+  }
+
+  static async uploadPartBytes({
+    signature: { url, expires, headers, method = 'PUT' },
+    body,
+    size = (body as Blob).size,
+    onProgress,
+    onComplete,
+    signal,
+  }: {
+    signature: AwsS3UploadParameters
+    body: FormData | Blob
+    size?: number
+    onProgress: any
+    onComplete: any
+    signal?: AbortSignal
+  }): Promise<UploadPartBytesResult> {
+    throwIfAborted(signal)
+
+    if (url == null) {
+      throw new Error('Cannot upload to an undefined URL')
+    }
+
+    return new Promise((resolve, reject) => {
+      const xhr = new XMLHttpRequest()
+      xhr.open(method, url, true)
+      if (headers) {
+        Object.keys(headers).forEach((key) => {
+          xhr.setRequestHeader(key, headers[key])
+        })
+      }
+      xhr.responseType = 'text'
+      if (typeof expires === 'number') {
+        xhr.timeout = expires * 1000
+      }
+
+      function onabort() {
+        xhr.abort()
+      }
+      function cleanup() {
+        signal?.removeEventListener('abort', onabort)
+      }
+      signal?.addEventListener('abort', onabort)
+
+      xhr.upload.addEventListener('progress', (ev) => {
+        onProgress(ev)
+      })
+
+      xhr.addEventListener('abort', () => {
+        cleanup()
+
+        reject(createAbortError())
+      })
+
+      xhr.addEventListener('timeout', () => {
+        cleanup()
+
+        const error = new Error('Request has expired')
+        ;(error as any).source = { status: 403 }
+        reject(error)
+      })
+      xhr.addEventListener('load', (ev) => {
+        cleanup()
+
+        if (
+          xhr.status === 403 &&
+          xhr.responseText.includes('<Message>Request has expired</Message>')
+        ) {
+          const error = new Error('Request has expired')
+          ;(error as any).source = xhr
+          reject(error)
+          return
+        }
+        if (xhr.status < 200 || xhr.status >= 300) {
+          const error = new Error('Non 2xx')
+          ;(error as any).source = xhr
+          reject(error)
+          return
+        }
+
+        // todo make a proper onProgress API (breaking change)
+        onProgress?.({ loaded: size, lengthComputable: true })
+
+        // NOTE This must be allowed by CORS.
+        const etag = xhr.getResponseHeader('ETag')
+        const location = xhr.getResponseHeader('Location')
+
+        if (method.toUpperCase() === 'POST' && location === null) {
+          // Not being able to read the Location header is not a fatal error.
+          // eslint-disable-next-line no-console
+          console.warn(
+            'AwsS3/Multipart: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.',
+          )
+        }
+        if (etag === null) {
+          reject(
+            new Error(
+              'AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.',
+            ),
+          )
+          return
+        }
+
+        onComplete?.(etag)
+        resolve({
+          ETag: etag,
+          ...(location ? { location } : undefined),
+        })
+      })
+
+      xhr.addEventListener('error', (ev) => {
+        cleanup()
+
+        const error = new Error('Unknown error')
+        ;(error as any).source = ev.target
+        reject(error)
+      })
+
+      xhr.send(body)
+    })
+  }
+
+  #setS3MultipartState = (
+    file: UppyFile<M, B>,
+    { key, uploadId }: UploadResult,
+  ) => {
+    const cFile = this.uppy.getFile(file.id)
+    if (cFile == null) {
+      // file was removed from store
+      return
+    }
+
+    this.uppy.setFileState(file.id, {
+      s3Multipart: {
+        ...(cFile as MultipartFile<M, B>).s3Multipart,
+        key,
+        uploadId,
+      },
+    } as Partial<MultipartFile<M, B>>)
+  }
+
+  #getFile = (file: UppyFile<M, B>) => {
+    return this.uppy.getFile(file.id) || file
+  }
+
+  #uploadLocalFile(file: UppyFile<M, B>) {
+    return new Promise<void | string>((resolve, reject) => {
+      const onProgress = (bytesUploaded: number, bytesTotal: number) => {
+        this.uppy.emit('upload-progress', this.uppy.getFile(file.id), {
+          // @ts-expect-error TODO: figure out if we need this
+          uploader: this,
+          bytesUploaded,
+          bytesTotal,
+        })
+      }
+
+      const onError = (err: unknown) => {
+        this.uppy.log(err as Error)
+        this.uppy.emit('upload-error', file, err as Error)
+
+        this.resetUploaderReferences(file.id)
+        reject(err)
+      }
+
+      const onSuccess = (result: B) => {
+        const uploadResp = {
+          body: {
+            ...result,
+          },
+          status: 200,
+          uploadURL: result.location,
+        }
+
+        this.resetUploaderReferences(file.id)
+
+        this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
+
+        if (result.location) {
+          this.uppy.log(`Download ${file.name} from ${result.location}`)
+        }
+
+        resolve()
+      }
+
+      const upload = new MultipartUploader<M, B>(file.data, {
+        // .bind to pass the file object to each handler.
+        companionComm: this.#companionCommunicationQueue,
+
+        log: (...args: Parameters<Uppy<M, B>['log']>) => this.uppy.log(...args),
+        getChunkSize:
+          this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null,
+
+        onProgress,
+        onError,
+        onSuccess,
+        onPartComplete: (part) => {
+          this.uppy.emit(
+            's3-multipart:part-uploaded',
+            this.#getFile(file),
+            part,
+          )
+        },
+
+        file,
+        shouldUseMultipart: this.opts.shouldUseMultipart,
+
+        ...(file as MultipartFile<M, B>).s3Multipart,
+      })
+
+      this.uploaders[file.id] = upload
+      const eventManager = new EventManager(this.uppy)
+      this.uploaderEvents[file.id] = eventManager
+
+      eventManager.onFileRemove(file.id, (removed) => {
+        upload.abort()
+        this.resetUploaderReferences(file.id, { abort: true })
+        resolve(`upload ${removed} was removed`)
+      })
+
+      eventManager.onCancelAll(file.id, (options) => {
+        if (options?.reason === 'user') {
+          upload.abort()
+          this.resetUploaderReferences(file.id, { abort: true })
+        }
+        resolve(`upload ${file.id} was canceled`)
+      })
+
+      eventManager.onFilePause(file.id, (isPaused) => {
+        if (isPaused) {
+          upload.pause()
+        } else {
+          upload.start()
+        }
+      })
+
+      eventManager.onPauseAll(file.id, () => {
+        upload.pause()
+      })
+
+      eventManager.onResumeAll(file.id, () => {
+        upload.start()
+      })
+
+      upload.start()
+    })
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  #getCompanionClientArgs(file: UppyFile<M, B>) {
+    return {
+      ...file.remote?.body,
+      protocol: 's3-multipart',
+      size: file.data.size,
+      metadata: file.meta,
+    }
+  }
+
+  #upload = async (fileIDs: string[]) => {
+    if (fileIDs.length === 0) return undefined
+
+    const files = this.uppy.getFilesByIds(fileIDs)
+    const filesFiltered = filterNonFailedFiles(files)
+    const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
+
+    this.uppy.emit('upload-start', filesToEmit)
+
+    const promises = filesFiltered.map((file) => {
+      if (file.isRemote) {
+        const getQueue = () => this.requests
+        this.#setResumableUploadsCapability(false)
+        const controller = new AbortController()
+
+        const removedHandler = (removedFile: UppyFile<M, B>) => {
+          if (removedFile.id === file.id) controller.abort()
+        }
+        this.uppy.on('file-removed', removedHandler)
+
+        const uploadPromise = this.uppy
+          .getRequestClientForFile<RequestClient<M, B>>(file)
+          .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
+            signal: controller.signal,
+            getQueue,
+          })
+
+        this.requests.wrapSyncFunction(
+          () => {
+            this.uppy.off('file-removed', removedHandler)
+          },
+          { priority: -1 },
+        )()
+
+        return uploadPromise
+      }
+
+      return this.#uploadLocalFile(file)
+    })
+
+    const upload = await Promise.all(promises)
+    // After the upload is done, another upload may happen with only local files.
+    // We reset the capability so that the next upload can use resumable uploads.
+    this.#setResumableUploadsCapability(true)
+    return upload
+  }
+
+  #setCompanionHeaders = () => {
+    this.#client.setCompanionHeaders(this.opts.companionHeaders)
+  }
+
+  #setResumableUploadsCapability = (boolean: boolean) => {
+    const { capabilities } = this.uppy.getState()
+    this.uppy.setState({
+      capabilities: {
+        ...capabilities,
+        resumableUploads: boolean,
+      },
+    })
+  }
+
+  #resetResumableCapability = () => {
+    this.#setResumableUploadsCapability(true)
+  }
+
+  install(): void {
+    this.#setResumableUploadsCapability(true)
+    this.uppy.addPreProcessor(this.#setCompanionHeaders)
+    this.uppy.addUploader(this.#upload)
+    this.uppy.on('cancel-all', this.#resetResumableCapability)
+  }
+
+  uninstall(): void {
+    this.uppy.removePreProcessor(this.#setCompanionHeaders)
+    this.uppy.removeUploader(this.#upload)
+    this.uppy.off('cancel-all', this.#resetResumableCapability)
+  }
+}
+
+export type uploadPartBytes = (typeof AwsS3Multipart<
+  any,
+  any
+>)['uploadPartBytes']

+ 28 - 0
packages/@uppy/aws-s3-multipart/src/utils.ts

@@ -0,0 +1,28 @@
+import { createAbortError } from '@uppy/utils/lib/AbortController'
+import type { Body as _Body } from '@uppy/utils/lib/UppyFile'
+
+import type { AwsS3Part } from './index'
+
+export function throwIfAborted(signal?: AbortSignal | null): void {
+  if (signal?.aborted) {
+    throw createAbortError('The operation was aborted', {
+      cause: signal.reason,
+    })
+  }
+}
+
+export type UploadResult = { key: string; uploadId: string }
+export type UploadResultWithSignal = UploadResult & { signal?: AbortSignal }
+export type MultipartUploadResult = UploadResult & { parts: AwsS3Part[] }
+export type MultipartUploadResultWithSignal = MultipartUploadResult & {
+  signal?: AbortSignal
+}
+
+export type UploadPartBytesResult = {
+  ETag: string
+  location?: string
+}
+
+export interface Body extends _Body {
+  location: string
+}

+ 30 - 0
packages/@uppy/aws-s3-multipart/tsconfig.build.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json"
+    },
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    }
+  ]
+}

+ 26 - 0
packages/@uppy/aws-s3-multipart/tsconfig.json

@@ -0,0 +1,26 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/companion-client": ["../companion-client/src/index.js"],
+      "@uppy/companion-client/lib/*": ["../companion-client/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../companion-client/tsconfig.build.json",
+    },
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+  ],
+}

+ 1 - 1
packages/@uppy/companion/src/config/companion.js

@@ -91,7 +91,7 @@ const validateConfig = (companionOptions) => {
   if (server && server.path) {
     // see https://github.com/transloadit/uppy/issues/4271
     // todo fix the code so we can allow `/`
-    if (server.path === '/') throw new Error('server.path cannot be set to /')
+    if (server.path === '/') throw new Error('If you want to use \'/\' as server.path, leave the \'path\' variable unset')
   }
 
   if (providerOptions) {

+ 4 - 1
packages/@uppy/core/src/Uppy.ts

@@ -47,7 +47,10 @@ import locale from './locale.ts'
 import type BasePlugin from './BasePlugin.ts'
 import type { Restrictions, ValidateableFile } from './Restricter.ts'
 
-type Processor = (fileIDs: string[], uploadID: string) => Promise<void> | void
+type Processor = (
+  fileIDs: string[],
+  uploadID: string,
+) => Promise<unknown> | void
 
 type FileRemoveReason = 'user' | 'cancel-all'
 

+ 1 - 0
packages/@uppy/dashboard/.npmignore

@@ -0,0 +1 @@
+tsconfig.*

文件差異過大導致無法顯示
+ 376 - 183
packages/@uppy/dashboard/src/Dashboard.tsx


+ 0 - 325
packages/@uppy/dashboard/src/components/AddFiles.jsx

@@ -1,325 +0,0 @@
-import { h, Component, Fragment } from 'preact'
-
-class AddFiles extends Component {
-  triggerFileInputClick = () => {
-    this.fileInput.click()
-  }
-
-  triggerFolderInputClick = () => {
-    this.folderInput.click()
-  }
-
-  triggerVideoCameraInputClick = () => {
-    this.mobileVideoFileInput.click()
-  }
-
-  triggerPhotoCameraInputClick = () => {
-    this.mobilePhotoFileInput.click()
-  }
-
-  onFileInputChange = (event) => {
-    this.props.handleInputChange(event)
-
-    // We clear the input after a file is selected, because otherwise
-    // change event is not fired in Chrome and Safari when a file
-    // with the same name is selected.
-    // ___Why not use value="" on <input/> instead?
-    //    Because if we use that method of clearing the input,
-    //    Chrome will not trigger change if we drop the same file twice (Issue #768).
-    event.target.value = null // eslint-disable-line no-param-reassign
-  }
-
-  renderHiddenInput = (isFolder, refCallback) => {
-    return (
-      <input
-        className="uppy-Dashboard-input"
-        hidden
-        aria-hidden="true"
-        tabIndex={-1}
-        webkitdirectory={isFolder}
-        type="file"
-        name="files[]"
-        multiple={this.props.maxNumberOfFiles !== 1}
-        onChange={this.onFileInputChange}
-        accept={this.props.allowedFileTypes}
-        ref={refCallback}
-      />
-    )
-  }
-
-  renderHiddenCameraInput = (type, nativeCameraFacingMode, refCallback) => {
-    const typeToAccept = { photo: 'image/*', video: 'video/*' }
-    const accept = typeToAccept[type]
-
-    return (
-      <input
-        className="uppy-Dashboard-input"
-        hidden
-        aria-hidden="true"
-        tabIndex={-1}
-        type="file"
-        name={`camera-${type}`}
-        onChange={this.onFileInputChange}
-        capture={nativeCameraFacingMode}
-        accept={accept}
-        ref={refCallback}
-      />
-    )
-  }
-
-  renderMyDeviceAcquirer = () => {
-    return (
-      <div
-        className="uppy-DashboardTab"
-        role="presentation"
-        data-uppy-acquirer-id="MyDevice"
-      >
-        <button
-          type="button"
-          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
-          role="tab"
-          tabIndex={0}
-          data-uppy-super-focusable
-          onClick={this.triggerFileInputClick}
-        >
-          <div className="uppy-DashboardTab-inner">
-            <svg className="uppy-DashboardTab-iconMyDevice" aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
-              <path d="M8.45 22.087l-1.305-6.674h17.678l-1.572 6.674H8.45zm4.975-12.412l1.083 1.765a.823.823 0 00.715.386h7.951V13.5H8.587V9.675h4.838zM26.043 13.5h-1.195v-2.598c0-.463-.336-.75-.798-.75h-8.356l-1.082-1.766A.823.823 0 0013.897 8H7.728c-.462 0-.815.256-.815.718V13.5h-.956a.97.97 0 00-.746.37.972.972 0 00-.19.81l1.724 8.565c.095.44.484.755.933.755H24c.44 0 .824-.3.929-.727l2.043-8.568a.972.972 0 00-.176-.825.967.967 0 00-.753-.38z" fill="currentcolor" fill-rule="evenodd" />
-            </svg>
-          </div>
-          <div className="uppy-DashboardTab-name">{this.props.i18n('myDevice')}</div>
-        </button>
-      </div>
-    )
-  }
-
-  renderPhotoCamera = () => {
-    return (
-      <div
-        className="uppy-DashboardTab"
-        role="presentation"
-        data-uppy-acquirer-id="MobilePhotoCamera"
-      >
-        <button
-          type="button"
-          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
-          role="tab"
-          tabIndex={0}
-          data-uppy-super-focusable
-          onClick={this.triggerPhotoCameraInputClick}
-        >
-          <div className="uppy-DashboardTab-inner">
-            <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
-              <path d="M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z" fill="#02B383" fill-rule="nonzero" />
-            </svg>
-          </div>
-          <div className="uppy-DashboardTab-name">{this.props.i18n('takePictureBtn')}</div>
-        </button>
-      </div>
-    )
-  }
-
-  renderVideoCamera = () => {
-    return (
-      <div
-        className="uppy-DashboardTab"
-        role="presentation"
-        data-uppy-acquirer-id="MobileVideoCamera"
-      >
-        <button
-          type="button"
-          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
-          role="tab"
-          tabIndex={0}
-          data-uppy-super-focusable
-          onClick={this.triggerVideoCameraInputClick}
-        >
-          <div className="uppy-DashboardTab-inner">
-            <svg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32">
-              <path fill="#FF675E" fillRule="nonzero" d="m21.254 14.277 2.941-2.588c.797-.313 1.243.818 1.09 1.554-.01 2.094.02 4.189-.017 6.282-.126.915-1.145 1.08-1.58.34l-2.434-2.142c-.192.287-.504 1.305-.738.468-.104-1.293-.028-2.596-.05-3.894.047-.312.381.823.426 1.069.063-.384.206-.744.362-1.09zm-12.939-3.73c3.858.013 7.717-.025 11.574.02.912.129 1.492 1.237 1.351 2.217-.019 2.412.04 4.83-.03 7.239-.17 1.025-1.166 1.59-2.029 1.429-3.705-.012-7.41.025-11.114-.019-.913-.129-1.492-1.237-1.352-2.217.018-2.404-.036-4.813.029-7.214.136-.82.83-1.473 1.571-1.454z " />
-            </svg>
-          </div>
-          <div className="uppy-DashboardTab-name">{this.props.i18n('recordVideoBtn')}</div>
-        </button>
-      </div>
-    )
-  }
-
-  renderBrowseButton = (text, onClickFn) => {
-    const numberOfAcquirers = this.props.acquirers.length
-    return (
-      <button
-        type="button"
-        className="uppy-u-reset uppy-c-btn uppy-Dashboard-browse"
-        onClick={onClickFn}
-        data-uppy-super-focusable={numberOfAcquirers === 0}
-      >
-        {text}
-      </button>
-    )
-  }
-
-  renderDropPasteBrowseTagline = (numberOfAcquirers) => {
-    const browseFiles = this.renderBrowseButton(this.props.i18n('browseFiles'), this.triggerFileInputClick)
-    const browseFolders = this.renderBrowseButton(this.props.i18n('browseFolders'), this.triggerFolderInputClick)
-
-    // in order to keep the i18n CamelCase and options lower (as are defaults) we will want to transform a lower
-    // to Camel
-    const lowerFMSelectionType = this.props.fileManagerSelectionType
-    const camelFMSelectionType = lowerFMSelectionType.charAt(0).toUpperCase() + lowerFMSelectionType.slice(1)
-
-    return (
-      <div class="uppy-Dashboard-AddFiles-title">
-        {
-          // eslint-disable-next-line no-nested-ternary
-          this.props.disableLocalFiles ? this.props.i18n('importFiles')
-            : numberOfAcquirers > 0
-              ? this.props.i18nArray(`dropPasteImport${camelFMSelectionType}`, { browseFiles, browseFolders, browse: browseFiles })
-              : this.props.i18nArray(`dropPaste${camelFMSelectionType}`, { browseFiles, browseFolders, browse: browseFiles })
-        }
-      </div>
-    )
-  }
-
-  [Symbol.for('uppy test: disable unused locale key warning')] () {
-    // Those are actually used in `renderDropPasteBrowseTagline` method.
-    this.props.i18nArray('dropPasteBoth')
-    this.props.i18nArray('dropPasteFiles')
-    this.props.i18nArray('dropPasteFolders')
-    this.props.i18nArray('dropPasteImportBoth')
-    this.props.i18nArray('dropPasteImportFiles')
-    this.props.i18nArray('dropPasteImportFolders')
-  }
-
-  renderAcquirer = (acquirer) => {
-    return (
-      <div
-        className="uppy-DashboardTab"
-        role="presentation"
-        data-uppy-acquirer-id={acquirer.id}
-      >
-        <button
-          type="button"
-          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
-          role="tab"
-          tabIndex={0}
-          data-cy={acquirer.id}
-          aria-controls={`uppy-DashboardContent-panel--${acquirer.id}`}
-          aria-selected={this.props.activePickerPanel.id === acquirer.id}
-          data-uppy-super-focusable
-          onClick={() => this.props.showPanel(acquirer.id)}
-        >
-          <div className="uppy-DashboardTab-inner">
-            {acquirer.icon()}
-          </div>
-          <div className="uppy-DashboardTab-name">{acquirer.name}</div>
-        </button>
-      </div>
-    )
-  }
-
-  renderAcquirers = (acquirers) => {
-    // Group last two buttons, so we don’t end up with
-    // just one button on a new line
-    const acquirersWithoutLastTwo = [...acquirers]
-    const lastTwoAcquirers = acquirersWithoutLastTwo.splice(acquirers.length - 2, acquirers.length)
-
-    return (
-      <>
-        {acquirersWithoutLastTwo.map((acquirer) => this.renderAcquirer(acquirer))}
-        <span role="presentation" style={{ 'white-space': 'nowrap' }}>
-          {lastTwoAcquirers.map((acquirer) => this.renderAcquirer(acquirer))}
-        </span>
-      </>
-    )
-  }
-
-  renderSourcesList = (acquirers, disableLocalFiles) => {
-    const { showNativePhotoCameraButton, showNativeVideoCameraButton } = this.props
-
-    let list = []
-
-    const myDeviceKey = 'myDevice'
-
-    if (!disableLocalFiles) list.push({ key: myDeviceKey, elements: this.renderMyDeviceAcquirer() })
-    if (showNativePhotoCameraButton) list.push({ key: 'nativePhotoCameraButton', elements: this.renderPhotoCamera() })
-    if (showNativeVideoCameraButton) list.push({ key: 'nativePhotoCameraButton', elements: this.renderVideoCamera() })
-    list.push(...acquirers.map((acquirer) => ({ key: acquirer.id, elements: this.renderAcquirer(acquirer) })))
-
-    // doesn't make sense to show only a lonely "My Device"
-    const hasOnlyMyDevice = list.length === 1 && list[0].key === myDeviceKey
-    if (hasOnlyMyDevice) list = []
-
-    // Group last two buttons, so we don’t end up with
-    // just one button on a new line
-    const listWithoutLastTwo = [...list]
-    const lastTwo = listWithoutLastTwo.splice(list.length - 2, list.length)
-
-    const renderList = (l) => l.map(({ key, elements }) => <Fragment key={key}>{elements}</Fragment>)
-
-    return (
-      <>
-        {this.renderDropPasteBrowseTagline(list.length)}
-
-        <div className="uppy-Dashboard-AddFiles-list" role="tablist">
-          {renderList(listWithoutLastTwo)}
-
-          <span role="presentation" style={{ 'white-space': 'nowrap' }}>
-            {renderList(lastTwo)}
-          </span>
-        </div>
-      </>
-    )
-  }
-
-  renderPoweredByUppy () {
-    const { i18nArray } = this.props
-
-    const uppyBranding = (
-      <span>
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon uppy-Dashboard-poweredByIcon" width="11" height="11" viewBox="0 0 11 11">
-          <path d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z" fillRule="evenodd" />
-        </svg>
-        <span className="uppy-Dashboard-poweredByUppy">Uppy</span>
-      </span>
-    )
-
-    const linkText = i18nArray('poweredBy', { uppy: uppyBranding })
-
-    return (
-      <a
-        tabIndex="-1"
-        href="https://uppy.io"
-        rel="noreferrer noopener"
-        target="_blank"
-        className="uppy-Dashboard-poweredBy"
-      >
-        {linkText}
-      </a>
-    )
-  }
-
-  render () {
-    const {
-      showNativePhotoCameraButton,
-      showNativeVideoCameraButton,
-      nativeCameraFacingMode,
-    } = this.props
-
-    return (
-      <div className="uppy-Dashboard-AddFiles">
-        {this.renderHiddenInput(false, (ref) => { this.fileInput = ref })}
-        {this.renderHiddenInput(true, (ref) => { this.folderInput = ref })}
-        {showNativePhotoCameraButton && this.renderHiddenCameraInput('photo', nativeCameraFacingMode, (ref) => { this.mobilePhotoFileInput = ref })}
-        {showNativeVideoCameraButton && this.renderHiddenCameraInput('video', nativeCameraFacingMode, (ref) => { this.mobileVideoFileInput = ref })}
-        {this.renderSourcesList(this.props.acquirers, this.props.disableLocalFiles)}
-        <div className="uppy-Dashboard-AddFiles-info">
-          {this.props.note && <div className="uppy-Dashboard-note">{this.props.note}</div>}
-          {this.props.proudlyDisplayPoweredByUppy && this.renderPoweredByUppy(this.props)}
-        </div>
-      </div>
-    )
-  }
-}
-
-export default AddFiles

+ 450 - 0
packages/@uppy/dashboard/src/components/AddFiles.tsx

@@ -0,0 +1,450 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck Typing this file requires more work, skipping it to unblock the rest of the transition.
+
+/* eslint-disable react/destructuring-assignment */
+import { h, Component, Fragment, type ComponentChild } from 'preact'
+
+type $TSFixMe = any
+
+class AddFiles extends Component {
+  fileInput: $TSFixMe
+
+  folderInput: $TSFixMe
+
+  mobilePhotoFileInput: $TSFixMe
+
+  mobileVideoFileInput: $TSFixMe
+
+  private triggerFileInputClick = () => {
+    this.fileInput.click()
+  }
+
+  private triggerFolderInputClick = () => {
+    this.folderInput.click()
+  }
+
+  private triggerVideoCameraInputClick = () => {
+    this.mobileVideoFileInput.click()
+  }
+
+  private triggerPhotoCameraInputClick = () => {
+    this.mobilePhotoFileInput.click()
+  }
+
+  private onFileInputChange = (event: $TSFixMe) => {
+    this.props.handleInputChange(event)
+
+    // We clear the input after a file is selected, because otherwise
+    // change event is not fired in Chrome and Safari when a file
+    // with the same name is selected.
+    // ___Why not use value="" on <input/> instead?
+    //    Because if we use that method of clearing the input,
+    //    Chrome will not trigger change if we drop the same file twice (Issue #768).
+    event.target.value = null // eslint-disable-line no-param-reassign
+  }
+
+  private renderHiddenInput = (isFolder: $TSFixMe, refCallback: $TSFixMe) => {
+    return (
+      <input
+        className="uppy-Dashboard-input"
+        hidden
+        aria-hidden="true"
+        tabIndex={-1}
+        webkitdirectory={isFolder}
+        type="file"
+        name="files[]"
+        multiple={this.props.maxNumberOfFiles !== 1}
+        onChange={this.onFileInputChange}
+        accept={this.props.allowedFileTypes}
+        ref={refCallback}
+      />
+    )
+  }
+
+  private renderHiddenCameraInput = (
+    type: $TSFixMe,
+    nativeCameraFacingMode: $TSFixMe,
+    refCallback: $TSFixMe,
+  ) => {
+    const typeToAccept = { photo: 'image/*', video: 'video/*' }
+    const accept = typeToAccept[type]
+
+    return (
+      <input
+        className="uppy-Dashboard-input"
+        hidden
+        aria-hidden="true"
+        tabIndex={-1}
+        type="file"
+        name={`camera-${type}`}
+        onChange={this.onFileInputChange}
+        capture={nativeCameraFacingMode}
+        accept={accept}
+        ref={refCallback}
+      />
+    )
+  }
+
+  private renderMyDeviceAcquirer = () => {
+    return (
+      <div
+        className="uppy-DashboardTab"
+        role="presentation"
+        data-uppy-acquirer-id="MyDevice"
+      >
+        <button
+          type="button"
+          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
+          role="tab"
+          tabIndex={0}
+          data-uppy-super-focusable
+          onClick={this.triggerFileInputClick}
+        >
+          <div className="uppy-DashboardTab-inner">
+            <svg
+              className="uppy-DashboardTab-iconMyDevice"
+              aria-hidden="true"
+              focusable="false"
+              width="32"
+              height="32"
+              viewBox="0 0 32 32"
+            >
+              <path
+                d="M8.45 22.087l-1.305-6.674h17.678l-1.572 6.674H8.45zm4.975-12.412l1.083 1.765a.823.823 0 00.715.386h7.951V13.5H8.587V9.675h4.838zM26.043 13.5h-1.195v-2.598c0-.463-.336-.75-.798-.75h-8.356l-1.082-1.766A.823.823 0 0013.897 8H7.728c-.462 0-.815.256-.815.718V13.5h-.956a.97.97 0 00-.746.37.972.972 0 00-.19.81l1.724 8.565c.095.44.484.755.933.755H24c.44 0 .824-.3.929-.727l2.043-8.568a.972.972 0 00-.176-.825.967.967 0 00-.753-.38z"
+                fill="currentcolor"
+                fill-rule="evenodd"
+              />
+            </svg>
+          </div>
+          <div className="uppy-DashboardTab-name">
+            {this.props.i18n('myDevice')}
+          </div>
+        </button>
+      </div>
+    )
+  }
+
+  private renderPhotoCamera = () => {
+    return (
+      <div
+        className="uppy-DashboardTab"
+        role="presentation"
+        data-uppy-acquirer-id="MobilePhotoCamera"
+      >
+        <button
+          type="button"
+          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
+          role="tab"
+          tabIndex={0}
+          data-uppy-super-focusable
+          onClick={this.triggerPhotoCameraInputClick}
+        >
+          <div className="uppy-DashboardTab-inner">
+            <svg
+              aria-hidden="true"
+              focusable="false"
+              width="32"
+              height="32"
+              viewBox="0 0 32 32"
+            >
+              <path
+                d="M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z"
+                fill="#02B383"
+                fill-rule="nonzero"
+              />
+            </svg>
+          </div>
+          <div className="uppy-DashboardTab-name">
+            {this.props.i18n('takePictureBtn')}
+          </div>
+        </button>
+      </div>
+    )
+  }
+
+  private renderVideoCamera = () => {
+    return (
+      <div
+        className="uppy-DashboardTab"
+        role="presentation"
+        data-uppy-acquirer-id="MobileVideoCamera"
+      >
+        <button
+          type="button"
+          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
+          role="tab"
+          tabIndex={0}
+          data-uppy-super-focusable
+          onClick={this.triggerVideoCameraInputClick}
+        >
+          <div className="uppy-DashboardTab-inner">
+            <svg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32">
+              <path
+                fill="#FF675E"
+                fillRule="nonzero"
+                d="m21.254 14.277 2.941-2.588c.797-.313 1.243.818 1.09 1.554-.01 2.094.02 4.189-.017 6.282-.126.915-1.145 1.08-1.58.34l-2.434-2.142c-.192.287-.504 1.305-.738.468-.104-1.293-.028-2.596-.05-3.894.047-.312.381.823.426 1.069.063-.384.206-.744.362-1.09zm-12.939-3.73c3.858.013 7.717-.025 11.574.02.912.129 1.492 1.237 1.351 2.217-.019 2.412.04 4.83-.03 7.239-.17 1.025-1.166 1.59-2.029 1.429-3.705-.012-7.41.025-11.114-.019-.913-.129-1.492-1.237-1.352-2.217.018-2.404-.036-4.813.029-7.214.136-.82.83-1.473 1.571-1.454z "
+              />
+            </svg>
+          </div>
+          <div className="uppy-DashboardTab-name">
+            {this.props.i18n('recordVideoBtn')}
+          </div>
+        </button>
+      </div>
+    )
+  }
+
+  private renderBrowseButton = (text: $TSFixMe, onClickFn: $TSFixMe) => {
+    const numberOfAcquirers = this.props.acquirers.length
+    return (
+      <button
+        type="button"
+        className="uppy-u-reset uppy-c-btn uppy-Dashboard-browse"
+        onClick={onClickFn}
+        data-uppy-super-focusable={numberOfAcquirers === 0}
+      >
+        {text}
+      </button>
+    )
+  }
+
+  private renderDropPasteBrowseTagline = (numberOfAcquirers: $TSFixMe) => {
+    const browseFiles = this.renderBrowseButton(
+      this.props.i18n('browseFiles'),
+      this.triggerFileInputClick,
+    )
+    const browseFolders = this.renderBrowseButton(
+      this.props.i18n('browseFolders'),
+      this.triggerFolderInputClick,
+    )
+
+    // in order to keep the i18n CamelCase and options lower (as are defaults) we will want to transform a lower
+    // to Camel
+    const lowerFMSelectionType = this.props.fileManagerSelectionType
+    const camelFMSelectionType =
+      lowerFMSelectionType.charAt(0).toUpperCase() +
+      lowerFMSelectionType.slice(1)
+
+    return (
+      <div class="uppy-Dashboard-AddFiles-title">
+        {
+          // eslint-disable-next-line no-nested-ternary
+          this.props.disableLocalFiles ?
+            this.props.i18n('importFiles')
+          : numberOfAcquirers > 0 ?
+            this.props.i18nArray(`dropPasteImport${camelFMSelectionType}`, {
+              browseFiles,
+              browseFolders,
+              browse: browseFiles,
+            })
+          : this.props.i18nArray(`dropPaste${camelFMSelectionType}`, {
+              browseFiles,
+              browseFolders,
+              browse: browseFiles,
+            })
+
+        }
+      </div>
+    )
+  }
+
+  private [Symbol.for('uppy test: disable unused locale key warning')]() {
+    // Those are actually used in `renderDropPasteBrowseTagline` method.
+    this.props.i18nArray('dropPasteBoth')
+    this.props.i18nArray('dropPasteFiles')
+    this.props.i18nArray('dropPasteFolders')
+    this.props.i18nArray('dropPasteImportBoth')
+    this.props.i18nArray('dropPasteImportFiles')
+    this.props.i18nArray('dropPasteImportFolders')
+  }
+
+  private renderAcquirer = (acquirer: $TSFixMe) => {
+    return (
+      <div
+        className="uppy-DashboardTab"
+        role="presentation"
+        data-uppy-acquirer-id={acquirer.id}
+      >
+        <button
+          type="button"
+          className="uppy-u-reset uppy-c-btn uppy-DashboardTab-btn"
+          role="tab"
+          tabIndex={0}
+          data-cy={acquirer.id}
+          aria-controls={`uppy-DashboardContent-panel--${acquirer.id}`}
+          aria-selected={this.props.activePickerPanel?.id === acquirer.id}
+          data-uppy-super-focusable
+          onClick={() => this.props.showPanel(acquirer.id)}
+        >
+          <div className="uppy-DashboardTab-inner">{acquirer.icon()}</div>
+          <div className="uppy-DashboardTab-name">{acquirer.name}</div>
+        </button>
+      </div>
+    )
+  }
+
+  private renderAcquirers = (acquirers: $TSFixMe) => {
+    // Group last two buttons, so we don’t end up with
+    // just one button on a new line
+    const acquirersWithoutLastTwo = [...acquirers]
+    const lastTwoAcquirers = acquirersWithoutLastTwo.splice(
+      acquirers.length - 2,
+      acquirers.length,
+    )
+
+    return (
+      <>
+        {acquirersWithoutLastTwo.map((acquirer) =>
+          this.renderAcquirer(acquirer),
+        )}
+        <span role="presentation" style={{ 'white-space': 'nowrap' }}>
+          {lastTwoAcquirers.map((acquirer) => this.renderAcquirer(acquirer))}
+        </span>
+      </>
+    )
+  }
+
+  private renderSourcesList = (
+    acquirers: $TSFixMe,
+    disableLocalFiles: $TSFixMe,
+  ) => {
+    const { showNativePhotoCameraButton, showNativeVideoCameraButton } =
+      this.props
+
+    let list = []
+
+    const myDeviceKey = 'myDevice'
+
+    if (!disableLocalFiles)
+      list.push({ key: myDeviceKey, elements: this.renderMyDeviceAcquirer() })
+    if (showNativePhotoCameraButton)
+      list.push({
+        key: 'nativePhotoCameraButton',
+        elements: this.renderPhotoCamera(),
+      })
+    if (showNativeVideoCameraButton)
+      list.push({
+        key: 'nativePhotoCameraButton',
+        elements: this.renderVideoCamera(),
+      })
+    list.push(
+      ...acquirers.map((acquirer: $TSFixMe) => ({
+        key: acquirer.id,
+        elements: this.renderAcquirer(acquirer),
+      })),
+    )
+
+    // doesn't make sense to show only a lonely "My Device"
+    const hasOnlyMyDevice = list.length === 1 && list[0].key === myDeviceKey
+    if (hasOnlyMyDevice) list = []
+
+    // Group last two buttons, so we don’t end up with
+    // just one button on a new line
+    const listWithoutLastTwo = [...list]
+    const lastTwo = listWithoutLastTwo.splice(list.length - 2, list.length)
+
+    const renderList = (l: $TSFixMe) =>
+      l.map(({ key, elements }: $TSFixMe) => (
+        <Fragment key={key}>{elements}</Fragment>
+      ))
+
+    return (
+      <>
+        {this.renderDropPasteBrowseTagline(list.length)}
+
+        <div className="uppy-Dashboard-AddFiles-list" role="tablist">
+          {renderList(listWithoutLastTwo)}
+
+          <span role="presentation" style={{ 'white-space': 'nowrap' }}>
+            {renderList(lastTwo)}
+          </span>
+        </div>
+      </>
+    )
+  }
+
+  private renderPoweredByUppy() {
+    const { i18nArray } = this.props as $TSFixMe
+
+    const uppyBranding = (
+      <span>
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon uppy-Dashboard-poweredByIcon"
+          width="11"
+          height="11"
+          viewBox="0 0 11 11"
+        >
+          <path
+            d="M7.365 10.5l-.01-4.045h2.612L5.5.806l-4.467 5.65h2.604l.01 4.044h3.718z"
+            fillRule="evenodd"
+          />
+        </svg>
+        <span className="uppy-Dashboard-poweredByUppy">Uppy</span>
+      </span>
+    )
+
+    const linkText = i18nArray('poweredBy', { uppy: uppyBranding })
+
+    return (
+      <a
+        tabIndex={-1}
+        href="https://uppy.io"
+        rel="noreferrer noopener"
+        target="_blank"
+        className="uppy-Dashboard-poweredBy"
+      >
+        {linkText}
+      </a>
+    )
+  }
+
+  render(): ComponentChild {
+    const {
+      showNativePhotoCameraButton,
+      showNativeVideoCameraButton,
+      nativeCameraFacingMode,
+    } = this.props
+
+    return (
+      <div className="uppy-Dashboard-AddFiles">
+        {this.renderHiddenInput(false, (ref: $TSFixMe) => {
+          this.fileInput = ref
+        })}
+        {this.renderHiddenInput(true, (ref: $TSFixMe) => {
+          this.folderInput = ref
+        })}
+        {showNativePhotoCameraButton &&
+          this.renderHiddenCameraInput(
+            'photo',
+            nativeCameraFacingMode,
+            (ref: $TSFixMe) => {
+              this.mobilePhotoFileInput = ref
+            },
+          )}
+        {showNativeVideoCameraButton &&
+          this.renderHiddenCameraInput(
+            'video',
+            nativeCameraFacingMode,
+            (ref: $TSFixMe) => {
+              this.mobileVideoFileInput = ref
+            },
+          )}
+        {this.renderSourcesList(
+          this.props.acquirers,
+          this.props.disableLocalFiles,
+        )}
+        <div className="uppy-Dashboard-AddFiles-info">
+          {this.props.note && (
+            <div className="uppy-Dashboard-note">{this.props.note}</div>
+          )}
+          {this.props.proudlyDisplayPoweredByUppy &&
+            this.renderPoweredByUppy(this.props)}
+        </div>
+      </div>
+    )
+  }
+}
+
+export default AddFiles

+ 10 - 3
packages/@uppy/dashboard/src/components/AddFilesPanel.jsx → packages/@uppy/dashboard/src/components/AddFilesPanel.tsx

@@ -1,8 +1,11 @@
+/* eslint-disable react/destructuring-assignment */
 import { h } from 'preact'
 import classNames from 'classnames'
-import AddFiles from './AddFiles.jsx'
+import AddFiles from './AddFiles.tsx'
 
-const AddFilesPanel = (props) => {
+type $TSFixMe = any
+
+const AddFilesPanel = (props: $TSFixMe): $TSFixMe => {
   return (
     <div
       className={classNames('uppy-Dashboard-AddFilesPanel', props.className)}
@@ -10,7 +13,11 @@ const AddFilesPanel = (props) => {
       aria-hidden={!props.showAddFilesPanel}
     >
       <div className="uppy-DashboardContent-bar">
-        <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+        <div
+          className="uppy-DashboardContent-title"
+          role="heading"
+          aria-level="1"
+        >
           {props.i18n('addingMoreFiles')}
         </div>
         <button

+ 91 - 57
packages/@uppy/dashboard/src/components/Dashboard.jsx → packages/@uppy/dashboard/src/components/Dashboard.tsx

@@ -1,14 +1,15 @@
+/* eslint-disable react/destructuring-assignment, react/jsx-props-no-spreading */
 import { h } from 'preact'
 import classNames from 'classnames'
 import isDragDropSupported from '@uppy/utils/lib/isDragDropSupported'
-import FileList from './FileList.jsx'
-import AddFiles from './AddFiles.jsx'
-import AddFilesPanel from './AddFilesPanel.jsx'
-import PickerPanelContent from './PickerPanelContent.jsx'
-import EditorPanel from './EditorPanel.jsx'
-import PanelTopBar from './PickerPanelTopBar.jsx'
-import FileCard from './FileCard/index.jsx'
-import Slide from './Slide.jsx'
+import FileList from './FileList.tsx'
+import AddFiles from './AddFiles.tsx'
+import AddFilesPanel from './AddFilesPanel.tsx'
+import PickerPanelContent from './PickerPanelContent.tsx'
+import EditorPanel from './EditorPanel.tsx'
+import PanelTopBar from './PickerPanelTopBar.tsx'
+import FileCard from './FileCard/index.tsx'
+import Slide from './Slide.tsx'
 
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
 // https://github.com/ghosh/micromodal
@@ -22,7 +23,9 @@ const HEIGHT_MD = 330
 // const HEIGHT_LG = 400
 // const HEIGHT_XL = 460
 
-export default function Dashboard (props) {
+type $TSFixMe = any
+
+export default function Dashboard(props: $TSFixMe): JSX.Element {
   const isNoFiles = props.totalFileCount === 0
   const isSingleFile = props.totalFileCount === 1
   const isSizeMD = props.containerWidth > WIDTH_MD
@@ -45,7 +48,8 @@ export default function Dashboard (props) {
     'uppy-Dashboard--isAddFilesPanelVisible': props.showAddFilesPanel,
     'uppy-Dashboard--isInnerWrapVisible': props.areInsidesReadyToBeVisible,
     // Only enable “centered single file” mode when Dashboard is tall enough
-    'uppy-Dashboard--singleFile': props.singleFileFullScreen && isSingleFile && isSizeHeightMD,
+    'uppy-Dashboard--singleFile':
+      props.singleFileFullScreen && isSingleFile && isSizeHeightMD,
   })
 
   // Important: keep these in sync with the percent width values in `src/components/FileItem/index.scss`.
@@ -60,11 +64,16 @@ export default function Dashboard (props) {
 
   const showFileList = props.showSelectedFiles && !isNoFiles
 
-  const numberOfFilesForRecovery = props.recoveredState ? Object.keys(props.recoveredState.files).length : null
-  const numberOfGhosts = props.files ? Object.keys(props.files).filter((fileID) => props.files[fileID].isGhost).length : null
+  const numberOfFilesForRecovery =
+    props.recoveredState ? Object.keys(props.recoveredState.files).length : null
+  const numberOfGhosts =
+    props.files ?
+      Object.keys(props.files).filter((fileID) => props.files[fileID].isGhost)
+        .length
+    : null
 
   const renderRestoredText = () => {
-    if (numberOfGhosts > 0) {
+    if (numberOfGhosts! > 0) {
       return props.i18n('recoveredXFiles', {
         smart_count: numberOfGhosts,
       })
@@ -78,10 +87,16 @@ export default function Dashboard (props) {
       className={dashboardClassName}
       data-uppy-theme={props.theme}
       data-uppy-num-acquirers={props.acquirers.length}
-      data-uppy-drag-drop-supported={!props.disableLocalFiles && isDragDropSupported()}
+      data-uppy-drag-drop-supported={
+        !props.disableLocalFiles && isDragDropSupported()
+      }
       aria-hidden={props.inline ? 'false' : props.isHidden}
       aria-disabled={props.disabled}
-      aria-label={!props.inline ? props.i18n('dashboardWindowTitle') : props.i18n('dashboardTitle')}
+      aria-label={
+        !props.inline ?
+          props.i18n('dashboardWindowTitle')
+        : props.i18n('dashboardTitle')
+      }
       onPaste={props.handlePaste}
       onDragOver={props.handleDragOver}
       onDragLeave={props.handleDragLeave}
@@ -97,14 +112,13 @@ export default function Dashboard (props) {
       <div
         className="uppy-Dashboard-inner"
         aria-modal={!props.inline && 'true'}
-        role={!props.inline && 'dialog'}
+        role={props.inline ? undefined : 'dialog'}
         style={{
           width: props.inline && props.width ? props.width : '',
           height: props.inline && props.height ? props.height : '',
         }}
       >
-
-        {!props.inline ? (
+        {!props.inline ?
           <button
             className="uppy-u-reset uppy-Dashboard-close"
             type="button"
@@ -114,7 +128,7 @@ export default function Dashboard (props) {
           >
             <span aria-hidden="true">&times;</span>
           </button>
-        ) : null}
+        : null}
 
         <div className="uppy-Dashboard-innerWrap">
           <div className="uppy-Dashboard-dropFilesHereHint">
@@ -126,9 +140,19 @@ export default function Dashboard (props) {
 
           {numberOfFilesForRecovery && (
             <div className="uppy-Dashboard-serviceMsg">
-              <svg className="uppy-Dashboard-serviceMsg-icon" aria-hidden="true" focusable="false" width="21" height="16" viewBox="0 0 24 19">
+              <svg
+                className="uppy-Dashboard-serviceMsg-icon"
+                aria-hidden="true"
+                focusable="false"
+                width="21"
+                height="16"
+                viewBox="0 0 24 19"
+              >
                 <g transform="translate(0 -1)" fill="none" fillRule="evenodd">
-                  <path d="M12.857 1.43l10.234 17.056A1 1 0 0122.234 20H1.766a1 1 0 01-.857-1.514L11.143 1.429a1 1 0 011.714 0z" fill="#FFD300" />
+                  <path
+                    d="M12.857 1.43l10.234 17.056A1 1 0 0122.234 20H1.766a1 1 0 01-.857-1.514L11.143 1.429a1 1 0 011.714 0z"
+                    fill="#FFD300"
+                  />
                   <path fill="#000" d="M11 6h2l-.3 8h-1.4z" />
                   <circle fill="#000" cx="12" cy="17" r="1" />
                 </g>
@@ -142,60 +166,70 @@ export default function Dashboard (props) {
             </div>
           )}
 
-          {showFileList ? (
-            <FileList
-              id={props.id}
-              error={props.error}
-              i18n={props.i18n}
-              uppy={props.uppy}
-              files={props.files}
-              acquirers={props.acquirers}
-              resumableUploads={props.resumableUploads}
-              hideRetryButton={props.hideRetryButton}
-              hidePauseResumeButton={props.hidePauseResumeButton}
-              hideCancelButton={props.hideCancelButton}
-              showLinkToFileUploadResult={props.showLinkToFileUploadResult}
-              showRemoveButtonAfterComplete={props.showRemoveButtonAfterComplete}
-              isWide={props.isWide}
-              metaFields={props.metaFields}
-              toggleFileCard={props.toggleFileCard}
-              handleRequestThumbnail={props.handleRequestThumbnail}
-              handleCancelThumbnail={props.handleCancelThumbnail}
-              recoveredState={props.recoveredState}
-              individualCancellation={props.individualCancellation}
-              openFileEditor={props.openFileEditor}
-              canEditFile={props.canEditFile}
-              toggleAddFilesPanel={props.toggleAddFilesPanel}
-              isSingleFile={isSingleFile}
-              itemsPerRow={itemsPerRow}
-            />
-          ) : (
-            // eslint-disable-next-line react/jsx-props-no-spreading
-            <AddFiles {...props} isSizeMD={isSizeMD} />
-          )}
+          {
+            showFileList ?
+              <FileList
+                id={props.id}
+                error={props.error}
+                i18n={props.i18n}
+                uppy={props.uppy}
+                files={props.files}
+                acquirers={props.acquirers}
+                resumableUploads={props.resumableUploads}
+                hideRetryButton={props.hideRetryButton}
+                hidePauseResumeButton={props.hidePauseResumeButton}
+                hideCancelButton={props.hideCancelButton}
+                showLinkToFileUploadResult={props.showLinkToFileUploadResult}
+                showRemoveButtonAfterComplete={
+                  props.showRemoveButtonAfterComplete
+                }
+                isWide={props.isWide}
+                metaFields={props.metaFields}
+                toggleFileCard={props.toggleFileCard}
+                handleRequestThumbnail={props.handleRequestThumbnail}
+                handleCancelThumbnail={props.handleCancelThumbnail}
+                recoveredState={props.recoveredState}
+                individualCancellation={props.individualCancellation}
+                openFileEditor={props.openFileEditor}
+                canEditFile={props.canEditFile}
+                toggleAddFilesPanel={props.toggleAddFilesPanel}
+                isSingleFile={isSingleFile}
+                itemsPerRow={itemsPerRow}
+              />
+              // eslint-disable-next-line react/jsx-props-no-spreading
+            : <AddFiles {...props} isSizeMD={isSizeMD} />
+          }
 
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-            {props.showAddFilesPanel ? <AddFilesPanel key="AddFiles" {...props} isSizeMD={isSizeMD} /> : null}
+            {props.showAddFilesPanel ?
+              <AddFilesPanel key="AddFiles" {...props} isSizeMD={isSizeMD} />
+            : null}
           </Slide>
 
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-            {props.fileCardFor ? <FileCard key="FileCard" {...props} /> : null}
+            {props.fileCardFor ?
+              <FileCard key="FileCard" {...props} />
+            : null}
           </Slide>
 
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-            {props.activePickerPanel ? <PickerPanelContent key="Picker" {...props} /> : null}
+            {props.activePickerPanel ?
+              <PickerPanelContent key="Picker" {...props} />
+            : null}
           </Slide>
 
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-            {props.showFileEditor ? <EditorPanel key="Editor" {...props} /> : null}
+            {props.showFileEditor ?
+              <EditorPanel key="Editor" {...props} />
+            : null}
           </Slide>
 
           <div className="uppy-Dashboard-progressindicators">
-            {props.progressindicators.map((target) => {
+            {props.progressindicators.map((target: $TSFixMe) => {
               return props.uppy.getPlugin(target.id).render(props.state)
             })}
           </div>

+ 15 - 4
packages/@uppy/dashboard/src/components/EditorPanel.jsx → packages/@uppy/dashboard/src/components/EditorPanel.tsx

@@ -1,7 +1,10 @@
+/* eslint-disable react/destructuring-assignment */
 import { h } from 'preact'
 import classNames from 'classnames'
 
-function EditorPanel (props) {
+type $TSFixMe = any
+
+function EditorPanel(props: $TSFixMe): JSX.Element {
   const file = props.files[props.fileCardFor]
 
   const handleCancel = () => {
@@ -17,9 +20,17 @@ function EditorPanel (props) {
       id="uppy-DashboardContent-panel--editor"
     >
       <div className="uppy-DashboardContent-bar">
-        <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+        <div
+          className="uppy-DashboardContent-title"
+          role="heading"
+          aria-level="1"
+        >
           {props.i18nArray('editing', {
-            file: <span className="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>,
+            file: (
+              <span className="uppy-DashboardContent-titleFile">
+                {file.meta ? file.meta.name : file.name}
+              </span>
+            ),
           })}
         </div>
         <button
@@ -38,7 +49,7 @@ function EditorPanel (props) {
         </button>
       </div>
       <div className="uppy-DashboardContent-panelBody">
-        {props.editors.map((target) => {
+        {props.editors.map((target: $TSFixMe) => {
           return props.uppy.getPlugin(target.id).render(props.state)
         })}
       </div>

+ 0 - 46
packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.jsx

@@ -1,46 +0,0 @@
-import { h } from 'preact'
-
-export default function RenderMetaFields (props)  {
-  const {
-    computedMetaFields,
-    requiredMetaFields,
-    updateMeta,
-    form,
-    formState,
-  } = props
-
-  const fieldCSSClasses = {
-    text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input',
-  }
-
-  return computedMetaFields.map((field) => {
-    const id = `uppy-Dashboard-FileCard-input-${field.id}`
-    const required = requiredMetaFields.includes(field.id)
-    return (
-      <fieldset key={field.id} className="uppy-Dashboard-FileCard-fieldset">
-        <label className="uppy-Dashboard-FileCard-label" htmlFor={id}>{field.name}</label>
-        {field.render !== undefined
-          ? field.render({
-            value: formState[field.id],
-            onChange: (newVal) => updateMeta(newVal, field.id),
-            fieldCSSClasses,
-            required,
-            form: form.id,
-          }, h)
-          : (
-            <input
-              className={fieldCSSClasses.text}
-              id={id}
-              form={form.id}
-              type={field.type || 'text'}
-              required={required}
-              value={formState[field.id]}
-              placeholder={field.placeholder}
-              onInput={ev => updateMeta(ev.target.value, field.id)}
-              data-uppy-super-focusable
-            />
-          )}
-      </fieldset>
-    )
-  })
-}

+ 54 - 0
packages/@uppy/dashboard/src/components/FileCard/RenderMetaFields.tsx

@@ -0,0 +1,54 @@
+import { h } from 'preact'
+
+type $TSFixMe = any
+
+export default function RenderMetaFields(props: $TSFixMe): JSX.Element {
+  const {
+    computedMetaFields,
+    requiredMetaFields,
+    updateMeta,
+    form,
+    formState,
+  } = props
+
+  const fieldCSSClasses = {
+    text: 'uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input',
+  }
+
+  return computedMetaFields.map((field: $TSFixMe) => {
+    const id = `uppy-Dashboard-FileCard-input-${field.id}`
+    const required = requiredMetaFields.includes(field.id)
+    return (
+      <fieldset key={field.id} className="uppy-Dashboard-FileCard-fieldset">
+        <label className="uppy-Dashboard-FileCard-label" htmlFor={id}>
+          {field.name}
+        </label>
+        {field.render !== undefined ?
+          field.render(
+            {
+              value: formState[field.id],
+              onChange: (newVal: $TSFixMe) => updateMeta(newVal, field.id),
+              fieldCSSClasses,
+              required,
+              form: form.id,
+            },
+            h,
+          )
+        : <input
+            className={fieldCSSClasses.text}
+            id={id}
+            form={form.id}
+            type={field.type || 'text'}
+            required={required}
+            value={formState[field.id]}
+            placeholder={field.placeholder}
+            onInput={(ev) =>
+              updateMeta((ev.target as HTMLInputElement).value, field.id)
+            }
+            data-uppy-super-focusable
+          />
+        }
+      </fieldset>
+    )
+  })
+}

+ 36 - 21
packages/@uppy/dashboard/src/components/FileCard/index.jsx → packages/@uppy/dashboard/src/components/FileCard/index.tsx

@@ -2,12 +2,14 @@ import { h } from 'preact'
 import { useEffect, useState, useCallback } from 'preact/hooks'
 import classNames from 'classnames'
 import { nanoid } from 'nanoid/non-secure'
-import getFileTypeIcon from '../../utils/getFileTypeIcon.jsx'
-import ignoreEvent from '../../utils/ignoreEvent.js'
-import FilePreview from '../FilePreview.jsx'
-import RenderMetaFields from './RenderMetaFields.jsx'
+import getFileTypeIcon from '../../utils/getFileTypeIcon.tsx'
+import ignoreEvent from '../../utils/ignoreEvent.ts'
+import FilePreview from '../FilePreview.tsx'
+import RenderMetaFields from './RenderMetaFields.tsx'
 
-export default function FileCard (props) {
+type $TSFixMe = any
+
+export default function FileCard(props: $TSFixMe): JSX.Element {
   const {
     files,
     fileCardFor,
@@ -23,8 +25,8 @@ export default function FileCard (props) {
   } = props
 
   const getMetaFields = () => {
-    return typeof metaFields === 'function'
-      ? metaFields(files[fileCardFor])
+    return typeof metaFields === 'function' ?
+        metaFields(files[fileCardFor])
       : metaFields
   }
 
@@ -32,19 +34,22 @@ export default function FileCard (props) {
   const computedMetaFields = getMetaFields() ?? []
   const showEditButton = canEditFile(file)
 
-  const storedMetaData = {}
-  computedMetaFields.forEach((field) => {
+  const storedMetaData: Record<string, string> = {}
+  computedMetaFields.forEach((field: $TSFixMe) => {
     storedMetaData[field.id] = file.meta[field.id] ?? ''
   })
 
   const [formState, setFormState] = useState(storedMetaData)
 
-  const handleSave = useCallback((ev) => {
-    ev.preventDefault()
-    saveFileCard(formState, fileCardFor)
-  }, [saveFileCard, formState, fileCardFor])
+  const handleSave = useCallback(
+    (ev: $TSFixMe) => {
+      ev.preventDefault()
+      saveFileCard(formState, fileCardFor)
+    },
+    [saveFileCard, formState, fileCardFor],
+  )
 
-  const updateMeta = (newVal, name) => {
+  const updateMeta = (newVal: $TSFixMe, name: $TSFixMe) => {
     setFormState({
       ...formState,
       [name]: newVal,
@@ -81,9 +86,17 @@ export default function FileCard (props) {
       onPaste={ignoreEvent}
     >
       <div className="uppy-DashboardContent-bar">
-        <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+        <div
+          className="uppy-DashboardContent-title"
+          role="heading"
+          aria-level="1"
+        >
           {i18nArray('editing', {
-            file: <span className="uppy-DashboardContent-titleFile">{file.meta ? file.meta.name : file.name}</span>,
+            file: (
+              <span className="uppy-DashboardContent-titleFile">
+                {file.meta ? file.meta.name : file.name}
+              </span>
+            ),
           })}
         </div>
         <button
@@ -98,14 +111,16 @@ export default function FileCard (props) {
       </div>
 
       <div className="uppy-Dashboard-FileCard-inner">
-        <div className="uppy-Dashboard-FileCard-preview" style={{ backgroundColor: getFileTypeIcon(file.type).color }}>
+        <div
+          className="uppy-Dashboard-FileCard-preview"
+          style={{ backgroundColor: getFileTypeIcon(file.type).color }}
+        >
           <FilePreview file={file} />
-          {showEditButton
-            && (
+          {showEditButton && (
             <button
               type="button"
               className="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
-              onClick={(event) => {
+              onClick={(event: $TSFixMe) => {
                 // When opening the image editor we want to save any meta fields changes.
                 // Otherwise it's confusing for the user to click save in the editor,
                 // but the changes here are discarded. This bypasses validation,
@@ -119,7 +134,7 @@ export default function FileCard (props) {
               we can conditionally display i18n('editFile')/i18n('editImage'). */}
               {i18n('editImage')}
             </button>
-            )}
+          )}
         </div>
 
         <div className="uppy-Dashboard-FileCard-info">

+ 58 - 27
packages/@uppy/dashboard/src/components/FileItem/Buttons/index.jsx → packages/@uppy/dashboard/src/components/FileItem/Buttons/index.tsx

@@ -1,17 +1,19 @@
-import { h } from 'preact'
-import copyToClipboard from '../../../utils/copyToClipboard.js'
+import { h, type ComponentChild } from 'preact'
+import copyToClipboard from '../../../utils/copyToClipboard.ts'
 
-function EditButton ({
+type $TSFixMe = any
+
+function EditButton({
   file,
   uploadInProgressOrComplete,
   metaFields,
   canEditFile,
   i18n,
   onClick,
-}) {
+}: $TSFixMe) {
   if (
-    (!uploadInProgressOrComplete && metaFields && metaFields.length > 0)
-    || (!uploadInProgressOrComplete && canEditFile(file))
+    (!uploadInProgressOrComplete && metaFields && metaFields.length > 0) ||
+    (!uploadInProgressOrComplete && canEditFile(file))
   ) {
     return (
       <button
@@ -21,11 +23,24 @@ function EditButton ({
         title={i18n('editFileWithFilename', { file: file.meta.name })}
         onClick={() => onClick()}
       >
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="14" height="14" viewBox="0 0 14 14">
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon"
+          width="14"
+          height="14"
+          viewBox="0 0 14 14"
+        >
           <g fillRule="evenodd">
-            <path d="M1.5 10.793h2.793A1 1 0 0 0 5 10.5L11.5 4a1 1 0 0 0 0-1.414L9.707.793a1 1 0 0 0-1.414 0l-6.5 6.5A1 1 0 0 0 1.5 8v2.793zm1-1V8L9 1.5l1.793 1.793-6.5 6.5H2.5z" fillRule="nonzero" />
+            <path
+              d="M1.5 10.793h2.793A1 1 0 0 0 5 10.5L11.5 4a1 1 0 0 0 0-1.414L9.707.793a1 1 0 0 0-1.414 0l-6.5 6.5A1 1 0 0 0 1.5 8v2.793zm1-1V8L9 1.5l1.793 1.793-6.5 6.5H2.5z"
+              fillRule="nonzero"
+            />
             <rect x="1" y="12.293" width="11" height="1" rx=".5" />
-            <path fillRule="nonzero" d="M6.793 2.5L9.5 5.207l.707-.707L7.5 1.793z" />
+            <path
+              fillRule="nonzero"
+              d="M6.793 2.5L9.5 5.207l.707-.707L7.5 1.793z"
+            />
           </g>
         </svg>
       </button>
@@ -34,7 +49,7 @@ function EditButton ({
   return null
 }
 
-function RemoveButton ({ i18n, onClick, file }) {
+function RemoveButton({ i18n, onClick, file }: $TSFixMe) {
   return (
     <button
       className="uppy-u-reset uppy-Dashboard-Item-action uppy-Dashboard-Item-action--remove"
@@ -43,16 +58,29 @@ function RemoveButton ({ i18n, onClick, file }) {
       title={i18n('removeFile', { file: file.meta.name })}
       onClick={() => onClick()}
     >
-      <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="18" height="18" viewBox="0 0 18 18">
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-c-icon"
+        width="18"
+        height="18"
+        viewBox="0 0 18 18"
+      >
         <path d="M9 0C4.034 0 0 4.034 0 9s4.034 9 9 9 9-4.034 9-9-4.034-9-9-9z" />
-        <path fill="#FFF" d="M13 12.222l-.778.778L9 9.778 5.778 13 5 12.222 8.222 9 5 5.778 5.778 5 9 8.222 12.222 5l.778.778L9.778 9z" />
+        <path
+          fill="#FFF"
+          d="M13 12.222l-.778.778L9 9.778 5.778 13 5 12.222 8.222 9 5 5.778 5.778 5 9 8.222 12.222 5l.778.778L9.778 9z"
+        />
       </svg>
     </button>
   )
 }
 
-const copyLinkToClipboard = (event, props) => {
-  copyToClipboard(props.file.uploadURL, props.i18n('copyLinkToClipboardFallback'))
+const copyLinkToClipboard = (event: $TSFixMe, props: $TSFixMe) => {
+  copyToClipboard(
+    props.file.uploadURL,
+    props.i18n('copyLinkToClipboardFallback'),
+  )
     .then(() => {
       props.uppy.log('Link copied to clipboard.')
       props.uppy.info(props.i18n('copyLinkToClipboardSuccess'), 'info', 3000)
@@ -62,7 +90,7 @@ const copyLinkToClipboard = (event, props) => {
     .then(() => event.target.focus({ preventScroll: true }))
 }
 
-function CopyLinkButton (props) {
+function CopyLinkButton(props: $TSFixMe) {
   const { i18n } = props
 
   return (
@@ -73,14 +101,21 @@ function CopyLinkButton (props) {
       title={i18n('copyLink')}
       onClick={(event) => copyLinkToClipboard(event, props)}
     >
-      <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="14" height="14" viewBox="0 0 14 12">
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-c-icon"
+        width="14"
+        height="14"
+        viewBox="0 0 14 12"
+      >
         <path d="M7.94 7.703a2.613 2.613 0 0 1-.626 2.681l-.852.851a2.597 2.597 0 0 1-1.849.766A2.616 2.616 0 0 1 2.764 7.54l.852-.852a2.596 2.596 0 0 1 2.69-.625L5.267 7.099a1.44 1.44 0 0 0-.833.407l-.852.851a1.458 1.458 0 0 0 1.03 2.486c.39 0 .755-.152 1.03-.426l.852-.852c.231-.231.363-.522.406-.824l1.04-1.038zm4.295-5.937A2.596 2.596 0 0 0 10.387 1c-.698 0-1.355.272-1.849.766l-.852.851a2.614 2.614 0 0 0-.624 2.688l1.036-1.036c.041-.304.173-.6.407-.833l.852-.852c.275-.275.64-.426 1.03-.426a1.458 1.458 0 0 1 1.03 2.486l-.852.851a1.442 1.442 0 0 1-.824.406l-1.04 1.04a2.596 2.596 0 0 0 2.683-.628l.851-.85a2.616 2.616 0 0 0 0-3.697zm-6.88 6.883a.577.577 0 0 0 .82 0l3.474-3.474a.579.579 0 1 0-.819-.82L5.355 7.83a.579.579 0 0 0 0 .819z" />
       </svg>
     </button>
   )
 }
 
-export default function Buttons (props) {
+export default function Buttons(props: $TSFixMe): ComponentChild {
   const {
     uppy,
     file,
@@ -112,21 +147,17 @@ export default function Buttons (props) {
         metaFields={metaFields}
         onClick={editAction}
       />
-      {showLinkToFileUploadResult && file.uploadURL ? (
-        <CopyLinkButton
-          file={file}
-          uppy={uppy}
-          i18n={i18n}
-        />
-      ) : null}
-      {showRemoveButton ? (
+      {showLinkToFileUploadResult && file.uploadURL ?
+        <CopyLinkButton file={file} uppy={uppy} i18n={i18n} />
+      : null}
+      {showRemoveButton ?
         <RemoveButton
           i18n={i18n}
           file={file}
           uppy={uppy}
-          onClick={() => props.uppy.removeFile(file.id, 'removed-by-user')}
+          onClick={() => uppy.removeFile(file.id, 'removed-by-user')}
         />
-      ) : null}
+      : null}
     </div>
   )
 }

+ 31 - 26
packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.jsx → packages/@uppy/dashboard/src/components/FileItem/FileInfo/index.tsx

@@ -1,12 +1,15 @@
-import {  h, Fragment  } from 'preact'
+/* eslint-disable react/destructuring-assignment */
+import { h, Fragment, type ComponentChild } from 'preact'
 import prettierBytes from '@transloadit/prettier-bytes'
 import truncateString from '@uppy/utils/lib/truncateString'
-import MetaErrorMessage from '../MetaErrorMessage.jsx'
+import MetaErrorMessage from '../MetaErrorMessage.tsx'
 
-const renderFileName = (props) => {
+type $TSFixMe = any
+
+const renderFileName = (props: $TSFixMe) => {
   const { author, name } = props.file.meta
 
-  function getMaxNameLength () {
+  function getMaxNameLength() {
     if (props.isSingleFile && props.containerHeight >= 350) {
       return 90
     }
@@ -29,7 +32,7 @@ const renderFileName = (props) => {
   )
 }
 
-const renderAuthor = (props) => {
+const renderAuthor = (props: $TSFixMe) => {
   const { author } = props.file.meta
   const providerName = props.file.remote?.providerName
   const dot = `\u00B7`
@@ -47,37 +50,39 @@ const renderAuthor = (props) => {
       >
         {truncateString(author.name, 13)}
       </a>
-      {providerName ? (
+      {providerName ?
         <>
           {` ${dot} `}
           {providerName}
           {` ${dot} `}
         </>
-      ) : null}
+      : null}
     </div>
   )
 }
 
-const renderFileSize = (props) => props.file.size && (
-  <div className="uppy-Dashboard-Item-statusSize">
-    {prettierBytes(props.file.size)}
-  </div>
-)
+const renderFileSize = (props: $TSFixMe) =>
+  props.file.size && (
+    <div className="uppy-Dashboard-Item-statusSize">
+      {prettierBytes(props.file.size)}
+    </div>
+  )
 
-const ReSelectButton = (props) => props.file.isGhost && (
-  <span>
-    {' \u2022 '}
-    <button
-      className="uppy-u-reset uppy-c-btn uppy-Dashboard-Item-reSelect"
-      type="button"
-      onClick={props.toggleAddFilesPanel}
-    >
-      {props.i18n('reSelect')}
-    </button>
-  </span>
-)
+const ReSelectButton = (props: $TSFixMe) =>
+  props.file.isGhost && (
+    <span>
+      {' \u2022 '}
+      <button
+        className="uppy-u-reset uppy-c-btn uppy-Dashboard-Item-reSelect"
+        type="button"
+        onClick={props.toggleAddFilesPanel}
+      >
+        {props.i18n('reSelect')}
+      </button>
+    </span>
+  )
 
-const ErrorButton = ({ file, onClick }) => {
+const ErrorButton = ({ file, onClick }: $TSFixMe) => {
   if (file.error) {
     return (
       <button
@@ -95,7 +100,7 @@ const ErrorButton = ({ file, onClick }) => {
   return null
 }
 
-export default function FileInfo (props) {
+export default function FileInfo(props: $TSFixMe): ComponentChild {
   const { file } = props
   return (
     <div

+ 0 - 39
packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.jsx

@@ -1,39 +0,0 @@
-import { h } from 'preact'
-import FilePreview from '../../FilePreview.jsx'
-import MetaErrorMessage from '../MetaErrorMessage.jsx'
-import getFileTypeIcon from '../../../utils/getFileTypeIcon.jsx'
-
-export default function FilePreviewAndLink (props) {
-  const { file, i18n, toggleFileCard, metaFields, showLinkToFileUploadResult } = props
-  const white = 'rgba(255, 255, 255, 0.5)'
-  const previewBackgroundColor = file.preview ? white : getFileTypeIcon(props.file.type).color
-
-  return (
-    <div
-      className="uppy-Dashboard-Item-previewInnerWrap"
-      style={{ backgroundColor: previewBackgroundColor }}
-    >
-      {
-        showLinkToFileUploadResult && file.uploadURL
-          && (
-          <a
-            className="uppy-Dashboard-Item-previewLink"
-            href={file.uploadURL}
-            rel="noreferrer noopener"
-            target="_blank"
-            aria-label={file.meta.name}
-          >
-            <span hidden>{file.meta.name}</span>
-          </a>
-          )
-      }
-      <FilePreview file={file} />
-      <MetaErrorMessage
-        file={file}
-        i18n={i18n}
-        toggleFileCard={toggleFileCard}
-        metaFields={metaFields}
-      />
-    </div>
-  )
-}

+ 40 - 0
packages/@uppy/dashboard/src/components/FileItem/FilePreviewAndLink/index.tsx

@@ -0,0 +1,40 @@
+import { h, type ComponentChild } from 'preact'
+import FilePreview from '../../FilePreview.tsx'
+import MetaErrorMessage from '../MetaErrorMessage.tsx'
+import getFileTypeIcon from '../../../utils/getFileTypeIcon.tsx'
+
+type $TSFixMe = any
+
+export default function FilePreviewAndLink(props: $TSFixMe): ComponentChild {
+  const { file, i18n, toggleFileCard, metaFields, showLinkToFileUploadResult } =
+    props
+  const white = 'rgba(255, 255, 255, 0.5)'
+  const previewBackgroundColor =
+    file.preview ? white : getFileTypeIcon(file.type).color
+
+  return (
+    <div
+      className="uppy-Dashboard-Item-previewInnerWrap"
+      style={{ backgroundColor: previewBackgroundColor }}
+    >
+      {showLinkToFileUploadResult && file.uploadURL && (
+        <a
+          className="uppy-Dashboard-Item-previewLink"
+          href={file.uploadURL}
+          rel="noreferrer noopener"
+          target="_blank"
+          aria-label={file.meta.name}
+        >
+          <span hidden>{file.meta.name}</span>
+        </a>
+      )}
+      <FilePreview file={file} />
+      <MetaErrorMessage
+        file={file}
+        i18n={i18n}
+        toggleFileCard={toggleFileCard}
+        metaFields={metaFields}
+      />
+    </div>
+  )
+}

+ 49 - 22
packages/@uppy/dashboard/src/components/FileItem/FileProgress/index.jsx → packages/@uppy/dashboard/src/components/FileItem/FileProgress/index.tsx

@@ -1,6 +1,9 @@
-import { h } from 'preact'
+/* eslint-disable react/destructuring-assignment */
+import { h, type ComponentChild } from 'preact'
 
-function onPauseResumeCancelRetry (props) {
+type $TSFixMe = any
+
+function onPauseResumeCancelRetry(props: $TSFixMe) {
   if (props.isUploaded) return
 
   if (props.error && !props.hideRetryButton) {
@@ -15,7 +18,7 @@ function onPauseResumeCancelRetry (props) {
   }
 }
 
-function progressIndicatorTitle (props) {
+function progressIndicatorTitle(props: $TSFixMe) {
   if (props.isUploaded) {
     return props.i18n('uploadComplete')
   }
@@ -29,14 +32,15 @@ function progressIndicatorTitle (props) {
       return props.i18n('resumeUpload')
     }
     return props.i18n('pauseUpload')
-  } if (props.individualCancellation) {
+  }
+  if (props.individualCancellation) {
     return props.i18n('cancelUpload')
   }
 
   return ''
 }
 
-function ProgressIndicatorButton (props) {
+function ProgressIndicatorButton(props: $TSFixMe) {
   return (
     <div className="uppy-Dashboard-Item-progress">
       <button
@@ -52,7 +56,7 @@ function ProgressIndicatorButton (props) {
   )
 }
 
-function ProgressCircleContainer ({ children }) {
+function ProgressCircleContainer({ children }: $TSFixMe) {
   return (
     <svg
       aria-hidden="true"
@@ -67,7 +71,7 @@ function ProgressCircleContainer ({ children }) {
   )
 }
 
-function ProgressCircle ({ progress }) {
+function ProgressCircle({ progress }: $TSFixMe) {
   // circle length equals 2 * PI * R
   const circleLength = 2 * Math.PI * 15
 
@@ -90,13 +94,13 @@ function ProgressCircle ({ progress }) {
         fill="none"
         stroke-width="2"
         stroke-dasharray={circleLength}
-        stroke-dashoffset={circleLength - ((circleLength / 100) * progress)}
+        stroke-dashoffset={circleLength - (circleLength / 100) * progress}
       />
     </g>
   )
 }
 
-export default function FileProgress (props) {
+export default function FileProgress(props: $TSFixMe): ComponentChild {
   // Nothing if upload has not started
   if (!props.file.progress.uploadStarted) {
     return null
@@ -109,7 +113,11 @@ export default function FileProgress (props) {
         <div className="uppy-Dashboard-Item-progressIndicator">
           <ProgressCircleContainer>
             <circle r="15" cx="18" cy="18" fill="#1bb240" />
-            <polygon className="uppy-Dashboard-Item-progressIcon--check" transform="translate(2, 3)" points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634" />
+            <polygon
+              className="uppy-Dashboard-Item-progressIcon--check"
+              transform="translate(2, 3)"
+              points="14 22.5 7 15.2457065 8.99985857 13.1732815 14 18.3547104 22.9729883 9 25 11.1005634"
+            />
           </ProgressCircleContainer>
         </div>
       </div>
@@ -125,7 +133,14 @@ export default function FileProgress (props) {
     return (
       // eslint-disable-next-line react/jsx-props-no-spreading
       <ProgressIndicatorButton {...props}>
-        <svg aria-hidden="true" focusable="false" className="uppy-c-icon uppy-Dashboard-Item-progressIcon--retry" width="28" height="31" viewBox="0 0 16 19">
+        <svg
+          aria-hidden="true"
+          focusable="false"
+          className="uppy-c-icon uppy-Dashboard-Item-progressIcon--retry"
+          width="28"
+          height="31"
+          viewBox="0 0 16 19"
+        >
           <path d="M16 11a8 8 0 1 1-8-8v2a6 6 0 1 0 6 6h2z" />
           <path d="M7.9 3H10v2H7.9z" />
           <path d="M8.536.5l3.535 3.536-1.414 1.414L7.12 1.914z" />
@@ -142,15 +157,19 @@ export default function FileProgress (props) {
       <ProgressIndicatorButton {...props}>
         <ProgressCircleContainer>
           <ProgressCircle progress={props.file.progress.percentage} />
-          {
-            props.file.isPaused
-              ? <polygon className="uppy-Dashboard-Item-progressIcon--play" transform="translate(3, 3)" points="12 20 12 10 20 15" />
-              : (
-                <g className="uppy-Dashboard-Item-progressIcon--pause" transform="translate(14.5, 13)">
-                  <rect x="0" y="0" width="2" height="10" rx="0" />
-                  <rect x="5" y="0" width="2" height="10" rx="0" />
-                </g>
-              )
+          {props.file.isPaused ?
+            <polygon
+              className="uppy-Dashboard-Item-progressIcon--play"
+              transform="translate(3, 3)"
+              points="12 20 12 10 20 15"
+            />
+          : <g
+              className="uppy-Dashboard-Item-progressIcon--pause"
+              transform="translate(14.5, 13)"
+            >
+              <rect x="0" y="0" width="2" height="10" rx="0" />
+              <rect x="5" y="0" width="2" height="10" rx="0" />
+            </g>
           }
         </ProgressCircleContainer>
       </ProgressIndicatorButton>
@@ -158,13 +177,21 @@ export default function FileProgress (props) {
   }
 
   // Cancel button for non-resumable uploads if individualCancellation is supported (not bundled)
-  if (!props.resumableUploads && props.individualCancellation && !props.hideCancelButton) {
+  if (
+    !props.resumableUploads &&
+    props.individualCancellation &&
+    !props.hideCancelButton
+  ) {
     return (
       // eslint-disable-next-line react/jsx-props-no-spreading
       <ProgressIndicatorButton {...props}>
         <ProgressCircleContainer>
           <ProgressCircle progress={props.file.progress.percentage} />
-          <polygon className="cancel" transform="translate(2, 2)" points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12" />
+          <polygon
+            className="cancel"
+            transform="translate(2, 2)"
+            points="19.8856516 11.0625 16 14.9481516 12.1019737 11.0625 11.0625 12.1143484 14.9481516 16 11.0625 19.8980263 12.1019737 20.9375 16 17.0518484 19.8856516 20.9375 20.9375 19.8980263 17.0518484 16 20.9375 12"
+          />
         </ProgressCircleContainer>
       </ProgressIndicatorButton>
     )

+ 12 - 9
packages/@uppy/dashboard/src/components/FileItem/MetaErrorMessage.jsx → packages/@uppy/dashboard/src/components/FileItem/MetaErrorMessage.tsx

@@ -1,29 +1,32 @@
 import { h } from 'preact'
 
-const metaFieldIdToName = (metaFieldId, metaFields) => {
+type $TSFixMe = any
+
+const metaFieldIdToName = (metaFieldId: $TSFixMe, metaFields: $TSFixMe) => {
   const fields = typeof metaFields === 'function' ? metaFields() : metaFields
-  const field = fields.filter(f => f.id === metaFieldId)
+  const field = fields.filter((f: $TSFixMe) => f.id === metaFieldId)
   return field[0].name
 }
 
-export default function renderMissingMetaFieldsError (props) {
+export default function MetaErrorMessage(props: $TSFixMe): JSX.Element {
   const { file, toggleFileCard, i18n, metaFields } = props
   const { missingRequiredMetaFields } = file
   if (!missingRequiredMetaFields?.length) {
-    return null
+    return null as $TSFixMe
   }
 
-  const metaFieldsString = missingRequiredMetaFields.map(missingMetaField => (
-    metaFieldIdToName(missingMetaField, metaFields)
-  )).join(', ')
+  const metaFieldsString = missingRequiredMetaFields
+    .map((missingMetaField: $TSFixMe) =>
+      metaFieldIdToName(missingMetaField, metaFields),
+    )
+    .join(', ')
 
   return (
     <div className="uppy-Dashboard-Item-errorMessage">
       {i18n('missingRequiredMetaFields', {
         smart_count: missingRequiredMetaFields.length,
         fields: metaFieldsString,
-      })}
-      {' '}
+      })}{' '}
       <button
         type="button"
         class="uppy-u-reset uppy-Dashboard-Item-errorMessageBtn"

+ 29 - 16
packages/@uppy/dashboard/src/components/FileItem/index.jsx → packages/@uppy/dashboard/src/components/FileItem/index.tsx

@@ -1,54 +1,65 @@
-import {  h, Component  } from 'preact'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck Typing this file requires more work, skipping it to unblock the rest of the transition.
+
+/* eslint-disable react/destructuring-assignment */
+import { h, Component, type ComponentChild } from 'preact'
 import classNames from 'classnames'
 import shallowEqual from 'is-shallow-equal'
-import FilePreviewAndLink from './FilePreviewAndLink/index.jsx'
-import FileProgress from './FileProgress/index.jsx'
-import FileInfo from './FileInfo/index.jsx'
-import Buttons from './Buttons/index.jsx'
+import FilePreviewAndLink from './FilePreviewAndLink/index.tsx'
+import FileProgress from './FileProgress/index.tsx'
+import FileInfo from './FileInfo/index.tsx'
+import Buttons from './Buttons/index.tsx'
+
+type $TSFixMe = any
 
 export default class FileItem extends Component {
-  componentDidMount () {
+  componentDidMount(): void {
     const { file } = this.props
     if (!file.preview) {
       this.props.handleRequestThumbnail(file)
     }
   }
 
-  shouldComponentUpdate (nextProps) {
+  shouldComponentUpdate(nextProps: $TSFixMe): boolean {
     return !shallowEqual(this.props, nextProps)
   }
 
   // VirtualList mounts FileItems again and they emit `thumbnail:request`
   // Otherwise thumbnails are broken or missing after Golden Retriever restores files
-  componentDidUpdate () {
+  componentDidUpdate(): void {
     const { file } = this.props
     if (!file.preview) {
       this.props.handleRequestThumbnail(file)
     }
   }
 
-  componentWillUnmount () {
+  componentWillUnmount(): void {
     const { file } = this.props
     if (!file.preview) {
       this.props.handleCancelThumbnail(file)
     }
   }
 
-  render () {
+  render(): ComponentChild {
     const { file } = this.props
 
     const isProcessing = file.progress.preprocess || file.progress.postprocess
-    const isUploaded = file.progress.uploadComplete && !isProcessing && !file.error
-    const uploadInProgressOrComplete = file.progress.uploadStarted || isProcessing
-    const uploadInProgress = (file.progress.uploadStarted && !file.progress.uploadComplete) || isProcessing
+    const isUploaded =
+      file.progress.uploadComplete && !isProcessing && !file.error
+    const uploadInProgressOrComplete =
+      file.progress.uploadStarted || isProcessing
+    const uploadInProgress =
+      (file.progress.uploadStarted && !file.progress.uploadComplete) ||
+      isProcessing
     const error = file.error || false
 
     // File that Golden Retriever was able to partly restore (only meta, not blob),
     // users still need to re-add it, so it’s a ghost
     const { isGhost } = file
 
-    let showRemoveButton = this.props.individualCancellation
-      ? !isUploaded
+    let showRemoveButton =
+      this.props.individualCancellation ?
+        !isUploaded
       : !uploadInProgress && !isUploaded
 
     if (isUploaded && this.props.showRemoveButtonAfterComplete) {
@@ -89,7 +100,9 @@ export default class FileItem extends Component {
             hideCancelButton={this.props.hideCancelButton}
             hidePauseResumeButton={this.props.hidePauseResumeButton}
             recoveredState={this.props.recoveredState}
-            showRemoveButtonAfterComplete={this.props.showRemoveButtonAfterComplete}
+            showRemoveButtonAfterComplete={
+              this.props.showRemoveButtonAfterComplete
+            }
             resumableUploads={this.props.resumableUploads}
             individualCancellation={this.props.individualCancellation}
             i18n={this.props.i18n}

+ 50 - 24
packages/@uppy/dashboard/src/components/FileList.jsx → packages/@uppy/dashboard/src/components/FileList.tsx

@@ -1,12 +1,16 @@
 import { h } from 'preact'
 import { useMemo } from 'preact/hooks'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import VirtualList from '@uppy/utils/lib/VirtualList'
-import FileItem from './FileItem/index.jsx'
+import FileItem from './FileItem/index.tsx'
 
-function chunks (list, size) {
-  const chunked = []
-  let currentChunk = []
-  list.forEach((item) => {
+type $TSFixMe = any
+
+function chunks(list: $TSFixMe, size: $TSFixMe) {
+  const chunked: $TSFixMe[] = []
+  let currentChunk: $TSFixMe[] = []
+  list.forEach((item: $TSFixMe) => {
     if (currentChunk.length < size) {
       currentChunk.push(item)
     } else {
@@ -18,37 +22,63 @@ function chunks (list, size) {
   return chunked
 }
 
-export default ({
-  id, error, i18n, uppy, files, acquirers, resumableUploads, hideRetryButton, hidePauseResumeButton, hideCancelButton,
-  showLinkToFileUploadResult, showRemoveButtonAfterComplete, isWide, metaFields, isSingleFile, toggleFileCard,
-  handleRequestThumbnail, handleCancelThumbnail, recoveredState, individualCancellation, itemsPerRow, openFileEditor,
-  canEditFile, toggleAddFilesPanel, containerWidth, containerHeight,
-}) => {
+export default function FileList({
+  id,
+  error,
+  i18n,
+  uppy,
+  files,
+  acquirers,
+  resumableUploads,
+  hideRetryButton,
+  hidePauseResumeButton,
+  hideCancelButton,
+  showLinkToFileUploadResult,
+  showRemoveButtonAfterComplete,
+  isWide,
+  metaFields,
+  isSingleFile,
+  toggleFileCard,
+  handleRequestThumbnail,
+  handleCancelThumbnail,
+  recoveredState,
+  individualCancellation,
+  itemsPerRow,
+  openFileEditor,
+  canEditFile,
+  toggleAddFilesPanel,
+  containerWidth,
+  containerHeight,
+}: $TSFixMe): JSX.Element {
   // It's not great that this is hardcoded!
   // It's ESPECIALLY not great that this is checking against `itemsPerRow`!
-  const rowHeight = itemsPerRow === 1
-    // Mobile
-    ? 71
-    // 190px height + 2 * 5px margin
+  const rowHeight =
+    itemsPerRow === 1 ?
+      // Mobile
+      71
+      // 190px height + 2 * 5px margin
     : 200
 
   // Sort files by file.isGhost, ghost files first, only if recoveredState is present
   const rows = useMemo(() => {
-    const sortByGhostComesFirst = (file1, file2) => files[file2].isGhost - files[file1].isGhost
+    const sortByGhostComesFirst = (file1: $TSFixMe, file2: $TSFixMe) =>
+      files[file2].isGhost - files[file1].isGhost
 
     const fileIds = Object.keys(files)
     if (recoveredState) fileIds.sort(sortByGhostComesFirst)
     return chunks(fileIds, itemsPerRow)
   }, [files, itemsPerRow, recoveredState])
 
-  const renderRow = (row) => (
-    // The `role="presentation` attribute ensures that the list items are properly
+  const renderRow = (
+    row: $TSFixMe, // The `role="presentation` attribute ensures that the list items are properly
+  ) => (
     // associated with the `VirtualList` element.
     // We use the first file ID as the key—this should not change across scroll rerenders
     <div class="uppy-Dashboard-filesInner" role="presentation" key={row[0]}>
-      {row.map((fileID) => (
+      {row.map((fileID: $TSFixMe) => (
         <FileItem
           key={fileID}
+          // @ts-expect-error it's fine
           uppy={uppy}
           // FIXME This is confusing, it's actually the Dashboard's plugin ID
           id={id}
@@ -86,11 +116,7 @@ export default ({
   )
 
   if (isSingleFile) {
-    return (
-      <div class="uppy-Dashboard-files">
-        {renderRow(rows[0])}
-      </div>
-    )
+    return <div class="uppy-Dashboard-files">{renderRow(rows[0])}</div>
   }
 
   return (

+ 15 - 4
packages/@uppy/dashboard/src/components/FilePreview.jsx → packages/@uppy/dashboard/src/components/FilePreview.tsx

@@ -1,7 +1,9 @@
 import { h } from 'preact'
-import getFileTypeIcon from '../utils/getFileTypeIcon.jsx'
+import getFileTypeIcon from '../utils/getFileTypeIcon.tsx'
 
-export default function FilePreview (props) {
+type $TSFixMe = any
+
+export default function FilePreview(props: $TSFixMe): JSX.Element {
   const { file } = props
 
   if (file.preview) {
@@ -18,8 +20,17 @@ export default function FilePreview (props) {
 
   return (
     <div className="uppy-Dashboard-Item-previewIconWrap">
-      <span className="uppy-Dashboard-Item-previewIcon" style={{ color }}>{icon}</span>
-      <svg aria-hidden="true" focusable="false" className="uppy-Dashboard-Item-previewIconBg" width="58" height="76" viewBox="0 0 58 76">
+      <span className="uppy-Dashboard-Item-previewIcon" style={{ color }}>
+        {icon}
+      </span>
+      <svg
+        aria-hidden="true"
+        focusable="false"
+        className="uppy-Dashboard-Item-previewIconBg"
+        width="58"
+        height="76"
+        viewBox="0 0 58 76"
+      >
         <rect fill="#FFF" width="58" height="76" rx="3" fillRule="evenodd" />
       </svg>
     </div>

+ 16 - 3
packages/@uppy/dashboard/src/components/PickerPanelContent.jsx → packages/@uppy/dashboard/src/components/PickerPanelContent.tsx

@@ -1,8 +1,17 @@
 import { h } from 'preact'
 import classNames from 'classnames'
-import ignoreEvent from '../utils/ignoreEvent.js'
+import ignoreEvent from '../utils/ignoreEvent.ts'
 
-function PickerPanelContent ({ activePickerPanel, className, hideAllPanels, i18n, state, uppy }) {
+type $TSFixMe = any
+
+function PickerPanelContent({
+  activePickerPanel,
+  className,
+  hideAllPanels,
+  i18n,
+  state,
+  uppy,
+}: $TSFixMe): JSX.Element {
   return (
     <div
       className={classNames('uppy-DashboardContent-panel', className)}
@@ -15,7 +24,11 @@ function PickerPanelContent ({ activePickerPanel, className, hideAllPanels, i18n
       onPaste={ignoreEvent}
     >
       <div className="uppy-DashboardContent-bar">
-        <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+        <div
+          className="uppy-DashboardContent-title"
+          role="heading"
+          aria-level="1"
+        >
           {i18n('importFrom', { name: activePickerPanel.name })}
         </div>
         <button

+ 57 - 21
packages/@uppy/dashboard/src/components/PickerPanelTopBar.jsx → packages/@uppy/dashboard/src/components/PickerPanelTopBar.tsx

@@ -1,5 +1,8 @@
+import type { UppyFile } from '@uppy/utils/lib/UppyFile'
 import { h } from 'preact'
 
+type $TSFixMe = any
+
 const uploadStates = {
   STATE_ERROR: 'error',
   STATE_WAITING: 'waiting',
@@ -10,7 +13,12 @@ const uploadStates = {
   STATE_PAUSED: 'paused',
 }
 
-function getUploadingState (isAllErrored, isAllComplete, isAllPaused, files = {}) {
+function getUploadingState(
+  isAllErrored: $TSFixMe,
+  isAllComplete: $TSFixMe,
+  isAllPaused: $TSFixMe,
+  files: Record<string, UppyFile<any, any>> = {},
+): $TSFixMe {
   if (isAllErrored) {
     return uploadStates.STATE_ERROR
   }
@@ -26,7 +34,7 @@ function getUploadingState (isAllErrored, isAllComplete, isAllPaused, files = {}
   let state = uploadStates.STATE_WAITING
   const fileIDs = Object.keys(files)
   for (let i = 0; i < fileIDs.length; i++) {
-    const { progress } = files[fileIDs[i]]
+    const { progress } = files[fileIDs[i] as keyof typeof files]
     // If ANY files are being uploaded right now, show the uploading state.
     if (progress.uploadStarted && !progress.uploadComplete) {
       return uploadStates.STATE_UPLOADING
@@ -38,17 +46,27 @@ function getUploadingState (isAllErrored, isAllComplete, isAllPaused, files = {}
     }
     // If NO files are being preprocessed or uploaded right now, but some files are
     // being postprocessed, show the postprocess state.
-    if (progress.postprocess && state !== uploadStates.STATE_UPLOADING && state !== uploadStates.STATE_PREPROCESSING) {
+    if (
+      progress.postprocess &&
+      state !== uploadStates.STATE_UPLOADING &&
+      state !== uploadStates.STATE_PREPROCESSING
+    ) {
       state = uploadStates.STATE_POSTPROCESSING
     }
   }
   return state
 }
 
-function UploadStatus ({
-  files, i18n, isAllComplete, isAllErrored, isAllPaused,
-  inProgressNotPausedFiles, newFiles, processingFiles,
-}) {
+function UploadStatus({
+  files,
+  i18n,
+  isAllComplete,
+  isAllErrored,
+  isAllPaused,
+  inProgressNotPausedFiles,
+  newFiles,
+  processingFiles,
+}: $TSFixMe) {
   const uploadingState = getUploadingState(
     isAllErrored,
     isAllComplete,
@@ -58,7 +76,9 @@ function UploadStatus ({
 
   switch (uploadingState) {
     case 'uploading':
-      return i18n('uploadingXFiles', { smart_count: inProgressNotPausedFiles.length })
+      return i18n('uploadingXFiles', {
+        smart_count: inProgressNotPausedFiles.length,
+      })
     case 'preprocessing':
     case 'postprocessing':
       return i18n('processingXFiles', { smart_count: processingFiles.length })
@@ -74,8 +94,15 @@ function UploadStatus ({
   }
 }
 
-function PanelTopBar (props) {
-  const { i18n, isAllComplete, hideCancelButton, maxNumberOfFiles, toggleAddFilesPanel, uppy } = props
+function PanelTopBar(props: $TSFixMe): JSX.Element {
+  const {
+    i18n,
+    isAllComplete,
+    hideCancelButton,
+    maxNumberOfFiles,
+    toggleAddFilesPanel,
+    uppy,
+  } = props
   let { allowNewUpload } = props
   // TODO maybe this should be done in ../Dashboard.jsx, then just pass that down as `allowNewUpload`
   if (allowNewUpload && maxNumberOfFiles) {
@@ -85,7 +112,7 @@ function PanelTopBar (props) {
 
   return (
     <div className="uppy-DashboardContent-bar">
-      {!isAllComplete && !hideCancelButton ? (
+      {!isAllComplete && !hideCancelButton ?
         <button
           className="uppy-DashboardContent-back"
           type="button"
@@ -93,16 +120,18 @@ function PanelTopBar (props) {
         >
           {i18n('cancel')}
         </button>
-      ) : (
-        <div />
-      )}
+      : <div />}
 
-      <div className="uppy-DashboardContent-title" role="heading" aria-level="1">
+      <div
+        className="uppy-DashboardContent-title"
+        role="heading"
+        aria-level="1"
+      >
         {/* eslint-disable-next-line react/jsx-props-no-spreading */}
         <UploadStatus {...props} />
       </div>
 
-      {allowNewUpload ? (
+      {allowNewUpload ?
         <button
           className="uppy-DashboardContent-addMore"
           type="button"
@@ -110,14 +139,21 @@ function PanelTopBar (props) {
           title={i18n('addMoreFiles')}
           onClick={() => toggleAddFilesPanel(true)}
         >
-          <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="15" height="15" viewBox="0 0 15 15">
+          <svg
+            aria-hidden="true"
+            focusable="false"
+            className="uppy-c-icon"
+            width="15"
+            height="15"
+            viewBox="0 0 15 15"
+          >
             <path d="M8 6.5h6a.5.5 0 0 1 .5.5v.5a.5.5 0 0 1-.5.5H8v6a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V8h-6a.5.5 0 0 1-.5-.5V7a.5.5 0 0 1 .5-.5h6v-6A.5.5 0 0 1 7 0h.5a.5.5 0 0 1 .5.5v6z" />
           </svg>
-          <span className="uppy-DashboardContent-addMoreCaption">{i18n('addMore')}</span>
+          <span className="uppy-DashboardContent-addMoreCaption">
+            {i18n('addMore')}
+          </span>
         </button>
-      ) : (
-        <div />
-      )}
+      : <div />}
     </div>
   )
 }

+ 0 - 99
packages/@uppy/dashboard/src/components/Slide.jsx

@@ -1,99 +0,0 @@
-import {  cloneElement, Component, toChildArray  } from 'preact'
-import classNames from 'classnames'
-
-const transitionName = 'uppy-transition-slideDownUp'
-const duration = 250
-
-/**
- * Vertical slide transition.
- *
- * This can take a _single_ child component, which _must_ accept a `className` prop.
- *
- * Currently this is specific to the `uppy-transition-slideDownUp` transition,
- * but it should be simple to extend this for any type of single-element
- * transition by setting the CSS name and duration as props.
- */
-class Slide extends Component {
-  constructor (props) {
-    super(props)
-
-    this.state = {
-      cachedChildren: null,
-      className: '',
-    }
-  }
-
-  // TODO: refactor to stable lifecycle method
-  // eslint-disable-next-line
-  componentWillUpdate (nextProps) {
-    const { cachedChildren } = this.state
-    const child = toChildArray(nextProps.children)[0]
-
-    if (cachedChildren === child) return null
-
-    const patch = {
-      cachedChildren: child,
-    }
-
-    // Enter transition
-    if (child && !cachedChildren) {
-      patch.className = `${transitionName}-enter`
-
-      cancelAnimationFrame(this.animationFrame)
-      clearTimeout(this.leaveTimeout)
-      this.leaveTimeout = undefined
-
-      this.animationFrame = requestAnimationFrame(() => {
-        // Force it to render before we add the active class
-        // this.base.getBoundingClientRect()
-
-        this.setState({
-          className: `${transitionName}-enter ${transitionName}-enter-active`,
-        })
-
-        this.enterTimeout = setTimeout(() => {
-          this.setState({ className: '' })
-        }, duration)
-      })
-    }
-
-    // Leave transition
-    if (cachedChildren && !child && this.leaveTimeout === undefined) {
-      patch.cachedChildren = cachedChildren
-      patch.className = `${transitionName}-leave`
-
-      cancelAnimationFrame(this.animationFrame)
-      clearTimeout(this.enterTimeout)
-      this.enterTimeout = undefined
-      this.animationFrame = requestAnimationFrame(() => {
-        this.setState({
-          className: `${transitionName}-leave ${transitionName}-leave-active`,
-        })
-
-        this.leaveTimeout = setTimeout(() => {
-          this.setState({
-            cachedChildren: null,
-            className: '',
-          })
-        }, duration)
-      })
-    }
-
-    // eslint-disable-next-line
-    this.setState(patch)
-  }
-
-  render () {
-    const { cachedChildren, className } = this.state
-
-    if (!cachedChildren) {
-      return null
-    }
-
-    return cloneElement(cachedChildren, {
-      className: classNames(className, cachedChildren.props.className),
-    })
-  }
-}
-
-export default Slide

+ 96 - 0
packages/@uppy/dashboard/src/components/Slide.tsx

@@ -0,0 +1,96 @@
+import {
+  cloneElement,
+  toChildArray,
+  type VNode,
+  type ComponentChildren,
+} from 'preact'
+import { useEffect, useState, useRef } from 'preact/hooks'
+import classNames from 'classnames'
+
+const transitionName = 'uppy-transition-slideDownUp'
+const duration = 250
+
+/**
+ * Vertical slide transition.
+ *
+ * This can take a _single_ child component, which _must_ accept a `className` prop.
+ *
+ * Currently this is specific to the `uppy-transition-slideDownUp` transition,
+ * but it should be simple to extend this for any type of single-element
+ * transition by setting the CSS name and duration as props.
+ */
+function Slide({
+  children,
+}: {
+  children: ComponentChildren
+}): JSX.Element | null {
+  const [cachedChildren, setCachedChildren] = useState<VNode<{
+    className?: string
+  }> | null>(null)
+  const [className, setClassName] = useState('')
+  const enterTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
+  const leaveTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
+  const animationFrameRef = useRef<ReturnType<typeof requestAnimationFrame>>()
+
+  const handleEnterTransition = () => {
+    setClassName(`${transitionName}-enter`)
+
+    cancelAnimationFrame(animationFrameRef.current!)
+    clearTimeout(leaveTimeoutRef.current)
+    leaveTimeoutRef.current = undefined
+
+    animationFrameRef.current = requestAnimationFrame(() => {
+      setClassName(`${transitionName}-enter ${transitionName}-enter-active`)
+
+      enterTimeoutRef.current = setTimeout(() => {
+        setClassName('')
+      }, duration)
+    })
+  }
+
+  const handleLeaveTransition = () => {
+    setClassName(`${transitionName}-leave`)
+
+    cancelAnimationFrame(animationFrameRef.current!)
+    clearTimeout(enterTimeoutRef.current)
+    enterTimeoutRef.current = undefined
+
+    animationFrameRef.current = requestAnimationFrame(() => {
+      setClassName(`${transitionName}-leave ${transitionName}-leave-active`)
+
+      leaveTimeoutRef.current = setTimeout(() => {
+        setCachedChildren(null)
+        setClassName('')
+      }, duration)
+    })
+  }
+
+  useEffect(() => {
+    const child = toChildArray(children)[0] as VNode
+    if (cachedChildren === child) return
+
+    if (child && !cachedChildren) {
+      handleEnterTransition()
+    } else if (cachedChildren && !child && !leaveTimeoutRef.current) {
+      handleLeaveTransition()
+    }
+
+    setCachedChildren(child)
+  }, [children, cachedChildren]) // Dependency array to trigger effect on children change
+
+  useEffect(() => {
+    return () => {
+      clearTimeout(enterTimeoutRef.current)
+      clearTimeout(leaveTimeoutRef.current)
+      cancelAnimationFrame(animationFrameRef.current!)
+    }
+  }, []) // Cleanup useEffect
+
+  if (!cachedChildren) return null
+
+  return cloneElement(cachedChildren, {
+    className: classNames(className, cachedChildren.props.className),
+  })
+}
+
+export default Slide

+ 0 - 1
packages/@uppy/dashboard/src/index.js

@@ -1 +0,0 @@
-export { default } from './Dashboard.jsx'

+ 31 - 16
packages/@uppy/dashboard/src/index.test.js → packages/@uppy/dashboard/src/index.test.ts

@@ -1,19 +1,29 @@
 import { afterAll, beforeAll, describe, it, expect } from 'vitest'
 
-import Core from '@uppy/core'
+import Core, { UIPlugin } from '@uppy/core'
 import StatusBarPlugin from '@uppy/status-bar'
 import GoogleDrivePlugin from '@uppy/google-drive'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import WebcamPlugin from '@uppy/webcam'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import Url from '@uppy/url'
 
 import resizeObserverPolyfill from 'resize-observer-polyfill'
-import DashboardPlugin from '../lib/index.js'
+import DashboardPlugin from './index.ts'
+
+type $TSFixMe = any
 
 describe('Dashboard', () => {
   beforeAll(() => {
-    globalThis.ResizeObserver = resizeObserverPolyfill.default || resizeObserverPolyfill
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore we're touching globals for the test
+    globalThis.ResizeObserver =
+      (resizeObserverPolyfill as any).default || resizeObserverPolyfill
   })
   afterAll(() => {
+    // @ts-expect-error we're touching globals for the test
     delete globalThis.ResizeObserver
   })
 
@@ -48,7 +58,10 @@ describe('Dashboard', () => {
         inline: true,
         target: 'body',
       })
-      core.use(GoogleDrivePlugin, { target: DashboardPlugin, companionUrl: 'https://fake.uppy.io/' })
+      core.use(GoogleDrivePlugin, {
+        target: DashboardPlugin as $TSFixMe,
+        companionUrl: 'https://fake.uppy.io/',
+      })
     }).not.toThrow()
 
     core.close()
@@ -75,12 +88,15 @@ describe('Dashboard', () => {
     core.use(DashboardPlugin, { inline: false })
     core.use(WebcamPlugin)
 
-    const dashboardPlugins = core.getState().plugins['Dashboard'].targets
+    const dashboardPlugins = core.getState().plugins['Dashboard']!
+      .targets as UIPlugin<any, any, any>[]
 
     // two built-in plugins + these ones below
     expect(dashboardPlugins.length).toEqual(4)
     expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true)
-    expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(true)
+    expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(
+      true,
+    )
 
     core.close()
   })
@@ -92,12 +108,15 @@ describe('Dashboard', () => {
     core.use(DashboardPlugin, { inline: false })
     core.use(WebcamPlugin, { target: 'body' })
 
-    const dashboardPlugins = core.getState().plugins['Dashboard'].targets
+    const dashboardPlugins = core.getState().plugins['Dashboard']!
+      .targets as UIPlugin<any, any, any>[]
 
     // two built-in plugins + these ones below
     expect(dashboardPlugins.length).toEqual(3)
     expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true)
-    expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(false)
+    expect(dashboardPlugins.some((plugin) => plugin.id === 'Webcam')).toEqual(
+      false,
+    )
 
     core.close()
   })
@@ -109,13 +128,11 @@ describe('Dashboard', () => {
       target: 'body',
     })
 
-    core.getPlugin('Dashboard').setOptions({
+    core.getPlugin('Dashboard')!.setOptions({
       width: 300,
     })
 
-    expect(
-      core.getPlugin('Dashboard').opts.width,
-    ).toEqual(300)
+    expect(core.getPlugin('Dashboard')!.opts.width).toEqual(300)
   })
 
   it('should use updated locale from Core, when it’s set via Core’s setOptions()', () => {
@@ -133,16 +150,14 @@ describe('Dashboard', () => {
       },
     })
 
-    expect(
-      core.getPlugin('Dashboard').i18n('myDevice'),
-    ).toEqual('Май дивайс')
+    expect(core.getPlugin('Dashboard')!.i18n('myDevice')).toEqual('Май дивайс')
   })
 
   it('should accept a callback as `metaFields` option', () => {
     const core = new Core()
     expect(() => {
       core.use(DashboardPlugin, {
-        metaFields: (file) => {
+        metaFields: (file: any) => {
           const fields = [{ id: 'name', name: 'File name' }]
           if (file.type.startsWith('image/')) {
             fields.push({ id: 'location', name: 'Photo Location' })

+ 1 - 0
packages/@uppy/dashboard/src/index.ts

@@ -0,0 +1 @@
+export { default } from './Dashboard.tsx'

+ 0 - 0
packages/@uppy/dashboard/src/locale.js → packages/@uppy/dashboard/src/locale.ts


+ 1 - 1
packages/@uppy/dashboard/src/utils/copyToClipboard.test.js → packages/@uppy/dashboard/src/utils/copyToClipboard.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest'
-import copyToClipboard from './copyToClipboard.js'
+import copyToClipboard from './copyToClipboard.ts'
 
 describe('copyToClipboard', () => {
   it.skip('should copy the specified text to the clipboard', () => {

+ 10 - 4
packages/@uppy/dashboard/src/utils/copyToClipboard.js → packages/@uppy/dashboard/src/utils/copyToClipboard.ts

@@ -8,8 +8,14 @@
  * @param {string} fallbackString
  * @returns {Promise}
  */
-export default function copyToClipboard (textToCopy, fallbackString = 'Copy the URL below') {
-  return new Promise((resolve) => {
+
+type $TSFixMe = any
+
+export default function copyToClipboard(
+  textToCopy: $TSFixMe,
+  fallbackString = 'Copy the URL below',
+): $TSFixMe {
+  return new Promise<void>((resolve) => {
     const textArea = document.createElement('textarea')
     textArea.setAttribute('style', {
       position: 'fixed',
@@ -22,13 +28,13 @@ export default function copyToClipboard (textToCopy, fallbackString = 'Copy the
       outline: 'none',
       boxShadow: 'none',
       background: 'transparent',
-    })
+    } as $TSFixMe as string)
 
     textArea.value = textToCopy
     document.body.appendChild(textArea)
     textArea.select()
 
-    const magicCopyFailed = () => {
+    const magicCopyFailed = (cause?: unknown) => {
       document.body.removeChild(textArea)
       // eslint-disable-next-line no-alert
       window.prompt(fallbackString, textToCopy)

+ 1 - 1
packages/@uppy/dashboard/src/utils/createSuperFocus.test.js → packages/@uppy/dashboard/src/utils/createSuperFocus.test.ts

@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest'
-import createSuperFocus from './createSuperFocus.js'
+import createSuperFocus from './createSuperFocus.ts'
 
 describe('createSuperFocus', () => {
   // superFocus.cancel() is used in dashboard

+ 10 - 4
packages/@uppy/dashboard/src/utils/createSuperFocus.js → packages/@uppy/dashboard/src/utils/createSuperFocus.ts

@@ -1,6 +1,10 @@
 import debounce from 'lodash/debounce.js'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS'
-import getActiveOverlayEl from './getActiveOverlayEl.js'
+import getActiveOverlayEl from './getActiveOverlayEl.ts'
+
+type $TSFixMe = any
 
 /*
   Focuses on some element in the currently topmost overlay.
@@ -12,10 +16,10 @@ import getActiveOverlayEl from './getActiveOverlayEl.js'
   2. If there are no [data-uppy-super-focusable] elements yet (or ever) - focuses
      on the first focusable element, but switches focus if superfocusable elements appear on next render.
 */
-export default function createSuperFocus () {
+export default function createSuperFocus(): $TSFixMe {
   let lastFocusWasOnSuperFocusableEl = false
 
-  const superFocus = (dashboardEl, activeOverlayType) => {
+  const superFocus = (dashboardEl: $TSFixMe, activeOverlayType: $TSFixMe) => {
     const overlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
 
     const isFocusInOverlay = overlayEl.contains(document.activeElement)
@@ -24,7 +28,9 @@ export default function createSuperFocus () {
     // [Practical check] without this line, typing in the search input in googledrive overlay won't work.
     if (isFocusInOverlay && lastFocusWasOnSuperFocusableEl) return
 
-    const superFocusableEl = overlayEl.querySelector('[data-uppy-super-focusable]')
+    const superFocusableEl = overlayEl.querySelector(
+      '[data-uppy-super-focusable]',
+    )
     // If we are already in the topmost overlay, AND there are no super focusable elements yet, - leave focus up to the user.
     // [Practical check] without this line, if you are in an empty folder in google drive, and something's uploading in the
     // bg, - focus will be jumping to Done all the time.

+ 0 - 11
packages/@uppy/dashboard/src/utils/getActiveOverlayEl.js

@@ -1,11 +0,0 @@
-/**
- * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top
- */
-export default function getActiveOverlayEl (dashboardEl, activeOverlayType) {
-  if (activeOverlayType) {
-    const overlayEl = dashboardEl.querySelector(`[data-uppy-paneltype="${activeOverlayType}"]`)
-    // if an overlay is already mounted
-    if (overlayEl) return overlayEl
-  }
-  return dashboardEl
-}

+ 18 - 0
packages/@uppy/dashboard/src/utils/getActiveOverlayEl.ts

@@ -0,0 +1,18 @@
+type $TSFixMe = any
+
+/**
+ * @returns {HTMLElement} - either dashboard element, or the overlay that's most on top
+ */
+export default function getActiveOverlayEl(
+  dashboardEl: $TSFixMe,
+  activeOverlayType: $TSFixMe,
+): $TSFixMe {
+  if (activeOverlayType) {
+    const overlayEl = dashboardEl.querySelector(
+      `[data-uppy-paneltype="${activeOverlayType}"]`,
+    )
+    // if an overlay is already mounted
+    if (overlayEl) return overlayEl
+  }
+  return dashboardEl
+}

+ 0 - 127
packages/@uppy/dashboard/src/utils/getFileTypeIcon.jsx

@@ -1,127 +0,0 @@
-import { h } from 'preact'
-
-function iconImage () {
-  return (
-    <svg aria-hidden="true" focusable="false" width="25" height="25" viewBox="0 0 25 25">
-      <g fill="#686DE0" fillRule="evenodd">
-        <path d="M5 7v10h15V7H5zm0-1h15a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z" fillRule="nonzero" />
-        <path d="M6.35 17.172l4.994-5.026a.5.5 0 0 1 .707 0l2.16 2.16 3.505-3.505a.5.5 0 0 1 .707 0l2.336 2.31-.707.72-1.983-1.97-3.505 3.505a.5.5 0 0 1-.707 0l-2.16-2.159-3.938 3.939-1.409.026z" fillRule="nonzero" />
-        <circle cx="7.5" cy="9.5" r="1.5" />
-      </g>
-    </svg>
-  )
-}
-
-function iconAudio () {
-  return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="25" height="25" viewBox="0 0 25 25">
-      <path d="M9.5 18.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V7.25a.5.5 0 0 1 .379-.485l9-2.25A.5.5 0 0 1 18.5 5v11.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V8.67l-8 2v7.97zm8-11v-2l-8 2v2l8-2zM7 19.64c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1zm9-2c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1z" fill="#049BCF" fillRule="nonzero" />
-    </svg>
-  )
-}
-
-function iconVideo () {
-  return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="25" height="25" viewBox="0 0 25 25">
-      <path d="M16 11.834l4.486-2.691A1 1 0 0 1 22 10v6a1 1 0 0 1-1.514.857L16 14.167V17a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2.834zM15 9H5v8h10V9zm1 4l5 3v-6l-5 3z" fill="#19AF67" fillRule="nonzero" />
-    </svg>
-  )
-}
-
-function iconPDF () {
-  return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="25" height="25" viewBox="0 0 25 25">
-      <path d="M9.766 8.295c-.691-1.843-.539-3.401.747-3.726 1.643-.414 2.505.938 2.39 3.299-.039.79-.194 1.662-.537 3.148.324.49.66.967 1.055 1.51.17.231.382.488.629.757 1.866-.128 3.653.114 4.918.655 1.487.635 2.192 1.685 1.614 2.84-.566 1.133-1.839 1.084-3.416.249-1.141-.604-2.457-1.634-3.51-2.707a13.467 13.467 0 0 0-2.238.426c-1.392 4.051-4.534 6.453-5.707 4.572-.986-1.58 1.38-4.206 4.914-5.375.097-.322.185-.656.264-1.001.08-.353.306-1.31.407-1.737-.678-1.059-1.2-2.031-1.53-2.91zm2.098 4.87c-.033.144-.068.287-.104.427l.033-.01-.012.038a14.065 14.065 0 0 1 1.02-.197l-.032-.033.052-.004a7.902 7.902 0 0 1-.208-.271c-.197-.27-.38-.526-.555-.775l-.006.028-.002-.003c-.076.323-.148.632-.186.8zm5.77 2.978c1.143.605 1.832.632 2.054.187.26-.519-.087-1.034-1.113-1.473-.911-.39-2.175-.608-3.55-.608.845.766 1.787 1.459 2.609 1.894zM6.559 18.789c.14.223.693.16 1.425-.413.827-.648 1.61-1.747 2.208-3.206-2.563 1.064-4.102 2.867-3.633 3.62zm5.345-10.97c.088-1.793-.351-2.48-1.146-2.28-.473.119-.564 1.05-.056 2.405.213.566.52 1.188.908 1.859.18-.858.268-1.453.294-1.984z" fill="#E2514A" fillRule="nonzero" />
-    </svg>
-  )
-}
-
-function iconArchive () {
-  return (
-    <svg aria-hidden="true" focusable="false" width="25" height="25" viewBox="0 0 25 25">
-      <path d="M10.45 2.05h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V2.55a.5.5 0 0 1 .5-.5zm2.05 1.024h1.05a.5.5 0 0 1 .5.5V3.6a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5v-.001zM10.45 0h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V.5a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 3.074h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 1.024h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm-2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-1.656 3.074l-.82 5.946c.52.302 1.174.458 1.976.458.803 0 1.455-.156 1.975-.458l-.82-5.946h-2.311zm0-1.025h2.312c.512 0 .946.378 1.015.885l.82 5.946c.056.412-.142.817-.501 1.026-.686.398-1.515.597-2.49.597-.974 0-1.804-.199-2.49-.597a1.025 1.025 0 0 1-.5-1.026l.819-5.946c.07-.507.503-.885 1.015-.885zm.545 6.6a.5.5 0 0 1-.397-.561l.143-.999a.5.5 0 0 1 .495-.429h.74a.5.5 0 0 1 .495.43l.143.998a.5.5 0 0 1-.397.561c-.404.08-.819.08-1.222 0z" fill="#00C469" fillRule="nonzero" />
-    </svg>
-  )
-}
-
-function iconFile () {
-  return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="25" height="25" viewBox="0 0 25 25">
-      <g fill="#A7AFB7" fillRule="nonzero">
-        <path d="M5.5 22a.5.5 0 0 1-.5-.5v-18a.5.5 0 0 1 .5-.5h10.719a.5.5 0 0 1 .367.16l3.281 3.556a.5.5 0 0 1 .133.339V21.5a.5.5 0 0 1-.5.5h-14zm.5-1h13V7.25L16 4H6v17z" />
-        <path d="M15 4v3a1 1 0 0 0 1 1h3V7h-3V4h-1z" />
-      </g>
-    </svg>
-  )
-}
-
-function iconText () {
-  return (
-    <svg aria-hidden="true" focusable="false" className="uppy-c-icon" width="25" height="25" viewBox="0 0 25 25">
-      <path d="M4.5 7h13a.5.5 0 1 1 0 1h-13a.5.5 0 0 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h10a.5.5 0 1 1 0 1h-10a.5.5 0 1 1 0-1z" fill="#5A5E69" fillRule="nonzero" />
-    </svg>
-  )
-}
-
-export default function getIconByMime (fileType) {
-  const defaultChoice = {
-    color: '#838999',
-    icon: iconFile(),
-  }
-
-  if (!fileType) return defaultChoice
-
-  const fileTypeGeneral = fileType.split('/')[0]
-  const fileTypeSpecific = fileType.split('/')[1]
-
-  // Text
-  if (fileTypeGeneral === 'text') {
-    return {
-      color: '#5a5e69',
-      icon: iconText(),
-    }
-  }
-
-  // Image
-  if (fileTypeGeneral === 'image') {
-    return {
-      color: '#686de0',
-      icon: iconImage(),
-    }
-  }
-
-  // Audio
-  if (fileTypeGeneral === 'audio') {
-    return {
-      color: '#068dbb',
-      icon: iconAudio(),
-    }
-  }
-
-  // Video
-  if (fileTypeGeneral === 'video') {
-    return {
-      color: '#19af67',
-      icon: iconVideo(),
-    }
-  }
-
-  // PDF
-  if (fileTypeGeneral === 'application' && fileTypeSpecific === 'pdf') {
-    return {
-      color: '#e25149',
-      icon: iconPDF(),
-    }
-  }
-
-  // Archive
-  const archiveTypes = ['zip', 'x-7z-compressed', 'x-rar-compressed', 'x-tar', 'x-gzip', 'x-apple-diskimage']
-  if (fileTypeGeneral === 'application' && archiveTypes.indexOf(fileTypeSpecific) !== -1) {
-    return {
-      color: '#00C469',
-      icon: iconArchive(),
-    }
-  }
-
-  return defaultChoice
-}

+ 212 - 0
packages/@uppy/dashboard/src/utils/getFileTypeIcon.tsx

@@ -0,0 +1,212 @@
+import { h } from 'preact'
+
+type $TSFixMe = any
+
+function iconImage() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <g fill="#686DE0" fillRule="evenodd">
+        <path
+          d="M5 7v10h15V7H5zm0-1h15a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"
+          fillRule="nonzero"
+        />
+        <path
+          d="M6.35 17.172l4.994-5.026a.5.5 0 0 1 .707 0l2.16 2.16 3.505-3.505a.5.5 0 0 1 .707 0l2.336 2.31-.707.72-1.983-1.97-3.505 3.505a.5.5 0 0 1-.707 0l-2.16-2.159-3.938 3.939-1.409.026z"
+          fillRule="nonzero"
+        />
+        <circle cx="7.5" cy="9.5" r="1.5" />
+      </g>
+    </svg>
+  )
+}
+
+function iconAudio() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <path
+        d="M9.5 18.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V7.25a.5.5 0 0 1 .379-.485l9-2.25A.5.5 0 0 1 18.5 5v11.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V8.67l-8 2v7.97zm8-11v-2l-8 2v2l8-2zM7 19.64c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1zm9-2c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1z"
+        fill="#049BCF"
+        fillRule="nonzero"
+      />
+    </svg>
+  )
+}
+
+function iconVideo() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <path
+        d="M16 11.834l4.486-2.691A1 1 0 0 1 22 10v6a1 1 0 0 1-1.514.857L16 14.167V17a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2.834zM15 9H5v8h10V9zm1 4l5 3v-6l-5 3z"
+        fill="#19AF67"
+        fillRule="nonzero"
+      />
+    </svg>
+  )
+}
+
+function iconPDF() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <path
+        d="M9.766 8.295c-.691-1.843-.539-3.401.747-3.726 1.643-.414 2.505.938 2.39 3.299-.039.79-.194 1.662-.537 3.148.324.49.66.967 1.055 1.51.17.231.382.488.629.757 1.866-.128 3.653.114 4.918.655 1.487.635 2.192 1.685 1.614 2.84-.566 1.133-1.839 1.084-3.416.249-1.141-.604-2.457-1.634-3.51-2.707a13.467 13.467 0 0 0-2.238.426c-1.392 4.051-4.534 6.453-5.707 4.572-.986-1.58 1.38-4.206 4.914-5.375.097-.322.185-.656.264-1.001.08-.353.306-1.31.407-1.737-.678-1.059-1.2-2.031-1.53-2.91zm2.098 4.87c-.033.144-.068.287-.104.427l.033-.01-.012.038a14.065 14.065 0 0 1 1.02-.197l-.032-.033.052-.004a7.902 7.902 0 0 1-.208-.271c-.197-.27-.38-.526-.555-.775l-.006.028-.002-.003c-.076.323-.148.632-.186.8zm5.77 2.978c1.143.605 1.832.632 2.054.187.26-.519-.087-1.034-1.113-1.473-.911-.39-2.175-.608-3.55-.608.845.766 1.787 1.459 2.609 1.894zM6.559 18.789c.14.223.693.16 1.425-.413.827-.648 1.61-1.747 2.208-3.206-2.563 1.064-4.102 2.867-3.633 3.62zm5.345-10.97c.088-1.793-.351-2.48-1.146-2.28-.473.119-.564 1.05-.056 2.405.213.566.52 1.188.908 1.859.18-.858.268-1.453.294-1.984z"
+        fill="#E2514A"
+        fillRule="nonzero"
+      />
+    </svg>
+  )
+}
+
+function iconArchive() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <path
+        d="M10.45 2.05h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V2.55a.5.5 0 0 1 .5-.5zm2.05 1.024h1.05a.5.5 0 0 1 .5.5V3.6a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5v-.001zM10.45 0h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V.5a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 3.074h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 1.024h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm-2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-1.656 3.074l-.82 5.946c.52.302 1.174.458 1.976.458.803 0 1.455-.156 1.975-.458l-.82-5.946h-2.311zm0-1.025h2.312c.512 0 .946.378 1.015.885l.82 5.946c.056.412-.142.817-.501 1.026-.686.398-1.515.597-2.49.597-.974 0-1.804-.199-2.49-.597a1.025 1.025 0 0 1-.5-1.026l.819-5.946c.07-.507.503-.885 1.015-.885zm.545 6.6a.5.5 0 0 1-.397-.561l.143-.999a.5.5 0 0 1 .495-.429h.74a.5.5 0 0 1 .495.43l.143.998a.5.5 0 0 1-.397.561c-.404.08-.819.08-1.222 0z"
+        fill="#00C469"
+        fillRule="nonzero"
+      />
+    </svg>
+  )
+}
+
+function iconFile() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <g fill="#A7AFB7" fillRule="nonzero">
+        <path d="M5.5 22a.5.5 0 0 1-.5-.5v-18a.5.5 0 0 1 .5-.5h10.719a.5.5 0 0 1 .367.16l3.281 3.556a.5.5 0 0 1 .133.339V21.5a.5.5 0 0 1-.5.5h-14zm.5-1h13V7.25L16 4H6v17z" />
+        <path d="M15 4v3a1 1 0 0 0 1 1h3V7h-3V4h-1z" />
+      </g>
+    </svg>
+  )
+}
+
+function iconText() {
+  return (
+    <svg
+      aria-hidden="true"
+      focusable="false"
+      className="uppy-c-icon"
+      width="25"
+      height="25"
+      viewBox="0 0 25 25"
+    >
+      <path
+        d="M4.5 7h13a.5.5 0 1 1 0 1h-13a.5.5 0 0 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h10a.5.5 0 1 1 0 1h-10a.5.5 0 1 1 0-1z"
+        fill="#5A5E69"
+        fillRule="nonzero"
+      />
+    </svg>
+  )
+}
+
+export default function getIconByMime(fileType: $TSFixMe): $TSFixMe {
+  const defaultChoice = {
+    color: '#838999',
+    icon: iconFile(),
+  }
+
+  if (!fileType) return defaultChoice
+
+  const fileTypeGeneral = fileType.split('/')[0]
+  const fileTypeSpecific = fileType.split('/')[1]
+
+  // Text
+  if (fileTypeGeneral === 'text') {
+    return {
+      color: '#5a5e69',
+      icon: iconText(),
+    }
+  }
+
+  // Image
+  if (fileTypeGeneral === 'image') {
+    return {
+      color: '#686de0',
+      icon: iconImage(),
+    }
+  }
+
+  // Audio
+  if (fileTypeGeneral === 'audio') {
+    return {
+      color: '#068dbb',
+      icon: iconAudio(),
+    }
+  }
+
+  // Video
+  if (fileTypeGeneral === 'video') {
+    return {
+      color: '#19af67',
+      icon: iconVideo(),
+    }
+  }
+
+  // PDF
+  if (fileTypeGeneral === 'application' && fileTypeSpecific === 'pdf') {
+    return {
+      color: '#e25149',
+      icon: iconPDF(),
+    }
+  }
+
+  // Archive
+  const archiveTypes = [
+    'zip',
+    'x-7z-compressed',
+    'x-rar-compressed',
+    'x-tar',
+    'x-gzip',
+    'x-apple-diskimage',
+  ]
+  if (
+    fileTypeGeneral === 'application' &&
+    archiveTypes.indexOf(fileTypeSpecific) !== -1
+  ) {
+    return {
+      color: '#00C469',
+      icon: iconArchive(),
+    }
+  }
+
+  return defaultChoice
+}

+ 4 - 3
packages/@uppy/dashboard/src/utils/ignoreEvent.js → packages/@uppy/dashboard/src/utils/ignoreEvent.ts

@@ -3,10 +3,11 @@
 // draging UI elements or pasting anything into any field triggers those events —
 // Url treats them as URLs that need to be imported
 
-function ignoreEvent (ev) {
+type $TSFixMe = any
+
+function ignoreEvent(ev: $TSFixMe): void {
   const { tagName } = ev.target
-  if (tagName === 'INPUT'
-      || tagName === 'TEXTAREA') {
+  if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
     ev.stopPropagation()
     return
   }

+ 28 - 11
packages/@uppy/dashboard/src/utils/trapFocus.js → packages/@uppy/dashboard/src/utils/trapFocus.ts

@@ -1,8 +1,12 @@
 import toArray from '@uppy/utils/lib/toArray'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import FOCUSABLE_ELEMENTS from '@uppy/utils/lib/FOCUSABLE_ELEMENTS'
-import getActiveOverlayEl from './getActiveOverlayEl.js'
+import getActiveOverlayEl from './getActiveOverlayEl.ts'
 
-function focusOnFirstNode (event, nodes) {
+type $TSFixMe = any
+
+function focusOnFirstNode(event: $TSFixMe, nodes: $TSFixMe) {
   const node = nodes[0]
   if (node) {
     node.focus()
@@ -10,7 +14,7 @@ function focusOnFirstNode (event, nodes) {
   }
 }
 
-function focusOnLastNode (event, nodes) {
+function focusOnLastNode(event: $TSFixMe, nodes: $TSFixMe) {
   const node = nodes[nodes.length - 1]
   if (node) {
     node.focus()
@@ -24,13 +28,19 @@ function focusOnLastNode (event, nodes) {
 //    active overlay!
 //    [Practical check] if we use (focusedItemIndex === -1), instagram provider in firefox will never get focus on its pics
 //    in the <ul>.
-function isFocusInOverlay (activeOverlayEl) {
+function isFocusInOverlay(activeOverlayEl: $TSFixMe) {
   return activeOverlayEl.contains(document.activeElement)
 }
 
-function trapFocus (event, activeOverlayType, dashboardEl) {
+function trapFocus(
+  event: $TSFixMe,
+  activeOverlayType: $TSFixMe,
+  dashboardEl: $TSFixMe,
+): void {
   const activeOverlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
-  const focusableNodes = toArray(activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS))
+  const focusableNodes = toArray(
+    activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS),
+  )
 
   const focusedItemIndex = focusableNodes.indexOf(document.activeElement)
 
@@ -40,21 +50,28 @@ function trapFocus (event, activeOverlayType, dashboardEl) {
   // plugins will try to focus on some important element as it loads.
   if (!isFocusInOverlay(activeOverlayEl)) {
     focusOnFirstNode(event, focusableNodes)
-  // If we pressed shift + tab, and we're on the first element of a modal
+    // If we pressed shift + tab, and we're on the first element of a modal
   } else if (event.shiftKey && focusedItemIndex === 0) {
     focusOnLastNode(event, focusableNodes)
-  // If we pressed tab, and we're on the last element of the modal
-  } else if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) {
+    // If we pressed tab, and we're on the last element of the modal
+  } else if (
+    !event.shiftKey &&
+    focusedItemIndex === focusableNodes.length - 1
+  ) {
     focusOnFirstNode(event, focusableNodes)
   }
 }
 
 // Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram),
 // never lets focus disappear from the modal.
-export  { trapFocus as forModal }
+export { trapFocus as forModal }
 
 // Traps focus inside of the currently open overlay, unless overlay is null - then let the user tab away.
-export function forInline (event, activeOverlayType, dashboardEl) {
+export function forInline(
+  event: $TSFixMe,
+  activeOverlayType: $TSFixMe,
+  dashboardEl: $TSFixMe,
+): void {
   // ___When we're in the bare 'Drop files here, paste, browse or import from' screen
   if (activeOverlayType === null) {
     // Do nothing and let the browser handle it, user can tab away from Uppy to other elements on the page

+ 60 - 0
packages/@uppy/dashboard/tsconfig.build.json

@@ -0,0 +1,60 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "noImplicitAny": false,
+    "outDir": "./lib",
+    "paths": {
+      "@uppy/informer": ["../informer/src/index.js"],
+      "@uppy/informer/lib/*": ["../informer/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/status-bar": ["../status-bar/src/index.js"],
+      "@uppy/status-bar/lib/*": ["../status-bar/src/*"],
+      "@uppy/thumbnail-generator": ["../thumbnail-generator/src/index.js"],
+      "@uppy/thumbnail-generator/lib/*": ["../thumbnail-generator/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+      "@uppy/google-drive": ["../google-drive/src/index.js"],
+      "@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+      "@uppy/url": ["../url/src/index.js"],
+      "@uppy/url/lib/*": ["../url/src/*"],
+      "@uppy/webcam": ["../webcam/src/index.js"],
+      "@uppy/webcam/lib/*": ["../webcam/src/*"]
+    },
+    "resolveJsonModule": false,
+    "rootDir": "./src",
+    "skipLibCheck": true
+  },
+  "include": ["./src/**/*.*"],
+  "exclude": ["./src/**/*.test.ts"],
+  "references": [
+    {
+      "path": "../informer/tsconfig.build.json"
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json"
+    },
+    {
+      "path": "../status-bar/tsconfig.build.json"
+    },
+    {
+      "path": "../thumbnail-generator/tsconfig.build.json"
+    },
+    {
+      "path": "../utils/tsconfig.build.json"
+    },
+    {
+      "path": "../core/tsconfig.build.json"
+    },
+    {
+      "path": "../google-drive/tsconfig.build.json"
+    },
+    {
+      "path": "../url/tsconfig.build.json"
+    },
+    {
+      "path": "../webcam/tsconfig.build.json"
+    }
+  ]
+}

+ 56 - 0
packages/@uppy/dashboard/tsconfig.json

@@ -0,0 +1,56 @@
+{
+  "extends": "../../../tsconfig.shared",
+  "compilerOptions": {
+    "emitDeclarationOnly": false,
+    "noEmit": true,
+    "paths": {
+      "@uppy/informer": ["../informer/src/index.js"],
+      "@uppy/informer/lib/*": ["../informer/src/*"],
+      "@uppy/provider-views": ["../provider-views/src/index.js"],
+      "@uppy/provider-views/lib/*": ["../provider-views/src/*"],
+      "@uppy/status-bar": ["../status-bar/src/index.js"],
+      "@uppy/status-bar/lib/*": ["../status-bar/src/*"],
+      "@uppy/thumbnail-generator": ["../thumbnail-generator/src/index.js"],
+      "@uppy/thumbnail-generator/lib/*": ["../thumbnail-generator/src/*"],
+      "@uppy/utils/lib/*": ["../utils/src/*"],
+      "@uppy/core": ["../core/src/index.js"],
+      "@uppy/core/lib/*": ["../core/src/*"],
+      "@uppy/google-drive": ["../google-drive/src/index.js"],
+      "@uppy/google-drive/lib/*": ["../google-drive/src/*"],
+      "@uppy/url": ["../url/src/index.js"],
+      "@uppy/url/lib/*": ["../url/src/*"],
+      "@uppy/webcam": ["../webcam/src/index.js"],
+      "@uppy/webcam/lib/*": ["../webcam/src/*"],
+    },
+  },
+  "include": ["./package.json", "./src/**/*.*"],
+  "references": [
+    {
+      "path": "../informer/tsconfig.build.json",
+    },
+    {
+      "path": "../provider-views/tsconfig.build.json",
+    },
+    {
+      "path": "../status-bar/tsconfig.build.json",
+    },
+    {
+      "path": "../thumbnail-generator/tsconfig.build.json",
+    },
+    {
+      "path": "../utils/tsconfig.build.json",
+    },
+    {
+      "path": "../core/tsconfig.build.json",
+    },
+    {
+      "path": "../google-drive/tsconfig.build.json",
+    },
+    {
+      "path": "../url/tsconfig.build.json",
+    },
+    {
+      "path": "../webcam/tsconfig.build.json",
+    },
+  ],
+}

部分文件因文件數量過多而無法顯示