Browse Source

Merge branch 'main'

Antoine du Hamel 1 year ago
parent
commit
63b999dabd
52 changed files with 3933 additions and 2372 deletions
  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 { 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
 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 = {
 const defaultOptions = {
-  getChunkSize (file) {
+  getChunkSize(file: { size: number }) {
     return Math.ceil(file.size / 10000)
     return Math.ceil(file.size / 10000)
   },
   },
-  onProgress () {},
-  onPartComplete () {},
-  onSuccess () {},
-  onError (err) {
+  onProgress() {},
+  onPartComplete() {},
+  onSuccess() {},
+  onError(err: unknown) {
     throw err
     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') {
   if (typeof value === 'string') {
+    // @ts-expect-error TS is not able to recognize it's fine.
     return parseInt(value, 10)
     return parseInt(value, 10)
   }
   }
   if (typeof value === 'number') {
   if (typeof value === 'number') {
+    // @ts-expect-error TS is not able to recognize it's fine.
     return value
     return value
   }
   }
   throw new TypeError('Expected a number')
   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
  * (based on the user-provided `shouldUseMultipart` option value) and to manage
  * the chunk splitting.
  * 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()
   #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.
    * 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
   #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
   #maxMultipartParts = 10_000
 
 
   #minPartSize = 5 * MB
   #minPartSize = 5 * MB
 
 
-  constructor (data, options) {
+  constructor(data: Blob, options: MultipartUploaderOptions<M, B>) {
     this.options = {
     this.options = {
       ...defaultOptions,
       ...defaultOptions,
       ...options,
       ...options,
@@ -89,7 +112,7 @@ class MultipartUploader {
     // When we are restoring an upload, we already have an UploadId and a Key. Otherwise
     // 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`.
     // we need to call `createMultipartUpload` to get an `uploadId` and a `key`.
     // Non-multipart uploads are not restorable.
     // Non-multipart uploads are not restorable.
-    this.#isRestoring = options.uploadId && options.key
+    this.#isRestoring = (options.uploadId && options.key) as any as boolean
 
 
     this.#initChunks()
     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
   // 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.
   // 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.
   // 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 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)
       : Boolean(this.#shouldUseMultipart)
 
 
     if (shouldUseMultipart && fileSize > this.#minPartSize) {
     if (shouldUseMultipart && fileSize > this.#minPartSize) {
       // At least 5MB per request:
       // 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)
       let arraySize = Math.floor(fileSize / chunkSize)
 
 
       // At most 10k requests per file:
       // At most 10k requests per file:
@@ -132,41 +159,48 @@ class MultipartUploader {
           shouldUseMultipart,
           shouldUseMultipart,
         }
         }
         if (this.#isRestoring) {
         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
           // setAsUploaded is called by listPart, to keep up-to-date the
           // quantity of data that is left to actually upload.
           // quantity of data that is left to actually upload.
-          this.#chunks[j].setAsUploaded = () => {
+          this.#chunks[j]!.setAsUploaded = () => {
             this.#chunks[j] = null
             this.#chunks[j] = null
             this.#chunkState[j].uploaded = size
             this.#chunkState[j].uploaded = size
           }
           }
         }
         }
       }
       }
     } else {
     } 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 }))
     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)
       .then(this.#onSuccess, this.#onReject)
     this.#uploadHasStarted = true
     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)
       .then(this.#onSuccess, this.#onReject)
   }
   }
 
 
-  #onPartProgress = (index) => (ev) => {
+  #onPartProgress = (index: number) => (ev: ProgressEvent) => {
     if (!ev.lengthComputable) return
     if (!ev.lengthComputable) return
 
 
     this.#chunkState[index].uploaded = ensureInt(ev.loaded)
     this.#chunkState[index].uploaded = ensureInt(ev.loaded)
@@ -175,7 +209,7 @@ class MultipartUploader {
     this.options.onProgress(totalUploaded, this.#data.size)
     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 avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers.
     this.#chunks[index] = null
     this.#chunks[index] = null
     this.#chunkState[index].etag = etag
     this.#chunkState[index].etag = etag
@@ -188,37 +222,44 @@ class MultipartUploader {
     this.options.onPartComplete(part)
     this.options.onPartComplete(part)
   }
   }
 
 
-  #abortUpload () {
+  #abortUpload() {
     this.#abortController.abort()
     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.#uploadHasStarted) {
-      if (!this.#abortController.signal.aborted) this.#abortController.abort(pausingUploadReason)
+      if (!this.#abortController.signal.aborted)
+        this.#abortController.abort(pausingUploadReason)
       this.#abortController = new AbortController()
       this.#abortController = new AbortController()
       this.#resumeUpload()
       this.#resumeUpload()
     } else if (this.#isRestoring) {
     } 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()
       this.#resumeUpload()
     } else {
     } else {
       this.#createUpload()
       this.#createUpload()
     }
     }
   }
   }
 
 
-  pause () {
+  pause(): void {
     this.#abortController.abort(pausingUploadReason)
     this.#abortController.abort(pausingUploadReason)
     // Swap it out for a new controller, because this instance may be resumed later.
     // Swap it out for a new controller, because this instance may be resumed later.
     this.#abortController = new AbortController()
     this.#abortController = new AbortController()
   }
   }
 
 
-  abort (opts = undefined) {
+  abort(opts?: { really?: boolean }): void {
     if (opts?.really) this.#abortUpload()
     if (opts?.really) this.#abortUpload()
     else this.pause()
     else this.pause()
   }
   }
 
 
   // TODO: remove this in the next major
   // TODO: remove this in the next major
-  get chunkState () {
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  get chunkState() {
     return this.#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
  * @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
  * path component URL (everything between the host and the question mark
  * character (?) that starts the query string parameters). If the absolute path
  * character (?) that starts the query string parameters). If the absolute path
  * is empty, use a forward slash character (/).
  * 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,
  * parameters, separated by ampersands (&). Percent-encode reserved characters,
  * including the space character. Encode names and values separately. If there
  * including the space character. Encode names and values separately. If there
  * are empty parameters, append the equals sign to the parameter name before
  * are empty parameters, append the equals sign to the parameter name before
  * encoding. After encoding, sort the parameters alphabetically by key name. If
  * encoding. After encoding, sort the parameters alphabetically by key name. If
  * there is no query string, use an empty string ("").
  * 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.
  * that will be signed, and their values, separated by newline characters.
  * For the values, trim any leading or trailing spaces, convert sequential
  * 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
  * 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-*
  * 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
  * headers in the signature. You can optionally include other standard headers
  * in the signature, such as content-type.
  * 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
  * 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
  * lowercase hexadecimal characters. If the payload is empty, use an empty
  * string as the input to the hash function.
  * string as the input to the hash function.
- * @returns {string}
  */
  */
-function createCanonicalRequest ({
+function createCanonicalRequest({
   method = 'PUT',
   method = 'PUT',
   CanonicalUri = '/',
   CanonicalUri = '/',
   CanonicalQueryString = '',
   CanonicalQueryString = '',
   SignedHeaders,
   SignedHeaders,
   HashedPayload,
   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 [
   return [
     method,
     method,
     CanonicalUri,
     CanonicalUri,
     CanonicalQueryString,
     CanonicalQueryString,
-    ...headerKeys.map(k => `${k}:${SignedHeaders[k]}`),
+    ...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`),
     '',
     '',
     headerKeys.join(';'),
     headerKeys.join(';'),
     HashedPayload,
     HashedPayload,
@@ -52,17 +59,23 @@ function createCanonicalRequest ({
 const ec = new TextEncoder()
 const ec = new TextEncoder()
 const algorithm = { name: 'HMAC', hash: 'SHA-256' }
 const algorithm = { name: 'HMAC', hash: 'SHA-256' }
 
 
-async function digest (data) {
+async function digest(data: string): ReturnType<SubtleCrypto['digest']> {
   const { subtle } = globalThis.crypto
   const { subtle } = globalThis.crypto
   return subtle.digest(algorithm.hash, ec.encode(data))
   return subtle.digest(algorithm.hash, ec.encode(data))
 }
 }
 
 
-async function generateHmacKey (secret) {
+async function generateHmacKey(secret: string | Uint8Array | ArrayBuffer) {
   const { subtle } = globalThis.crypto
   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)
   const byteArray = new Uint8Array(arrayBuffer)
   let hexString = ''
   let hexString = ''
   for (let i = 0; i < byteArray.length; i++) {
   for (let i = 0; i < byteArray.length; i++) {
@@ -71,27 +84,35 @@ function arrayBufferToHexString (arrayBuffer) {
   return hexString
   return hexString
 }
 }
 
 
-async function hash (key, data) {
+async function hash(key: Parameters<typeof generateHmacKey>[0], data: string) {
   const { subtle } = globalThis.crypto
   const { subtle } = globalThis.crypto
   return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data))
   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
  * @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,
   bucketName,
-  Key, Region,
+  Key,
+  Region,
   expires,
   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 Service = 's3'
   const host = `${bucketName}.${Service}.${Region}.amazonaws.com`
   const host = `${bucketName}.${Service}.${Region}.amazonaws.com`
   /**
   /**
@@ -100,7 +121,7 @@ export default async function createSignedURL ({
    *
    *
    * @see https://tc39.es/ecma262/#sec-encodeuri-uri
    * @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 payload = 'UNSIGNED-PAYLOAD'
 
 
   const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ
   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-Content-Sha256', payload)
   url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`)
   url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`)
   url.searchParams.set('X-Amz-Date', requestDateTime)
   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:
   // 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-Security-Token', sessionToken)
   url.searchParams.set('X-Amz-SignedHeaders', 'host')
   url.searchParams.set('X-Amz-SignedHeaders', 'host')
   // Those two are present only for Multipart Uploads:
   // 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)
   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
   // Step 1: Create a canonical request
   const canonical = createCanonicalRequest({
   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 'whatwg-fetch'
 import nock from 'nock'
 import nock from 'nock'
 import Core from '@uppy/core'
 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 KB = 1024
 const MB = KB * KB
 const MB = KB * KB
@@ -12,36 +13,41 @@ describe('AwsS3Multipart', () => {
   beforeEach(() => nock.disableNetConnect())
   beforeEach(() => nock.disableNetConnect())
 
 
   it('Registers AwsS3Multipart upload plugin', () => {
   it('Registers AwsS3Multipart upload plugin', () => {
-    const core = new Core()
+    const core = new Core<any, Body>()
     core.use(AwsS3Multipart)
     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')
     expect(pluginNames).toContain('AwsS3Multipart')
   })
   })
 
 
   describe('companionUrl assertion', () => {
   describe('companionUrl assertion', () => {
     it('Throws an error for main functions if configured without companionUrl', () => {
     it('Throws an error for main functions if configured without companionUrl', () => {
-      const core = new Core()
+      const core = new Core<any, Body>()
       core.use(AwsS3Multipart)
       core.use(AwsS3Multipart)
-      const awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
 
 
       const err = 'Expected a `companionUrl` option'
       const err = 'Expected a `companionUrl` option'
       const file = {}
       const file = {}
       const opts = {}
       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.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)
       expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err)
     })
     })
   })
   })
 
 
   describe('non-multipart upload', () => {
   describe('non-multipart upload', () => {
     it('should handle POST uploads', async () => {
     it('should handle POST uploads', async () => {
-      const core = new Core()
+      const core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
       core.use(AwsS3Multipart, {
         shouldUseMultipart: false,
         shouldUseMultipart: false,
         limit: 0,
         limit: 0,
@@ -71,7 +77,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -87,6 +93,7 @@ describe('AwsS3Multipart', () => {
           ETag: 'test',
           ETag: 'test',
           location: 'http://example.com',
           location: 'http://example.com',
         },
         },
+        status: 200,
         uploadURL: 'http://example.com',
         uploadURL: 'http://example.com',
       })
       })
 
 
@@ -95,11 +102,11 @@ describe('AwsS3Multipart', () => {
   })
   })
 
 
   describe('without companionUrl (custom main functions)', () => {
   describe('without companionUrl (custom main functions)', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, Body>
+    let awsS3Multipart: AwsS3Multipart<any, Body>
 
 
     beforeEach(() => {
     beforeEach(() => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
       core.use(AwsS3Multipart, {
         limit: 0,
         limit: 0,
         createMultipartUpload: vi.fn(() => {
         createMultipartUpload: vi.fn(() => {
@@ -110,17 +117,19 @@ describe('AwsS3Multipart', () => {
         }),
         }),
         completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
         completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
         abortMultipartUpload: vi.fn(),
         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 () => {
     it('Calls the prepareUploadParts function totalChunks / limit times', async () => {
@@ -137,15 +146,23 @@ describe('AwsS3Multipart', () => {
       const fileSize = 5 * MB + 1 * MB
       const fileSize = 5 * MB + 1 * MB
 
 
       scope
       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, '']
           return [200, '']
         })
         })
       scope
       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, '']
           return [200, '']
         })
         })
       scope
       scope
@@ -159,7 +176,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -167,7 +184,7 @@ describe('AwsS3Multipart', () => {
       await core.upload()
       await core.upload()
 
 
       expect(
       expect(
-        awsS3Multipart.opts.prepareUploadParts.mock.calls.length,
+        (awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
       ).toEqual(2)
       ).toEqual(2)
 
 
       scope.done()
       scope.done()
@@ -201,14 +218,17 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
 
 
       await core.upload()
       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)
         expect(parts.map((part) => part.number)).toEqual(expected)
 
 
         for (const part of parts) {
         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([
       expect(completeCall.parts).toEqual([
         { ETag: 'test', PartNumber: 1 },
         { ETag: 'test', PartNumber: 1 },
@@ -254,13 +286,21 @@ describe('AwsS3Multipart', () => {
         .options((uri) => uri.includes('test/upload/multitest.dat'))
         .options((uri) => uri.includes('test/upload/multitest.dat'))
         .reply(200, '')
         .reply(200, '')
       scope
       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' })
         .reply(200, '', { ETag: 'test' })
 
 
       // Fail the part 7 upload once, then let it succeed
       // Fail the part 7 upload once, then let it succeed
       let calls = 0
       let calls = 0
       scope
       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' }]))
         .reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }]))
 
 
       scope.persist()
       scope.persist()
@@ -271,14 +311,25 @@ describe('AwsS3Multipart', () => {
       awsS3Multipart.setOptions({
       awsS3Multipart.setOptions({
         retryDelays: [10],
         retryDelays: [10],
         createMultipartUpload: vi.fn((file) => {
         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]
           const testChunkState = multipartUploader.chunkState[6]
           let busy = false
           let busy = false
           let done = 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 {
           return {
             uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
             uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
@@ -291,7 +342,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -299,19 +350,23 @@ describe('AwsS3Multipart', () => {
       await core.upload()
       await core.upload()
 
 
       // The chunk should be marked as done once
       // 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,
       // 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)
       // 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) {
         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()
     const uploadPartBytes = vi.fn()
 
 
-    afterEach(() => vi.clearAllMocks())
+    afterEach(() => {
+      vi.clearAllMocks()
+    })
 
 
     it('retries uploadPartBytes when it fails once', async () => {
     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
           // 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
       const fileSize = 5 * MB + 1 * MB
 
 
       core.addFile({
       core.addFile({
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -363,18 +423,19 @@ describe('AwsS3Multipart', () => {
     })
     })
 
 
     it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
     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 fileSize = 5 * MB + 1 * MB
       const mock = vi.fn()
       const mock = vi.fn()
       core.on('upload-error', mock)
       core.on('upload-error', mock)
@@ -383,7 +444,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -396,19 +457,20 @@ describe('AwsS3Multipart', () => {
   })
   })
 
 
   describe('dynamic companionHeader', () => {
   describe('dynamic companionHeader', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, any>
+    let awsS3Multipart: AwsS3Multipart<any, any>
     const oldToken = 'old token'
     const oldToken = 'old token'
     const newToken = 'new token'
     const newToken = 'new token'
 
 
     beforeEach(() => {
     beforeEach(() => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart, {
       core.use(AwsS3Multipart, {
+        companionUrl: '',
         companionHeaders: {
         companionHeaders: {
           authorization: oldToken,
           authorization: oldToken,
         },
         },
       })
       })
-      awsS3Multipart = core.getPlugin('AwsS3Multipart')
+      awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
     })
     })
 
 
     it('companionHeader is updated before uploading file', async () => {
     it('companionHeader is updated before uploading file', async () => {
@@ -420,23 +482,29 @@ describe('AwsS3Multipart', () => {
 
 
       await core.upload()
       await core.upload()
 
 
+      // @ts-expect-error private property
       const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
       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', () => {
   describe('dynamic companionHeader using setOption', () => {
-    let core
-    let awsS3Multipart
+    let core: Core<any, Body>
+    let awsS3Multipart: AwsS3Multipart<any, Body>
     const newToken = 'new token'
     const newToken = 'new token'
 
 
     it('companionHeader is updated before uploading file', async () => {
     it('companionHeader is updated before uploading file', async () => {
-      core = new Core()
+      core = new Core<any, Body>()
       core.use(AwsS3Multipart)
       core.use(AwsS3Multipart)
       /* Set up preprocessor */
       /* Set up preprocessor */
       core.addPreProcessor(() => {
       core.addPreProcessor(() => {
-        awsS3Multipart = core.getPlugin('AwsS3Multipart')
+        awsS3Multipart = core.getPlugin('AwsS3Multipart') as AwsS3Multipart<
+          any,
+          Body
+        >
         awsS3Multipart.setOptions({
         awsS3Multipart.setOptions({
           companionHeaders: {
           companionHeaders: {
             authorization: newToken,
             authorization: newToken,
@@ -446,15 +514,18 @@ describe('AwsS3Multipart', () => {
 
 
       await core.upload()
       await core.upload()
 
 
+      // @ts-expect-error private property
       const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
       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', () => {
   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, {
       core.setFileMeta(file.id, {
         ...file.meta,
         ...file.meta,
         createMultipartUpload: true,
         createMultipartUpload: true,
@@ -487,8 +558,10 @@ describe('AwsS3Multipart', () => {
         listParts: true,
         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],
         PartNumber: file.meta[metaKey],
         ETag: metaKey,
         ETag: metaKey,
         Size: 5 * MB,
         Size: 5 * MB,
@@ -508,7 +581,6 @@ describe('AwsS3Multipart', () => {
       expect(file.meta.createMultipartUpload).toBe(true)
       expect(file.meta.createMultipartUpload).toBe(true)
       expect(file.meta.signPart).toBe(true)
       expect(file.meta.signPart).toBe(true)
       expect(file.meta.abortingPart).toBe(5)
       expect(file.meta.abortingPart).toBe(5)
-      return {}
     })
     })
 
 
     beforeEach(() => {
     beforeEach(() => {
@@ -520,14 +592,13 @@ describe('AwsS3Multipart', () => {
     })
     })
 
 
     it('preserves file metadata if upload is completed', async () => {
     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')
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
         .defaultReplyHeaders({
@@ -545,7 +616,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           type: 'application/octet-stream',
         }),
         }),
       })
       })
@@ -565,7 +636,9 @@ describe('AwsS3Multipart', () => {
             abortingPart: partData.partNumber,
             abortingPart: partData.partNumber,
           })
           })
           core.removeFile(file.id)
           core.removeFile(file.id)
-          return {}
+          return {
+            url: undefined as any as string,
+          }
         }
         }
 
 
         core.setFileMeta(file.id, {
         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')
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
         .defaultReplyHeaders({
@@ -604,7 +676,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           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')
       nock('https://bucket.s3.us-east-2.amazonaws.com')
         .defaultReplyHeaders({
         .defaultReplyHeaders({
@@ -674,7 +745,7 @@ describe('AwsS3Multipart', () => {
         source: 'vi',
         source: 'vi',
         name: 'multitest.dat',
         name: 'multitest.dat',
         type: 'application/octet-stream',
         type: 'application/octet-stream',
-        data: new File([new Uint8Array(fileSize)], {
+        data: new File([new Uint8Array(fileSize)], '', {
           type: 'application/octet-stream',
           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) {
   if (server && server.path) {
     // see https://github.com/transloadit/uppy/issues/4271
     // see https://github.com/transloadit/uppy/issues/4271
     // todo fix the code so we can allow `/`
     // 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) {
   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 BasePlugin from './BasePlugin.ts'
 import type { Restrictions, ValidateableFile } from './Restricter.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'
 type FileRemoveReason = 'user' | 'cancel-all'
 
 

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

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

File diff suppressed because it is too large
+ 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 { h } from 'preact'
 import classNames from 'classnames'
 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 (
   return (
     <div
     <div
       className={classNames('uppy-Dashboard-AddFilesPanel', props.className)}
       className={classNames('uppy-Dashboard-AddFilesPanel', props.className)}
@@ -10,7 +13,11 @@ const AddFilesPanel = (props) => {
       aria-hidden={!props.showAddFilesPanel}
       aria-hidden={!props.showAddFilesPanel}
     >
     >
       <div className="uppy-DashboardContent-bar">
       <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')}
           {props.i18n('addingMoreFiles')}
         </div>
         </div>
         <button
         <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 { h } from 'preact'
 import classNames from 'classnames'
 import classNames from 'classnames'
 import isDragDropSupported from '@uppy/utils/lib/isDragDropSupported'
 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
 // http://dev.edenspiekermann.com/2016/02/11/introducing-accessible-modal-dialog
 // https://github.com/ghosh/micromodal
 // https://github.com/ghosh/micromodal
@@ -22,7 +23,9 @@ const HEIGHT_MD = 330
 // const HEIGHT_LG = 400
 // const HEIGHT_LG = 400
 // const HEIGHT_XL = 460
 // 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 isNoFiles = props.totalFileCount === 0
   const isSingleFile = props.totalFileCount === 1
   const isSingleFile = props.totalFileCount === 1
   const isSizeMD = props.containerWidth > WIDTH_MD
   const isSizeMD = props.containerWidth > WIDTH_MD
@@ -45,7 +48,8 @@ export default function Dashboard (props) {
     'uppy-Dashboard--isAddFilesPanelVisible': props.showAddFilesPanel,
     'uppy-Dashboard--isAddFilesPanelVisible': props.showAddFilesPanel,
     'uppy-Dashboard--isInnerWrapVisible': props.areInsidesReadyToBeVisible,
     'uppy-Dashboard--isInnerWrapVisible': props.areInsidesReadyToBeVisible,
     // Only enable “centered single file” mode when Dashboard is tall enough
     // 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`.
   // 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 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 = () => {
   const renderRestoredText = () => {
-    if (numberOfGhosts > 0) {
+    if (numberOfGhosts! > 0) {
       return props.i18n('recoveredXFiles', {
       return props.i18n('recoveredXFiles', {
         smart_count: numberOfGhosts,
         smart_count: numberOfGhosts,
       })
       })
@@ -78,10 +87,16 @@ export default function Dashboard (props) {
       className={dashboardClassName}
       className={dashboardClassName}
       data-uppy-theme={props.theme}
       data-uppy-theme={props.theme}
       data-uppy-num-acquirers={props.acquirers.length}
       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-hidden={props.inline ? 'false' : props.isHidden}
       aria-disabled={props.disabled}
       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}
       onPaste={props.handlePaste}
       onDragOver={props.handleDragOver}
       onDragOver={props.handleDragOver}
       onDragLeave={props.handleDragLeave}
       onDragLeave={props.handleDragLeave}
@@ -97,14 +112,13 @@ export default function Dashboard (props) {
       <div
       <div
         className="uppy-Dashboard-inner"
         className="uppy-Dashboard-inner"
         aria-modal={!props.inline && 'true'}
         aria-modal={!props.inline && 'true'}
-        role={!props.inline && 'dialog'}
+        role={props.inline ? undefined : 'dialog'}
         style={{
         style={{
           width: props.inline && props.width ? props.width : '',
           width: props.inline && props.width ? props.width : '',
           height: props.inline && props.height ? props.height : '',
           height: props.inline && props.height ? props.height : '',
         }}
         }}
       >
       >
-
-        {!props.inline ? (
+        {!props.inline ?
           <button
           <button
             className="uppy-u-reset uppy-Dashboard-close"
             className="uppy-u-reset uppy-Dashboard-close"
             type="button"
             type="button"
@@ -114,7 +128,7 @@ export default function Dashboard (props) {
           >
           >
             <span aria-hidden="true">&times;</span>
             <span aria-hidden="true">&times;</span>
           </button>
           </button>
-        ) : null}
+        : null}
 
 
         <div className="uppy-Dashboard-innerWrap">
         <div className="uppy-Dashboard-innerWrap">
           <div className="uppy-Dashboard-dropFilesHereHint">
           <div className="uppy-Dashboard-dropFilesHereHint">
@@ -126,9 +140,19 @@ export default function Dashboard (props) {
 
 
           {numberOfFilesForRecovery && (
           {numberOfFilesForRecovery && (
             <div className="uppy-Dashboard-serviceMsg">
             <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">
                 <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" />
                   <path fill="#000" d="M11 6h2l-.3 8h-1.4z" />
                   <circle fill="#000" cx="12" cy="17" r="1" />
                   <circle fill="#000" cx="12" cy="17" r="1" />
                 </g>
                 </g>
@@ -142,60 +166,70 @@ export default function Dashboard (props) {
             </div>
             </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>
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
             {/* 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>
 
 
           <Slide>
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
             {/* 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>
 
 
           <Slide>
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
             {/* 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>
 
 
           <Slide>
           <Slide>
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
             {/* eslint-disable-next-line react/jsx-props-no-spreading */}
-            {props.showFileEditor ? <EditorPanel key="Editor" {...props} /> : null}
+            {props.showFileEditor ?
+              <EditorPanel key="Editor" {...props} />
+            : null}
           </Slide>
           </Slide>
 
 
           <div className="uppy-Dashboard-progressindicators">
           <div className="uppy-Dashboard-progressindicators">
-            {props.progressindicators.map((target) => {
+            {props.progressindicators.map((target: $TSFixMe) => {
               return props.uppy.getPlugin(target.id).render(props.state)
               return props.uppy.getPlugin(target.id).render(props.state)
             })}
             })}
           </div>
           </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 { h } from 'preact'
 import classNames from 'classnames'
 import classNames from 'classnames'
 
 
-function EditorPanel (props) {
+type $TSFixMe = any
+
+function EditorPanel(props: $TSFixMe): JSX.Element {
   const file = props.files[props.fileCardFor]
   const file = props.files[props.fileCardFor]
 
 
   const handleCancel = () => {
   const handleCancel = () => {
@@ -17,9 +20,17 @@ function EditorPanel (props) {
       id="uppy-DashboardContent-panel--editor"
       id="uppy-DashboardContent-panel--editor"
     >
     >
       <div className="uppy-DashboardContent-bar">
       <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', {
           {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>
         </div>
         <button
         <button
@@ -38,7 +49,7 @@ function EditorPanel (props) {
         </button>
         </button>
       </div>
       </div>
       <div className="uppy-DashboardContent-panelBody">
       <div className="uppy-DashboardContent-panelBody">
-        {props.editors.map((target) => {
+        {props.editors.map((target: $TSFixMe) => {
           return props.uppy.getPlugin(target.id).render(props.state)
           return props.uppy.getPlugin(target.id).render(props.state)
         })}
         })}
       </div>
       </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 { useEffect, useState, useCallback } from 'preact/hooks'
 import classNames from 'classnames'
 import classNames from 'classnames'
 import { nanoid } from 'nanoid/non-secure'
 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 {
   const {
     files,
     files,
     fileCardFor,
     fileCardFor,
@@ -23,8 +25,8 @@ export default function FileCard (props) {
   } = props
   } = props
 
 
   const getMetaFields = () => {
   const getMetaFields = () => {
-    return typeof metaFields === 'function'
-      ? metaFields(files[fileCardFor])
+    return typeof metaFields === 'function' ?
+        metaFields(files[fileCardFor])
       : metaFields
       : metaFields
   }
   }
 
 
@@ -32,19 +34,22 @@ export default function FileCard (props) {
   const computedMetaFields = getMetaFields() ?? []
   const computedMetaFields = getMetaFields() ?? []
   const showEditButton = canEditFile(file)
   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] ?? ''
     storedMetaData[field.id] = file.meta[field.id] ?? ''
   })
   })
 
 
   const [formState, setFormState] = useState(storedMetaData)
   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({
     setFormState({
       ...formState,
       ...formState,
       [name]: newVal,
       [name]: newVal,
@@ -81,9 +86,17 @@ export default function FileCard (props) {
       onPaste={ignoreEvent}
       onPaste={ignoreEvent}
     >
     >
       <div className="uppy-DashboardContent-bar">
       <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', {
           {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>
         </div>
         <button
         <button
@@ -98,14 +111,16 @@ export default function FileCard (props) {
       </div>
       </div>
 
 
       <div className="uppy-Dashboard-FileCard-inner">
       <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} />
           <FilePreview file={file} />
-          {showEditButton
-            && (
+          {showEditButton && (
             <button
             <button
               type="button"
               type="button"
               className="uppy-u-reset uppy-c-btn uppy-Dashboard-FileCard-edit"
               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.
                 // 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,
                 // Otherwise it's confusing for the user to click save in the editor,
                 // but the changes here are discarded. This bypasses validation,
                 // 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'). */}
               we can conditionally display i18n('editFile')/i18n('editImage'). */}
               {i18n('editImage')}
               {i18n('editImage')}
             </button>
             </button>
-            )}
+          )}
         </div>
         </div>
 
 
         <div className="uppy-Dashboard-FileCard-info">
         <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,
   file,
   uploadInProgressOrComplete,
   uploadInProgressOrComplete,
   metaFields,
   metaFields,
   canEditFile,
   canEditFile,
   i18n,
   i18n,
   onClick,
   onClick,
-}) {
+}: $TSFixMe) {
   if (
   if (
-    (!uploadInProgressOrComplete && metaFields && metaFields.length > 0)
-    || (!uploadInProgressOrComplete && canEditFile(file))
+    (!uploadInProgressOrComplete && metaFields && metaFields.length > 0) ||
+    (!uploadInProgressOrComplete && canEditFile(file))
   ) {
   ) {
     return (
     return (
       <button
       <button
@@ -21,11 +23,24 @@ function EditButton ({
         title={i18n('editFileWithFilename', { file: file.meta.name })}
         title={i18n('editFileWithFilename', { file: file.meta.name })}
         onClick={() => onClick()}
         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">
           <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" />
             <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>
           </g>
         </svg>
         </svg>
       </button>
       </button>
@@ -34,7 +49,7 @@ function EditButton ({
   return null
   return null
 }
 }
 
 
-function RemoveButton ({ i18n, onClick, file }) {
+function RemoveButton({ i18n, onClick, file }: $TSFixMe) {
   return (
   return (
     <button
     <button
       className="uppy-u-reset uppy-Dashboard-Item-action uppy-Dashboard-Item-action--remove"
       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 })}
       title={i18n('removeFile', { file: file.meta.name })}
       onClick={() => onClick()}
       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 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>
       </svg>
     </button>
     </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(() => {
     .then(() => {
       props.uppy.log('Link copied to clipboard.')
       props.uppy.log('Link copied to clipboard.')
       props.uppy.info(props.i18n('copyLinkToClipboardSuccess'), 'info', 3000)
       props.uppy.info(props.i18n('copyLinkToClipboardSuccess'), 'info', 3000)
@@ -62,7 +90,7 @@ const copyLinkToClipboard = (event, props) => {
     .then(() => event.target.focus({ preventScroll: true }))
     .then(() => event.target.focus({ preventScroll: true }))
 }
 }
 
 
-function CopyLinkButton (props) {
+function CopyLinkButton(props: $TSFixMe) {
   const { i18n } = props
   const { i18n } = props
 
 
   return (
   return (
@@ -73,14 +101,21 @@ function CopyLinkButton (props) {
       title={i18n('copyLink')}
       title={i18n('copyLink')}
       onClick={(event) => copyLinkToClipboard(event, props)}
       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" />
         <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>
       </svg>
     </button>
     </button>
   )
   )
 }
 }
 
 
-export default function Buttons (props) {
+export default function Buttons(props: $TSFixMe): ComponentChild {
   const {
   const {
     uppy,
     uppy,
     file,
     file,
@@ -112,21 +147,17 @@ export default function Buttons (props) {
         metaFields={metaFields}
         metaFields={metaFields}
         onClick={editAction}
         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
         <RemoveButton
           i18n={i18n}
           i18n={i18n}
           file={file}
           file={file}
           uppy={uppy}
           uppy={uppy}
-          onClick={() => props.uppy.removeFile(file.id, 'removed-by-user')}
+          onClick={() => uppy.removeFile(file.id, 'removed-by-user')}
         />
         />
-      ) : null}
+      : null}
     </div>
     </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 prettierBytes from '@transloadit/prettier-bytes'
 import truncateString from '@uppy/utils/lib/truncateString'
 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
   const { author, name } = props.file.meta
 
 
-  function getMaxNameLength () {
+  function getMaxNameLength() {
     if (props.isSingleFile && props.containerHeight >= 350) {
     if (props.isSingleFile && props.containerHeight >= 350) {
       return 90
       return 90
     }
     }
@@ -29,7 +32,7 @@ const renderFileName = (props) => {
   )
   )
 }
 }
 
 
-const renderAuthor = (props) => {
+const renderAuthor = (props: $TSFixMe) => {
   const { author } = props.file.meta
   const { author } = props.file.meta
   const providerName = props.file.remote?.providerName
   const providerName = props.file.remote?.providerName
   const dot = `\u00B7`
   const dot = `\u00B7`
@@ -47,37 +50,39 @@ const renderAuthor = (props) => {
       >
       >
         {truncateString(author.name, 13)}
         {truncateString(author.name, 13)}
       </a>
       </a>
-      {providerName ? (
+      {providerName ?
         <>
         <>
           {` ${dot} `}
           {` ${dot} `}
           {providerName}
           {providerName}
           {` ${dot} `}
           {` ${dot} `}
         </>
         </>
-      ) : null}
+      : null}
     </div>
     </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) {
   if (file.error) {
     return (
     return (
       <button
       <button
@@ -95,7 +100,7 @@ const ErrorButton = ({ file, onClick }) => {
   return null
   return null
 }
 }
 
 
-export default function FileInfo (props) {
+export default function FileInfo(props: $TSFixMe): ComponentChild {
   const { file } = props
   const { file } = props
   return (
   return (
     <div
     <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.isUploaded) return
 
 
   if (props.error && !props.hideRetryButton) {
   if (props.error && !props.hideRetryButton) {
@@ -15,7 +18,7 @@ function onPauseResumeCancelRetry (props) {
   }
   }
 }
 }
 
 
-function progressIndicatorTitle (props) {
+function progressIndicatorTitle(props: $TSFixMe) {
   if (props.isUploaded) {
   if (props.isUploaded) {
     return props.i18n('uploadComplete')
     return props.i18n('uploadComplete')
   }
   }
@@ -29,14 +32,15 @@ function progressIndicatorTitle (props) {
       return props.i18n('resumeUpload')
       return props.i18n('resumeUpload')
     }
     }
     return props.i18n('pauseUpload')
     return props.i18n('pauseUpload')
-  } if (props.individualCancellation) {
+  }
+  if (props.individualCancellation) {
     return props.i18n('cancelUpload')
     return props.i18n('cancelUpload')
   }
   }
 
 
   return ''
   return ''
 }
 }
 
 
-function ProgressIndicatorButton (props) {
+function ProgressIndicatorButton(props: $TSFixMe) {
   return (
   return (
     <div className="uppy-Dashboard-Item-progress">
     <div className="uppy-Dashboard-Item-progress">
       <button
       <button
@@ -52,7 +56,7 @@ function ProgressIndicatorButton (props) {
   )
   )
 }
 }
 
 
-function ProgressCircleContainer ({ children }) {
+function ProgressCircleContainer({ children }: $TSFixMe) {
   return (
   return (
     <svg
     <svg
       aria-hidden="true"
       aria-hidden="true"
@@ -67,7 +71,7 @@ function ProgressCircleContainer ({ children }) {
   )
   )
 }
 }
 
 
-function ProgressCircle ({ progress }) {
+function ProgressCircle({ progress }: $TSFixMe) {
   // circle length equals 2 * PI * R
   // circle length equals 2 * PI * R
   const circleLength = 2 * Math.PI * 15
   const circleLength = 2 * Math.PI * 15
 
 
@@ -90,13 +94,13 @@ function ProgressCircle ({ progress }) {
         fill="none"
         fill="none"
         stroke-width="2"
         stroke-width="2"
         stroke-dasharray={circleLength}
         stroke-dasharray={circleLength}
-        stroke-dashoffset={circleLength - ((circleLength / 100) * progress)}
+        stroke-dashoffset={circleLength - (circleLength / 100) * progress}
       />
       />
     </g>
     </g>
   )
   )
 }
 }
 
 
-export default function FileProgress (props) {
+export default function FileProgress(props: $TSFixMe): ComponentChild {
   // Nothing if upload has not started
   // Nothing if upload has not started
   if (!props.file.progress.uploadStarted) {
   if (!props.file.progress.uploadStarted) {
     return null
     return null
@@ -109,7 +113,11 @@ export default function FileProgress (props) {
         <div className="uppy-Dashboard-Item-progressIndicator">
         <div className="uppy-Dashboard-Item-progressIndicator">
           <ProgressCircleContainer>
           <ProgressCircleContainer>
             <circle r="15" cx="18" cy="18" fill="#1bb240" />
             <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>
           </ProgressCircleContainer>
         </div>
         </div>
       </div>
       </div>
@@ -125,7 +133,14 @@ export default function FileProgress (props) {
     return (
     return (
       // eslint-disable-next-line react/jsx-props-no-spreading
       // eslint-disable-next-line react/jsx-props-no-spreading
       <ProgressIndicatorButton {...props}>
       <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="M16 11a8 8 0 1 1-8-8v2a6 6 0 1 0 6 6h2z" />
           <path d="M7.9 3H10v2H7.9z" />
           <path d="M7.9 3H10v2H7.9z" />
           <path d="M8.536.5l3.535 3.536-1.414 1.414L7.12 1.914z" />
           <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}>
       <ProgressIndicatorButton {...props}>
         <ProgressCircleContainer>
         <ProgressCircleContainer>
           <ProgressCircle progress={props.file.progress.percentage} />
           <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>
         </ProgressCircleContainer>
       </ProgressIndicatorButton>
       </ProgressIndicatorButton>
@@ -158,13 +177,21 @@ export default function FileProgress (props) {
   }
   }
 
 
   // Cancel button for non-resumable uploads if individualCancellation is supported (not bundled)
   // 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 (
     return (
       // eslint-disable-next-line react/jsx-props-no-spreading
       // eslint-disable-next-line react/jsx-props-no-spreading
       <ProgressIndicatorButton {...props}>
       <ProgressIndicatorButton {...props}>
         <ProgressCircleContainer>
         <ProgressCircleContainer>
           <ProgressCircle progress={props.file.progress.percentage} />
           <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>
         </ProgressCircleContainer>
       </ProgressIndicatorButton>
       </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'
 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 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
   return field[0].name
 }
 }
 
 
-export default function renderMissingMetaFieldsError (props) {
+export default function MetaErrorMessage(props: $TSFixMe): JSX.Element {
   const { file, toggleFileCard, i18n, metaFields } = props
   const { file, toggleFileCard, i18n, metaFields } = props
   const { missingRequiredMetaFields } = file
   const { missingRequiredMetaFields } = file
   if (!missingRequiredMetaFields?.length) {
   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 (
   return (
     <div className="uppy-Dashboard-Item-errorMessage">
     <div className="uppy-Dashboard-Item-errorMessage">
       {i18n('missingRequiredMetaFields', {
       {i18n('missingRequiredMetaFields', {
         smart_count: missingRequiredMetaFields.length,
         smart_count: missingRequiredMetaFields.length,
         fields: metaFieldsString,
         fields: metaFieldsString,
-      })}
-      {' '}
+      })}{' '}
       <button
       <button
         type="button"
         type="button"
         class="uppy-u-reset uppy-Dashboard-Item-errorMessageBtn"
         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 classNames from 'classnames'
 import shallowEqual from 'is-shallow-equal'
 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 {
 export default class FileItem extends Component {
-  componentDidMount () {
+  componentDidMount(): void {
     const { file } = this.props
     const { file } = this.props
     if (!file.preview) {
     if (!file.preview) {
       this.props.handleRequestThumbnail(file)
       this.props.handleRequestThumbnail(file)
     }
     }
   }
   }
 
 
-  shouldComponentUpdate (nextProps) {
+  shouldComponentUpdate(nextProps: $TSFixMe): boolean {
     return !shallowEqual(this.props, nextProps)
     return !shallowEqual(this.props, nextProps)
   }
   }
 
 
   // VirtualList mounts FileItems again and they emit `thumbnail:request`
   // VirtualList mounts FileItems again and they emit `thumbnail:request`
   // Otherwise thumbnails are broken or missing after Golden Retriever restores files
   // Otherwise thumbnails are broken or missing after Golden Retriever restores files
-  componentDidUpdate () {
+  componentDidUpdate(): void {
     const { file } = this.props
     const { file } = this.props
     if (!file.preview) {
     if (!file.preview) {
       this.props.handleRequestThumbnail(file)
       this.props.handleRequestThumbnail(file)
     }
     }
   }
   }
 
 
-  componentWillUnmount () {
+  componentWillUnmount(): void {
     const { file } = this.props
     const { file } = this.props
     if (!file.preview) {
     if (!file.preview) {
       this.props.handleCancelThumbnail(file)
       this.props.handleCancelThumbnail(file)
     }
     }
   }
   }
 
 
-  render () {
+  render(): ComponentChild {
     const { file } = this.props
     const { file } = this.props
 
 
     const isProcessing = file.progress.preprocess || file.progress.postprocess
     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
     const error = file.error || false
 
 
     // File that Golden Retriever was able to partly restore (only meta, not blob),
     // 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
     // users still need to re-add it, so it’s a ghost
     const { isGhost } = file
     const { isGhost } = file
 
 
-    let showRemoveButton = this.props.individualCancellation
-      ? !isUploaded
+    let showRemoveButton =
+      this.props.individualCancellation ?
+        !isUploaded
       : !uploadInProgress && !isUploaded
       : !uploadInProgress && !isUploaded
 
 
     if (isUploaded && this.props.showRemoveButtonAfterComplete) {
     if (isUploaded && this.props.showRemoveButtonAfterComplete) {
@@ -89,7 +100,9 @@ export default class FileItem extends Component {
             hideCancelButton={this.props.hideCancelButton}
             hideCancelButton={this.props.hideCancelButton}
             hidePauseResumeButton={this.props.hidePauseResumeButton}
             hidePauseResumeButton={this.props.hidePauseResumeButton}
             recoveredState={this.props.recoveredState}
             recoveredState={this.props.recoveredState}
-            showRemoveButtonAfterComplete={this.props.showRemoveButtonAfterComplete}
+            showRemoveButtonAfterComplete={
+              this.props.showRemoveButtonAfterComplete
+            }
             resumableUploads={this.props.resumableUploads}
             resumableUploads={this.props.resumableUploads}
             individualCancellation={this.props.individualCancellation}
             individualCancellation={this.props.individualCancellation}
             i18n={this.props.i18n}
             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 { h } from 'preact'
 import { useMemo } from 'preact/hooks'
 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 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) {
     if (currentChunk.length < size) {
       currentChunk.push(item)
       currentChunk.push(item)
     } else {
     } else {
@@ -18,37 +22,63 @@ function chunks (list, size) {
   return chunked
   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 not great that this is hardcoded!
   // It's ESPECIALLY not great that this is checking against `itemsPerRow`!
   // 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
     : 200
 
 
   // Sort files by file.isGhost, ghost files first, only if recoveredState is present
   // Sort files by file.isGhost, ghost files first, only if recoveredState is present
   const rows = useMemo(() => {
   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)
     const fileIds = Object.keys(files)
     if (recoveredState) fileIds.sort(sortByGhostComesFirst)
     if (recoveredState) fileIds.sort(sortByGhostComesFirst)
     return chunks(fileIds, itemsPerRow)
     return chunks(fileIds, itemsPerRow)
   }, [files, itemsPerRow, recoveredState])
   }, [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.
     // associated with the `VirtualList` element.
     // We use the first file ID as the key—this should not change across scroll rerenders
     // 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]}>
     <div class="uppy-Dashboard-filesInner" role="presentation" key={row[0]}>
-      {row.map((fileID) => (
+      {row.map((fileID: $TSFixMe) => (
         <FileItem
         <FileItem
           key={fileID}
           key={fileID}
+          // @ts-expect-error it's fine
           uppy={uppy}
           uppy={uppy}
           // FIXME This is confusing, it's actually the Dashboard's plugin ID
           // FIXME This is confusing, it's actually the Dashboard's plugin ID
           id={id}
           id={id}
@@ -86,11 +116,7 @@ export default ({
   )
   )
 
 
   if (isSingleFile) {
   if (isSingleFile) {
-    return (
-      <div class="uppy-Dashboard-files">
-        {renderRow(rows[0])}
-      </div>
-    )
+    return <div class="uppy-Dashboard-files">{renderRow(rows[0])}</div>
   }
   }
 
 
   return (
   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 { 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
   const { file } = props
 
 
   if (file.preview) {
   if (file.preview) {
@@ -18,8 +20,17 @@ export default function FilePreview (props) {
 
 
   return (
   return (
     <div className="uppy-Dashboard-Item-previewIconWrap">
     <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" />
         <rect fill="#FFF" width="58" height="76" rx="3" fillRule="evenodd" />
       </svg>
       </svg>
     </div>
     </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 { h } from 'preact'
 import classNames from 'classnames'
 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 (
   return (
     <div
     <div
       className={classNames('uppy-DashboardContent-panel', className)}
       className={classNames('uppy-DashboardContent-panel', className)}
@@ -15,7 +24,11 @@ function PickerPanelContent ({ activePickerPanel, className, hideAllPanels, i18n
       onPaste={ignoreEvent}
       onPaste={ignoreEvent}
     >
     >
       <div className="uppy-DashboardContent-bar">
       <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 })}
           {i18n('importFrom', { name: activePickerPanel.name })}
         </div>
         </div>
         <button
         <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'
 import { h } from 'preact'
 
 
+type $TSFixMe = any
+
 const uploadStates = {
 const uploadStates = {
   STATE_ERROR: 'error',
   STATE_ERROR: 'error',
   STATE_WAITING: 'waiting',
   STATE_WAITING: 'waiting',
@@ -10,7 +13,12 @@ const uploadStates = {
   STATE_PAUSED: 'paused',
   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) {
   if (isAllErrored) {
     return uploadStates.STATE_ERROR
     return uploadStates.STATE_ERROR
   }
   }
@@ -26,7 +34,7 @@ function getUploadingState (isAllErrored, isAllComplete, isAllPaused, files = {}
   let state = uploadStates.STATE_WAITING
   let state = uploadStates.STATE_WAITING
   const fileIDs = Object.keys(files)
   const fileIDs = Object.keys(files)
   for (let i = 0; i < fileIDs.length; i++) {
   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 ANY files are being uploaded right now, show the uploading state.
     if (progress.uploadStarted && !progress.uploadComplete) {
     if (progress.uploadStarted && !progress.uploadComplete) {
       return uploadStates.STATE_UPLOADING
       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
     // If NO files are being preprocessed or uploaded right now, but some files are
     // being postprocessed, show the postprocess state.
     // 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
       state = uploadStates.STATE_POSTPROCESSING
     }
     }
   }
   }
   return state
   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(
   const uploadingState = getUploadingState(
     isAllErrored,
     isAllErrored,
     isAllComplete,
     isAllComplete,
@@ -58,7 +76,9 @@ function UploadStatus ({
 
 
   switch (uploadingState) {
   switch (uploadingState) {
     case 'uploading':
     case 'uploading':
-      return i18n('uploadingXFiles', { smart_count: inProgressNotPausedFiles.length })
+      return i18n('uploadingXFiles', {
+        smart_count: inProgressNotPausedFiles.length,
+      })
     case 'preprocessing':
     case 'preprocessing':
     case 'postprocessing':
     case 'postprocessing':
       return i18n('processingXFiles', { smart_count: processingFiles.length })
       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
   let { allowNewUpload } = props
   // TODO maybe this should be done in ../Dashboard.jsx, then just pass that down as `allowNewUpload`
   // TODO maybe this should be done in ../Dashboard.jsx, then just pass that down as `allowNewUpload`
   if (allowNewUpload && maxNumberOfFiles) {
   if (allowNewUpload && maxNumberOfFiles) {
@@ -85,7 +112,7 @@ function PanelTopBar (props) {
 
 
   return (
   return (
     <div className="uppy-DashboardContent-bar">
     <div className="uppy-DashboardContent-bar">
-      {!isAllComplete && !hideCancelButton ? (
+      {!isAllComplete && !hideCancelButton ?
         <button
         <button
           className="uppy-DashboardContent-back"
           className="uppy-DashboardContent-back"
           type="button"
           type="button"
@@ -93,16 +120,18 @@ function PanelTopBar (props) {
         >
         >
           {i18n('cancel')}
           {i18n('cancel')}
         </button>
         </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 */}
         {/* eslint-disable-next-line react/jsx-props-no-spreading */}
         <UploadStatus {...props} />
         <UploadStatus {...props} />
       </div>
       </div>
 
 
-      {allowNewUpload ? (
+      {allowNewUpload ?
         <button
         <button
           className="uppy-DashboardContent-addMore"
           className="uppy-DashboardContent-addMore"
           type="button"
           type="button"
@@ -110,14 +139,21 @@ function PanelTopBar (props) {
           title={i18n('addMoreFiles')}
           title={i18n('addMoreFiles')}
           onClick={() => toggleAddFilesPanel(true)}
           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" />
             <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>
           </svg>
-          <span className="uppy-DashboardContent-addMoreCaption">{i18n('addMore')}</span>
+          <span className="uppy-DashboardContent-addMoreCaption">
+            {i18n('addMore')}
+          </span>
         </button>
         </button>
-      ) : (
-        <div />
-      )}
+      : <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 { 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 StatusBarPlugin from '@uppy/status-bar'
 import GoogleDrivePlugin from '@uppy/google-drive'
 import GoogleDrivePlugin from '@uppy/google-drive'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import WebcamPlugin from '@uppy/webcam'
 import WebcamPlugin from '@uppy/webcam'
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore untyped
 import Url from '@uppy/url'
 import Url from '@uppy/url'
 
 
 import resizeObserverPolyfill from 'resize-observer-polyfill'
 import resizeObserverPolyfill from 'resize-observer-polyfill'
-import DashboardPlugin from '../lib/index.js'
+import DashboardPlugin from './index.ts'
+
+type $TSFixMe = any
 
 
 describe('Dashboard', () => {
 describe('Dashboard', () => {
   beforeAll(() => {
   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(() => {
   afterAll(() => {
+    // @ts-expect-error we're touching globals for the test
     delete globalThis.ResizeObserver
     delete globalThis.ResizeObserver
   })
   })
 
 
@@ -48,7 +58,10 @@ describe('Dashboard', () => {
         inline: true,
         inline: true,
         target: 'body',
         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()
     }).not.toThrow()
 
 
     core.close()
     core.close()
@@ -75,12 +88,15 @@ describe('Dashboard', () => {
     core.use(DashboardPlugin, { inline: false })
     core.use(DashboardPlugin, { inline: false })
     core.use(WebcamPlugin)
     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
     // two built-in plugins + these ones below
     expect(dashboardPlugins.length).toEqual(4)
     expect(dashboardPlugins.length).toEqual(4)
     expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true)
     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()
     core.close()
   })
   })
@@ -92,12 +108,15 @@ describe('Dashboard', () => {
     core.use(DashboardPlugin, { inline: false })
     core.use(DashboardPlugin, { inline: false })
     core.use(WebcamPlugin, { target: 'body' })
     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
     // two built-in plugins + these ones below
     expect(dashboardPlugins.length).toEqual(3)
     expect(dashboardPlugins.length).toEqual(3)
     expect(dashboardPlugins.some((plugin) => plugin.id === 'Url')).toEqual(true)
     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()
     core.close()
   })
   })
@@ -109,13 +128,11 @@ describe('Dashboard', () => {
       target: 'body',
       target: 'body',
     })
     })
 
 
-    core.getPlugin('Dashboard').setOptions({
+    core.getPlugin('Dashboard')!.setOptions({
       width: 300,
       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()', () => {
   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', () => {
   it('should accept a callback as `metaFields` option', () => {
     const core = new Core()
     const core = new Core()
     expect(() => {
     expect(() => {
       core.use(DashboardPlugin, {
       core.use(DashboardPlugin, {
-        metaFields: (file) => {
+        metaFields: (file: any) => {
           const fields = [{ id: 'name', name: 'File name' }]
           const fields = [{ id: 'name', name: 'File name' }]
           if (file.type.startsWith('image/')) {
           if (file.type.startsWith('image/')) {
             fields.push({ id: 'location', name: 'Photo Location' })
             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 { describe, it, expect } from 'vitest'
-import copyToClipboard from './copyToClipboard.js'
+import copyToClipboard from './copyToClipboard.ts'
 
 
 describe('copyToClipboard', () => {
 describe('copyToClipboard', () => {
   it.skip('should copy the specified text to the clipboard', () => {
   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
  * @param {string} fallbackString
  * @returns {Promise}
  * @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')
     const textArea = document.createElement('textarea')
     textArea.setAttribute('style', {
     textArea.setAttribute('style', {
       position: 'fixed',
       position: 'fixed',
@@ -22,13 +28,13 @@ export default function copyToClipboard (textToCopy, fallbackString = 'Copy the
       outline: 'none',
       outline: 'none',
       boxShadow: 'none',
       boxShadow: 'none',
       background: 'transparent',
       background: 'transparent',
-    })
+    } as $TSFixMe as string)
 
 
     textArea.value = textToCopy
     textArea.value = textToCopy
     document.body.appendChild(textArea)
     document.body.appendChild(textArea)
     textArea.select()
     textArea.select()
 
 
-    const magicCopyFailed = () => {
+    const magicCopyFailed = (cause?: unknown) => {
       document.body.removeChild(textArea)
       document.body.removeChild(textArea)
       // eslint-disable-next-line no-alert
       // eslint-disable-next-line no-alert
       window.prompt(fallbackString, textToCopy)
       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 { describe, it, expect } from 'vitest'
-import createSuperFocus from './createSuperFocus.js'
+import createSuperFocus from './createSuperFocus.ts'
 
 
 describe('createSuperFocus', () => {
 describe('createSuperFocus', () => {
   // superFocus.cancel() is used in dashboard
   // 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'
 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 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.
   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
   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.
      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
   let lastFocusWasOnSuperFocusableEl = false
 
 
-  const superFocus = (dashboardEl, activeOverlayType) => {
+  const superFocus = (dashboardEl: $TSFixMe, activeOverlayType: $TSFixMe) => {
     const overlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
     const overlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
 
 
     const isFocusInOverlay = overlayEl.contains(document.activeElement)
     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.
     // [Practical check] without this line, typing in the search input in googledrive overlay won't work.
     if (isFocusInOverlay && lastFocusWasOnSuperFocusableEl) return
     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.
     // 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
     // [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.
     // 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 —
 // draging UI elements or pasting anything into any field triggers those events —
 // Url treats them as URLs that need to be imported
 // 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
   const { tagName } = ev.target
-  if (tagName === 'INPUT'
-      || tagName === 'TEXTAREA') {
+  if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
     ev.stopPropagation()
     ev.stopPropagation()
     return
     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'
 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 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]
   const node = nodes[0]
   if (node) {
   if (node) {
     node.focus()
     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]
   const node = nodes[nodes.length - 1]
   if (node) {
   if (node) {
     node.focus()
     node.focus()
@@ -24,13 +28,19 @@ function focusOnLastNode (event, nodes) {
 //    active overlay!
 //    active overlay!
 //    [Practical check] if we use (focusedItemIndex === -1), instagram provider in firefox will never get focus on its pics
 //    [Practical check] if we use (focusedItemIndex === -1), instagram provider in firefox will never get focus on its pics
 //    in the <ul>.
 //    in the <ul>.
-function isFocusInOverlay (activeOverlayEl) {
+function isFocusInOverlay(activeOverlayEl: $TSFixMe) {
   return activeOverlayEl.contains(document.activeElement)
   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 activeOverlayEl = getActiveOverlayEl(dashboardEl, activeOverlayType)
-  const focusableNodes = toArray(activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS))
+  const focusableNodes = toArray(
+    activeOverlayEl.querySelectorAll(FOCUSABLE_ELEMENTS),
+  )
 
 
   const focusedItemIndex = focusableNodes.indexOf(document.activeElement)
   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.
   // plugins will try to focus on some important element as it loads.
   if (!isFocusInOverlay(activeOverlayEl)) {
   if (!isFocusInOverlay(activeOverlayEl)) {
     focusOnFirstNode(event, focusableNodes)
     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) {
   } else if (event.shiftKey && focusedItemIndex === 0) {
     focusOnLastNode(event, focusableNodes)
     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)
     focusOnFirstNode(event, focusableNodes)
   }
   }
 }
 }
 
 
 // Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram),
 // Traps focus inside of the currently open overlay (e.g. Dashboard, or e.g. Instagram),
 // never lets focus disappear from the modal.
 // 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.
 // 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
   // ___When we're in the bare 'Drop files here, paste, browse or import from' screen
   if (activeOverlayType === null) {
   if (activeOverlayType === null) {
     // Do nothing and let the browser handle it, user can tab away from Uppy to other elements on the page
     // 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",
+    },
+  ],
+}

Some files were not shown because too many files changed in this diff